Add iPhone local favorites

This commit is contained in:
diyaa
2026-05-31 13:20:07 +02:00
parent cb556ccf0a
commit 3d7252ce6d
8 changed files with 1103 additions and 14 deletions
@@ -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
}
}
@@ -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"])
}
}