Add iPhone local favorites
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
public struct FavoriteTrackRecord: Codable, Hashable, Sendable {
|
||||
public var remoteTrackId: String
|
||||
public var favoritedAt: Date
|
||||
|
||||
public init(
|
||||
remoteTrackId: String,
|
||||
favoritedAt: Date
|
||||
) {
|
||||
self.remoteTrackId = remoteTrackId
|
||||
self.favoritedAt = favoritedAt
|
||||
}
|
||||
}
|
||||
|
||||
public protocol FavoriteTrackStore: Actor {
|
||||
func loadFavoriteTracks() async throws -> [FavoriteTrackRecord]
|
||||
func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws
|
||||
}
|
||||
|
||||
public extension FavoriteTrackStore {
|
||||
func saveFavoriteTrack(_ track: FavoriteTrackRecord) async throws {
|
||||
var tracks = try await loadFavoriteTracks()
|
||||
|
||||
if let existingIndex = tracks.firstIndex(where: { $0.remoteTrackId == track.remoteTrackId }) {
|
||||
tracks[existingIndex] = track
|
||||
} else {
|
||||
tracks.append(track)
|
||||
}
|
||||
|
||||
try await saveFavoriteTracks(tracks)
|
||||
}
|
||||
|
||||
func removeFavoriteTrack(remoteTrackId: String) async throws {
|
||||
let tracks = try await loadFavoriteTracks()
|
||||
.filter { $0.remoteTrackId != remoteTrackId }
|
||||
try await saveFavoriteTracks(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
public actor FileFavoriteTrackStore: FavoriteTrackStore {
|
||||
private let fileURL: URL
|
||||
private let fileManager: FileManager
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
public init(
|
||||
fileURL: URL? = nil,
|
||||
fileManager: FileManager = .default
|
||||
) throws {
|
||||
self.fileManager = fileManager
|
||||
if let fileURL {
|
||||
self.fileURL = fileURL
|
||||
} else {
|
||||
self.fileURL = try Self.defaultFileURL(fileManager: fileManager)
|
||||
}
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
public func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] {
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
return try decoder.decode([FavoriteTrackRecord].self, from: data)
|
||||
}
|
||||
|
||||
public func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws {
|
||||
try fileManager.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let sortedTracks = tracks.sorted { lhs, rhs in
|
||||
lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending
|
||||
}
|
||||
let data = try encoder.encode(sortedTracks)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
private static func defaultFileURL(fileManager: FileManager) throws -> URL {
|
||||
guard let applicationSupportURL = fileManager.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
|
||||
return applicationSupportURL
|
||||
.appendingPathComponent("Velody", isDirectory: true)
|
||||
.appendingPathComponent("favorite-tracks.json")
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryFavoriteTrackStore: FavoriteTrackStore {
|
||||
private var tracks: [FavoriteTrackRecord]
|
||||
|
||||
public init(tracks: [FavoriteTrackRecord] = []) {
|
||||
self.tracks = tracks
|
||||
}
|
||||
|
||||
public func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] {
|
||||
tracks
|
||||
}
|
||||
|
||||
public func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws {
|
||||
self.tracks = tracks
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import VelodyPersistence
|
||||
|
||||
final class FavoriteTrackStoreTests: XCTestCase {
|
||||
func testFavoriteTrackStoreUpsertsAndRemovesByRemoteTrackID() async throws {
|
||||
let store = InMemoryFavoriteTrackStore()
|
||||
let originalFavorite = FavoriteTrackRecord(
|
||||
remoteTrackId: "track-123",
|
||||
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
let updatedFavorite = FavoriteTrackRecord(
|
||||
remoteTrackId: "track-123",
|
||||
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
||||
)
|
||||
let secondFavorite = FavoriteTrackRecord(
|
||||
remoteTrackId: "track-456",
|
||||
favoritedAt: Date(timeIntervalSince1970: 3_000)
|
||||
)
|
||||
|
||||
try await store.saveFavoriteTrack(originalFavorite)
|
||||
try await store.saveFavoriteTrack(updatedFavorite)
|
||||
try await store.saveFavoriteTrack(secondFavorite)
|
||||
|
||||
let savedFavorites = try await store.loadFavoriteTracks()
|
||||
|
||||
XCTAssertEqual(savedFavorites.count, 2)
|
||||
XCTAssertTrue(savedFavorites.contains(updatedFavorite))
|
||||
XCTAssertTrue(savedFavorites.contains(secondFavorite))
|
||||
|
||||
try await store.removeFavoriteTrack(remoteTrackId: "track-123")
|
||||
let remainingFavorites = try await store.loadFavoriteTracks()
|
||||
|
||||
XCTAssertEqual(remainingFavorites, [secondFavorite])
|
||||
}
|
||||
|
||||
func testFileFavoriteTrackStorePersistsAcrossInstances() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
||||
let favorites = [
|
||||
FavoriteTrackRecord(
|
||||
remoteTrackId: "track-123",
|
||||
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||
),
|
||||
FavoriteTrackRecord(
|
||||
remoteTrackId: "track-456",
|
||||
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
||||
),
|
||||
]
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let firstStore = try FileFavoriteTrackStore(fileURL: fileURL)
|
||||
try await firstStore.saveFavoriteTracks(favorites)
|
||||
|
||||
let secondStore = try FileFavoriteTrackStore(fileURL: fileURL)
|
||||
let restoredFavorites = try await secondStore.loadFavoriteTracks()
|
||||
|
||||
XCTAssertEqual(restoredFavorites, favorites)
|
||||
}
|
||||
|
||||
func testFileFavoriteTrackStoreWritesAtomicallyAndOverwritesOldContents() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
||||
let firstFavorites = [
|
||||
FavoriteTrackRecord(
|
||||
remoteTrackId: "track-b",
|
||||
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
||||
),
|
||||
FavoriteTrackRecord(
|
||||
remoteTrackId: "track-a",
|
||||
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||
),
|
||||
]
|
||||
let secondFavorites = [
|
||||
FavoriteTrackRecord(
|
||||
remoteTrackId: "track-c",
|
||||
favoritedAt: Date(timeIntervalSince1970: 3_000)
|
||||
),
|
||||
]
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let store = try FileFavoriteTrackStore(fileURL: fileURL)
|
||||
try await store.saveFavoriteTracks(firstFavorites)
|
||||
try await store.saveFavoriteTracks(secondFavorites)
|
||||
|
||||
let rawData = try Data(contentsOf: fileURL)
|
||||
let restoredFavorites = try decoder.decode([FavoriteTrackRecord].self, from: rawData)
|
||||
let directoryContents = try fileManager.contentsOfDirectory(atPath: tempDirectory.path)
|
||||
let json = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: rawData) as? [[String: Any]]
|
||||
)
|
||||
|
||||
XCTAssertEqual(restoredFavorites, secondFavorites)
|
||||
XCTAssertEqual(directoryContents.sorted(), ["favorite-tracks.json"])
|
||||
XCTAssertEqual(Set(json[0].keys), ["favoritedAt", "remoteTrackId"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user