diff --git a/.gitignore b/.gitignore index df7e993..9956104 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,7 @@ playground.xcworkspace # Package.resolved packages/apple/**/.build/ **/.build/ + +# Xcode local build artifacts +.derivedData/ +DerivedData/ diff --git a/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift b/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift index 63b60fd..a73642f 100644 --- a/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift +++ b/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift @@ -1,6 +1,6 @@ import AVFoundation +import CryptoKit import Foundation -import VelodyDomain import VelodyPersistence final class AVFoundationMetadataReader: MetadataReader { @@ -20,17 +20,20 @@ final class AVFoundationMetadataReader: MetadataReader { final class FileSystemLocalMusicScanner: LocalMusicScanner { private let metadataReader: any MetadataReader + private let fileHasher: SHA256FileHasher private let fileManager: FileManager init( metadataReader: any MetadataReader, + fileHasher: SHA256FileHasher = SHA256FileHasher(), fileManager: FileManager = .default ) { self.metadataReader = metadataReader + self.fileHasher = fileHasher self.fileManager = fileManager } - func scanFolder(at folderURL: URL) async throws -> [LibraryTrack] { + func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack] { let hasScopedAccess = folderURL.startAccessingSecurityScopedResource() defer { if hasScopedAccess { @@ -40,13 +43,16 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { guard let enumerator = fileManager.enumerator( at: folderURL, - includingPropertiesForKeys: [.isRegularFileKey], + includingPropertiesForKeys: [ + .isRegularFileKey, + .contentModificationDateKey, + ], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { throw CocoaError(.fileReadUnknown) } - var discoveredTracks: [LibraryTrack] = [] + var discoveredTracks: [ScannedLocalTrack] = [] for case let fileURL as URL in enumerator { guard shouldScan(fileURL) else { @@ -54,30 +60,32 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { } let fallbackTitle = fileURL.deletingPathExtension().lastPathComponent + let sha256 = try fileHasher.hashFile(at: fileURL) + let fileModifiedAt = modificationDate(for: fileURL) do { let metadata = try await metadataReader.readMetadata(for: fileURL) discoveredTracks.append( - LibraryTrack( - id: fileURL.path, + ScannedLocalTrack( title: metadata.title?.trimmedNonEmpty ?? fallbackTitle, artist: metadata.artist?.trimmedNonEmpty ?? "Unknown Artist", album: metadata.album?.trimmedNonEmpty, durationSeconds: metadata.durationSeconds, localFilePath: fileURL.path, - sha256: nil + sha256: sha256, + fileModifiedAt: fileModifiedAt ) ) } catch { discoveredTracks.append( - LibraryTrack( - id: fileURL.path, + ScannedLocalTrack( title: fallbackTitle, artist: "Unknown Artist", album: nil, durationSeconds: nil, localFilePath: fileURL.path, - sha256: nil + sha256: sha256, + fileModifiedAt: fileModifiedAt ) ) } @@ -101,6 +109,46 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]) return resourceValues?.isRegularFile == true } + + private func modificationDate(for fileURL: URL) -> Date { + if let resourceValues = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]), + let contentModificationDate = resourceValues.contentModificationDate + { + return contentModificationDate + } + + if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let modificationDate = attributes[.modificationDate] as? Date + { + return modificationDate + } + + return .distantPast + } +} + +final class SHA256FileHasher { + private let bufferSize = 1_048_576 + + func hashFile(at fileURL: URL) throws -> String { + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { + try? fileHandle.close() + } + + var hasher = SHA256() + + while true { + let data = try fileHandle.read(upToCount: bufferSize) ?? Data() + if data.isEmpty { + break + } + + hasher.update(data: data) + } + + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } } private extension Array where Element == AVMetadataItem { diff --git a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift index 1057a55..06a138e 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift @@ -26,10 +26,10 @@ final class MacLibraryViewModel { var isRunningSyncBootstrap = false private let folderAccessService: any VelodyPersistence.FolderAccessService + private let catalogService: any LocalCatalogService private let localMusicScanner: any LocalMusicScanner private let keychainService: any KeychainService private let userDefaults: UserDefaults - private let store = InMemoryLocalLibraryStore() private var hasLoaded = false init( @@ -40,8 +40,10 @@ final class MacLibraryViewModel { let localMusicScanner = FileSystemLocalMusicScanner( metadataReader: AVFoundationMetadataReader() ) + let repository = Self.makeTrackRepository() self.folderAccessService = folderAccessService + self.catalogService = DefaultLocalCatalogService(repository: repository) self.localMusicScanner = localMusicScanner self.keychainService = keychainService self.userDefaults = userDefaults @@ -57,8 +59,13 @@ final class MacLibraryViewModel { func loadIfNeeded() async { guard !hasLoaded else { return } hasLoaded = true - tracks = await store.loadTracks() - discoveredTrackCount = tracks.count + do { + tracks = try await catalogService.loadActiveLocalTracks() + discoveredTrackCount = tracks.count + } catch { + scanStatus = "Failed to load saved catalog: \(error.localizedDescription)" + } + await restoreDeviceIdentity() } @@ -69,7 +76,11 @@ final class MacLibraryViewModel { tracks = [] discoveredTrackCount = 0 Task { - await store.replaceTracks([]) + do { + try await catalogService.resetLocalCatalog() + } catch { + scanStatus = "Failed to reset saved catalog: \(error.localizedDescription)" + } } } } @@ -88,10 +99,17 @@ final class MacLibraryViewModel { do { let discoveredTracks = try await localMusicScanner.scanFolder(at: folderURL) - await store.replaceTracks(discoveredTracks) - tracks = await store.loadTracks() + let scanResult = try await catalogService.reconcileScanResults( + discoveredTracks, + in: folderURL, + scannedAt: Date() + ) + tracks = scanResult.tracks discoveredTrackCount = tracks.count - scanStatus = "Scan finished. Found \(discoveredTrackCount) MP3 file(s)." + scanStatus = Self.scanStatus( + for: scanResult, + activeTrackCount: discoveredTrackCount + ) } catch { scanStatus = "Scan failed: \(error.localizedDescription)" } @@ -231,6 +249,21 @@ final class MacLibraryViewModel { URLSessionVelodyAPIClient(environment: environment) } + private static func makeTrackRepository() -> any TrackRepository { + if let repository = try? SwiftDataTrackRepository() { + return repository + } + + return InMemoryTrackRepository() + } + + private static func scanStatus( + for result: LocalCatalogScanResult, + activeTrackCount: Int + ) -> String { + "Scan finished. Active: \(activeTrackCount). Added: \(result.insertedTrackCount). Updated: \(result.updatedTrackCount). Reactivated: \(result.reactivatedTrackCount). Deleted: \(result.deletedTrackCount). Duplicates skipped: \(result.skippedDuplicateTrackCount)." + } + private static var currentAppVersion: String { if let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, !shortVersion.isEmpty diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 1c12ca0..753a3c6 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -11,25 +11,38 @@ final class iPhoneLibraryViewModel { var tracks: [LibraryTrack] = [] var syncStatus = "Offline library not synced yet" - private let store = InMemoryLocalLibraryStore() + private let store: any LocalLibraryStore private let syncCoordinator: PlaceholderSyncCoordinator private var hasLoaded = false init() { + let repository = Self.makeTrackRepository() let environment = ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "0.1.0" ) let apiClient = StubVelodyAPIClient(environment: environment) + self.store = repository self.syncCoordinator = PlaceholderSyncCoordinator( apiClient: apiClient, - store: store + store: repository ) } func loadIfNeeded() async { guard !hasLoaded else { return } hasLoaded = true + + do { + let persistedTracks = try await store.loadTracks() + if !persistedTracks.isEmpty { + tracks = persistedTracks + syncStatus = "Loaded \(persistedTracks.count) cached track(s) from local storage." + } + } catch { + syncStatus = "Failed to load cached catalog: \(error.localizedDescription)" + } + await refreshSync() } @@ -42,4 +55,12 @@ final class iPhoneLibraryViewModel { syncStatus = "Sync placeholder failed: \(error.localizedDescription)" } } + + private static func makeTrackRepository() -> any TrackRepository { + if let repository = try? SwiftDataTrackRepository() { + return repository + } + + return InMemoryTrackRepository() + } } diff --git a/backend/prisma/migrations/20260528090629_add_user_ownership_and_local_catalog/migration.sql b/backend/prisma/migrations/20260528090629_add_user_ownership_and_local_catalog/migration.sql new file mode 100644 index 0000000..8b2a348 --- /dev/null +++ b/backend/prisma/migrations/20260528090629_add_user_ownership_and_local_catalog/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "artwork_assets" ALTER COLUMN "id" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "audio_assets" ALTER COLUMN "id" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "device_sync_cursors" ALTER COLUMN "updated_at" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "devices" ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "updated_at" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "tracks" ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "updated_at" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "upload_sessions" ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "updated_at" DROP DEFAULT; + +-- RenameIndex +ALTER INDEX "artwork_assets_storage_key" RENAME TO "artwork_assets_storage_key_key"; + +-- RenameIndex +ALTER INDEX "audio_assets_storage_key" RENAME TO "audio_assets_storage_key_key"; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index 1eed3a5..044d57c 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -1,2 +1,3 @@ # Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) provider = "postgresql" diff --git a/packages/apple/VelodyPersistence/Package.swift b/packages/apple/VelodyPersistence/Package.swift index 02f86f1..6326e14 100644 --- a/packages/apple/VelodyPersistence/Package.swift +++ b/packages/apple/VelodyPersistence/Package.swift @@ -21,5 +21,12 @@ let package = Package( name: "VelodyPersistence", dependencies: ["VelodyDomain"] ), + .testTarget( + name: "VelodyPersistenceTests", + dependencies: [ + "VelodyDomain", + "VelodyPersistence", + ] + ), ] ) diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift new file mode 100644 index 0000000..d6ba5b8 --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift @@ -0,0 +1,231 @@ +import Foundation +import VelodyDomain + +public protocol LocalCatalogService: Sendable { + func loadActiveLocalTracks() async throws -> [LibraryTrack] + func reconcileScanResults( + _ scannedTracks: [ScannedLocalTrack], + in folderURL: URL, + scannedAt: Date + ) async throws -> LocalCatalogScanResult + func resetLocalCatalog() async throws +} + +public actor DefaultLocalCatalogService: LocalCatalogService { + private let repository: any TrackRepository + + public init(repository: any TrackRepository) { + self.repository = repository + } + + public func loadActiveLocalTracks() async throws -> [LibraryTrack] { + try await repository.loadLocalTracks( + origin: .localScan, + includeDeleted: false + ).map(\.libraryTrack) + } + + public func reconcileScanResults( + _ scannedTracks: [ScannedLocalTrack], + in folderURL: URL, + scannedAt: Date = Date() + ) async throws -> LocalCatalogScanResult { + let groupedTracks = Dictionary(grouping: scannedTracks, by: \.deduplicationKey) + let presentFilePaths = Set(scannedTracks.map(\.localFilePath)) + + var insertedTrackCount = 0 + var updatedTrackCount = 0 + var reactivatedTrackCount = 0 + var deletedTrackCount = 0 + var unchangedTrackCount = 0 + + for deduplicationKey in groupedTracks.keys.sorted() { + guard let groupedDuplicates = groupedTracks[deduplicationKey] else { + continue + } + + let existingTrack = try await repository.findTrack( + deduplicationKey: deduplicationKey + ) + let chosenTrack = preferredTrack( + from: groupedDuplicates, + preferredPath: existingTrack?.localFilePath + ) + + let existingTrackByPath = try await repository.findTrack( + localFilePath: chosenTrack.localFilePath, + origin: .localScan + ) + + let resolution = try await persistChosenTrack( + chosenTrack, + existingTrackBySHA: existingTrack, + existingTrackByPath: existingTrackByPath, + scannedAt: scannedAt + ) + + switch resolution { + case .inserted: + insertedTrackCount += 1 + case .updated: + updatedTrackCount += 1 + case .reactivated: + reactivatedTrackCount += 1 + case .unchanged: + unchangedTrackCount += 1 + case .merged(let additionalDeletedTrackCount): + updatedTrackCount += 1 + deletedTrackCount += additionalDeletedTrackCount + case .mergedReactivated(let additionalDeletedTrackCount): + reactivatedTrackCount += 1 + deletedTrackCount += additionalDeletedTrackCount + } + } + + deletedTrackCount += try await repository.markDeletedLocalTracks( + missingFrom: presentFilePaths, + under: folderURL.path, + scannedAt: scannedAt + ) + + let activeTracks = try await loadActiveLocalTracks() + + return LocalCatalogScanResult( + tracks: activeTracks, + insertedTrackCount: insertedTrackCount, + updatedTrackCount: updatedTrackCount, + reactivatedTrackCount: reactivatedTrackCount, + deletedTrackCount: deletedTrackCount, + skippedDuplicateTrackCount: scannedTracks.count - groupedTracks.count, + unchangedTrackCount: unchangedTrackCount + ) + } + + public func resetLocalCatalog() async throws { + try await repository.removeTracks(origin: .localScan) + } + + private func persistChosenTrack( + _ scannedTrack: ScannedLocalTrack, + existingTrackBySHA: LocalTrack?, + existingTrackByPath: LocalTrack?, + scannedAt: Date + ) async throws -> TrackResolution { + if let existingTrackBySHA, + let existingTrackByPath, + existingTrackBySHA.id != existingTrackByPath.id + { + let canonicalTrack = mergedTrack( + existingTrackBySHA, + with: scannedTrack, + scannedAt: scannedAt + ) + + try await repository.saveLocalTrack(canonicalTrack.track) + + if !existingTrackByPath.isDeleted { + var supersededTrack = existingTrackByPath + supersededTrack.isDeleted = true + supersededTrack.deletedAt = scannedAt + supersededTrack.lastScannedAt = scannedAt + supersededTrack.updatedAt = scannedAt + try await repository.saveLocalTrack(supersededTrack) + } + + return canonicalTrack.wasDeleted + ? .mergedReactivated(additionalDeletedTrackCount: existingTrackByPath.isDeleted ? 0 : 1) + : .merged(additionalDeletedTrackCount: existingTrackByPath.isDeleted ? 0 : 1) + } + + if let existingTrack = existingTrackBySHA ?? existingTrackByPath { + let merged = mergedTrack(existingTrack, with: scannedTrack, scannedAt: scannedAt) + try await repository.saveLocalTrack(merged.track) + + if merged.wasDeleted { + return .reactivated + } + + return merged.didChange ? .updated : .unchanged + } + + try await repository.saveLocalTrack( + LocalTrack( + scannedTrack: scannedTrack, + observedAt: scannedAt + ) + ) + + return .inserted + } + + private func preferredTrack( + from tracks: [ScannedLocalTrack], + preferredPath: String? + ) -> ScannedLocalTrack { + if let preferredPath, + let matchingTrack = tracks.first(where: { $0.localFilePath == preferredPath }) + { + return matchingTrack + } + + return tracks.sorted { lhs, rhs in + lhs.localFilePath.localizedCaseInsensitiveCompare(rhs.localFilePath) == .orderedAscending + }.first ?? tracks[0] + } + + private func mergedTrack( + _ existingTrack: LocalTrack, + with scannedTrack: ScannedLocalTrack, + scannedAt: Date + ) -> MergedTrack { + var updatedTrack = existingTrack + let wasDeleted = existingTrack.isDeleted + let didChange = + existingTrack.origin != .localScan + || existingTrack.title != scannedTrack.title + || existingTrack.artist != scannedTrack.artist + || existingTrack.album != scannedTrack.album + || existingTrack.durationSeconds != scannedTrack.durationSeconds + || existingTrack.localFilePath != scannedTrack.localFilePath + || existingTrack.sha256 != scannedTrack.sha256 + || existingTrack.fileModifiedAt != scannedTrack.fileModifiedAt + || existingTrack.isDeleted + + updatedTrack.origin = .localScan + updatedTrack.title = scannedTrack.title + updatedTrack.artist = scannedTrack.artist + updatedTrack.album = scannedTrack.album + updatedTrack.durationSeconds = scannedTrack.durationSeconds + updatedTrack.localFilePath = scannedTrack.localFilePath + updatedTrack.sha256 = scannedTrack.sha256 + updatedTrack.fileModifiedAt = scannedTrack.fileModifiedAt + updatedTrack.lastScannedAt = scannedAt + updatedTrack.isDeleted = false + updatedTrack.deletedAt = nil + + if didChange { + updatedTrack.updatedAt = scannedAt + } + + return MergedTrack( + track: updatedTrack, + didChange: didChange, + wasDeleted: wasDeleted + ) + } +} + +private struct MergedTrack { + let track: LocalTrack + let didChange: Bool + let wasDeleted: Bool +} + +private enum TrackResolution { + case inserted + case updated + case reactivated + case unchanged + case merged(additionalDeletedTrackCount: Int) + case mergedReactivated(additionalDeletedTrackCount: Int) +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift index 7187211..9ce12c3 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift @@ -2,22 +2,6 @@ import Foundation import VelodyDomain public protocol LocalLibraryStore: Sendable { - func loadTracks() async -> [LibraryTrack] - func replaceTracks(_ tracks: [LibraryTrack]) async -} - -public actor InMemoryLocalLibraryStore: LocalLibraryStore { - private var tracks: [LibraryTrack] - - public init(tracks: [LibraryTrack] = []) { - self.tracks = tracks - } - - public func loadTracks() async -> [LibraryTrack] { - tracks - } - - public func replaceTracks(_ tracks: [LibraryTrack]) async { - self.tracks = tracks - } + func loadTracks() async throws -> [LibraryTrack] + func replaceTracks(_ tracks: [LibraryTrack]) async throws } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift index fb72120..8fd3aa5 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift @@ -31,5 +31,5 @@ public protocol MetadataReader { } public protocol LocalMusicScanner { - func scanFolder(at folderURL: URL) async throws -> [LibraryTrack] + func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack] } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift new file mode 100644 index 0000000..3f8b301 --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift @@ -0,0 +1,204 @@ +import Foundation +import VelodyDomain + +public enum LocalTrackOrigin: String, Codable, Hashable, Sendable { + case localScan + case syncBootstrap +} + +public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { + public var id: String + public var origin: LocalTrackOrigin + public var title: String + public var artist: String + public var album: String? + public var durationSeconds: Double? + public var localFilePath: String + public var sha256: String? + public var fileModifiedAt: Date? + public var lastScannedAt: Date? + public var isDeleted: Bool + public var deletedAt: Date? + public var createdAt: Date + public var updatedAt: Date + + public init( + id: String, + origin: LocalTrackOrigin, + title: String, + artist: String, + album: String? = nil, + durationSeconds: Double? = nil, + localFilePath: String = "", + sha256: String? = nil, + fileModifiedAt: Date? = nil, + lastScannedAt: Date? = nil, + isDeleted: Bool = false, + deletedAt: Date? = nil, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.origin = origin + self.title = title + self.artist = artist + self.album = album + self.durationSeconds = durationSeconds + self.localFilePath = localFilePath + self.sha256 = sha256 + self.fileModifiedAt = fileModifiedAt + self.lastScannedAt = lastScannedAt + self.isDeleted = isDeleted + self.deletedAt = deletedAt + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + public init( + scannedTrack: ScannedLocalTrack, + id: String = UUID().uuidString, + observedAt: Date = Date() + ) { + self.init( + id: id, + origin: .localScan, + title: scannedTrack.title, + artist: scannedTrack.artist, + album: scannedTrack.album, + durationSeconds: scannedTrack.durationSeconds, + localFilePath: scannedTrack.localFilePath, + sha256: scannedTrack.sha256, + fileModifiedAt: scannedTrack.fileModifiedAt, + lastScannedAt: observedAt, + isDeleted: false, + deletedAt: nil, + createdAt: observedAt, + updatedAt: observedAt + ) + } + + public init( + libraryTrack: LibraryTrack, + origin: LocalTrackOrigin, + observedAt: Date = Date() + ) { + self.init( + id: libraryTrack.id, + origin: origin, + title: libraryTrack.title, + artist: libraryTrack.artist, + album: libraryTrack.album, + durationSeconds: libraryTrack.durationSeconds, + localFilePath: libraryTrack.localFilePath, + sha256: libraryTrack.sha256, + fileModifiedAt: nil, + lastScannedAt: origin == .localScan ? observedAt : nil, + isDeleted: false, + deletedAt: nil, + createdAt: observedAt, + updatedAt: observedAt + ) + } + + public var deduplicationKey: String { + Self.deduplicationKey( + origin: origin, + sha256: sha256, + id: id + ) + } + + public var libraryTrack: LibraryTrack { + LibraryTrack( + id: id, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + localFilePath: localFilePath, + sha256: sha256 + ) + } + + public static func deduplicationKey( + origin: LocalTrackOrigin, + sha256: String?, + id: String + ) -> String { + switch origin { + case .localScan: + if let sha256, !sha256.isEmpty { + return localScanDeduplicationKey(for: sha256) + } + + return "localScan-id::\(id)" + case .syncBootstrap: + return "syncBootstrap-id::\(id)" + } + } + + public static func localScanDeduplicationKey(for sha256: String) -> String { + "localScan::\(sha256)" + } +} + +public struct ScannedLocalTrack: Identifiable, Hashable, Sendable { + public var id: String { localFilePath } + public var title: String + public var artist: String + public var album: String? + public var durationSeconds: Double? + public var localFilePath: String + public var sha256: String + public var fileModifiedAt: Date + + public init( + title: String, + artist: String, + album: String? = nil, + durationSeconds: Double? = nil, + localFilePath: String, + sha256: String, + fileModifiedAt: Date + ) { + self.title = title + self.artist = artist + self.album = album + self.durationSeconds = durationSeconds + self.localFilePath = localFilePath + self.sha256 = sha256 + self.fileModifiedAt = fileModifiedAt + } + + public var deduplicationKey: String { + LocalTrack.localScanDeduplicationKey(for: sha256) + } +} + +public struct LocalCatalogScanResult: Hashable, Sendable { + public var tracks: [LibraryTrack] + public var insertedTrackCount: Int + public var updatedTrackCount: Int + public var reactivatedTrackCount: Int + public var deletedTrackCount: Int + public var skippedDuplicateTrackCount: Int + public var unchangedTrackCount: Int + + public init( + tracks: [LibraryTrack], + insertedTrackCount: Int, + updatedTrackCount: Int, + reactivatedTrackCount: Int, + deletedTrackCount: Int, + skippedDuplicateTrackCount: Int, + unchangedTrackCount: Int + ) { + self.tracks = tracks + self.insertedTrackCount = insertedTrackCount + self.updatedTrackCount = updatedTrackCount + self.reactivatedTrackCount = reactivatedTrackCount + self.deletedTrackCount = deletedTrackCount + self.skippedDuplicateTrackCount = skippedDuplicateTrackCount + self.unchangedTrackCount = unchangedTrackCount + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift new file mode 100644 index 0000000..d805368 --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift @@ -0,0 +1,76 @@ +import Foundation +import SwiftData + +@Model +final class TrackEntity { + @Attribute(.unique) var trackID: String + @Attribute(.unique) var deduplicationKey: String + var originRawValue: String + var title: String + var artist: String + var album: String? + var durationSeconds: Double? + var localFilePath: String + var sha256: String? + var fileModifiedAt: Date? + var lastScannedAt: Date? + var isMarkedDeleted: Bool + var deletedAt: Date? + var createdAt: Date + var updatedAt: Date + + init(track: LocalTrack) { + trackID = track.id + deduplicationKey = track.deduplicationKey + originRawValue = track.origin.rawValue + title = track.title + artist = track.artist + album = track.album + durationSeconds = track.durationSeconds + localFilePath = track.localFilePath + sha256 = track.sha256 + fileModifiedAt = track.fileModifiedAt + lastScannedAt = track.lastScannedAt + isMarkedDeleted = track.isDeleted + deletedAt = track.deletedAt + createdAt = track.createdAt + updatedAt = track.updatedAt + } + + var localTrack: LocalTrack { + LocalTrack( + id: trackID, + origin: LocalTrackOrigin(rawValue: originRawValue) ?? .localScan, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + localFilePath: localFilePath, + sha256: sha256, + fileModifiedAt: fileModifiedAt, + lastScannedAt: lastScannedAt, + isDeleted: isMarkedDeleted, + deletedAt: deletedAt, + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + func apply(_ track: LocalTrack) { + trackID = track.id + deduplicationKey = track.deduplicationKey + originRawValue = track.origin.rawValue + title = track.title + artist = track.artist + album = track.album + durationSeconds = track.durationSeconds + localFilePath = track.localFilePath + sha256 = track.sha256 + fileModifiedAt = track.fileModifiedAt + lastScannedAt = track.lastScannedAt + isMarkedDeleted = track.isDeleted + deletedAt = track.deletedAt + createdAt = track.createdAt + updatedAt = track.updatedAt + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift new file mode 100644 index 0000000..451eaaf --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift @@ -0,0 +1,365 @@ +import Foundation +@preconcurrency import SwiftData +import VelodyDomain + +public protocol TrackRepository: LocalLibraryStore { + func loadLocalTracks( + origin: LocalTrackOrigin?, + includeDeleted: Bool + ) async throws -> [LocalTrack] + func findTrack(trackID: String) async throws -> LocalTrack? + func findTrack(deduplicationKey: String) async throws -> LocalTrack? + func findTrack( + localFilePath: String, + origin: LocalTrackOrigin? + ) async throws -> LocalTrack? + func saveLocalTrack(_ track: LocalTrack) async throws + func removeTracks(origin: LocalTrackOrigin?) async throws + func markDeletedLocalTracks( + missingFrom scannedFilePaths: Set, + under rootFolderPath: String, + scannedAt: Date + ) async throws -> Int +} + +public actor SwiftDataTrackRepository: TrackRepository { + private let modelContainer: ModelContainer + private let modelContext: ModelContext + + public init( + databaseURL: URL? = nil, + isStoredInMemoryOnly: Bool = false, + fileManager: FileManager = .default + ) throws { + let configuration: ModelConfiguration + + if isStoredInMemoryOnly { + configuration = ModelConfiguration(isStoredInMemoryOnly: true) + } else { + let storeURL: URL + if let databaseURL { + storeURL = databaseURL + } else { + storeURL = try Self.defaultStoreURL(fileManager: fileManager) + } + try fileManager.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + configuration = ModelConfiguration(url: storeURL) + } + + modelContainer = try ModelContainer( + for: TrackEntity.self, + configurations: configuration + ) + modelContext = ModelContext(modelContainer) + modelContext.autosaveEnabled = false + } + + public func loadTracks() async throws -> [LibraryTrack] { + try await loadLocalTracks(origin: nil, includeDeleted: false) + .map(\.libraryTrack) + } + + public func replaceTracks(_ tracks: [LibraryTrack]) async throws { + try await removeTracks(origin: .syncBootstrap) + + let observedAt = Date() + for track in tracks { + try await saveLocalTrack( + LocalTrack( + libraryTrack: track, + origin: .syncBootstrap, + observedAt: observedAt + ) + ) + } + } + + public func loadLocalTracks( + origin: LocalTrackOrigin?, + includeDeleted: Bool + ) async throws -> [LocalTrack] { + try fetchEntities() + .map(\.localTrack) + .filter { track in + let originMatches = origin.map { track.origin == $0 } ?? true + let deletedMatches = includeDeleted || !track.isDeleted + return originMatches && deletedMatches + } + .sorted(by: sortTracks(_:_:)) + } + + public func findTrack(trackID: String) async throws -> LocalTrack? { + try fetchEntities() + .first(where: { $0.trackID == trackID })? + .localTrack + } + + public func findTrack(deduplicationKey: String) async throws -> LocalTrack? { + try fetchEntities() + .first(where: { $0.deduplicationKey == deduplicationKey })? + .localTrack + } + + public func findTrack( + localFilePath: String, + origin: LocalTrackOrigin? + ) async throws -> LocalTrack? { + let matches = try fetchEntities() + .map(\.localTrack) + .filter { track in + let pathMatches = track.localFilePath == localFilePath + let originMatches = origin.map { track.origin == $0 } ?? true + return pathMatches && originMatches + } + .sorted { lhs, rhs in + if lhs.isDeleted != rhs.isDeleted { + return !lhs.isDeleted + } + + return lhs.updatedAt > rhs.updatedAt + } + + return matches.first + } + + public func saveLocalTrack(_ track: LocalTrack) async throws { + let existingEntity = try findEntity(trackID: track.id) + ?? (try findEntity(deduplicationKey: track.deduplicationKey)) + + if let existingEntity { + existingEntity.apply(track) + } else { + modelContext.insert(TrackEntity(track: track)) + } + + try modelContext.save() + } + + public func removeTracks(origin: LocalTrackOrigin?) async throws { + let matchingEntities = try fetchEntities().filter { entity in + guard let origin else { + return true + } + + return entity.originRawValue == origin.rawValue + } + + for entity in matchingEntities { + modelContext.delete(entity) + } + + if !matchingEntities.isEmpty { + try modelContext.save() + } + } + + public func markDeletedLocalTracks( + missingFrom scannedFilePaths: Set, + under rootFolderPath: String, + scannedAt: Date + ) async throws -> Int { + let localTracks = try fetchEntities() + .filter { entity in + entity.originRawValue == LocalTrackOrigin.localScan.rawValue + && !entity.isMarkedDeleted + && isWithinRootFolder(entity.localFilePath, rootFolderPath: rootFolderPath) + && !scannedFilePaths.contains(entity.localFilePath) + } + + for entity in localTracks { + entity.isMarkedDeleted = true + entity.deletedAt = scannedAt + entity.lastScannedAt = scannedAt + entity.updatedAt = scannedAt + } + + if !localTracks.isEmpty { + try modelContext.save() + } + + return localTracks.count + } + + private static func defaultStoreURL(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("local-catalog.store") + } + + private func fetchEntities() throws -> [TrackEntity] { + try modelContext.fetch(FetchDescriptor()) + } + + private func findEntity(trackID: String) throws -> TrackEntity? { + try fetchEntities().first(where: { $0.trackID == trackID }) + } + + private func findEntity(deduplicationKey: String) throws -> TrackEntity? { + try fetchEntities().first(where: { $0.deduplicationKey == deduplicationKey }) + } + +} + +public actor InMemoryTrackRepository: TrackRepository { + private var tracksByDeduplicationKey: [String: LocalTrack] + + public init(tracks: [LocalTrack] = []) { + tracksByDeduplicationKey = Dictionary( + uniqueKeysWithValues: tracks.map { ($0.deduplicationKey, $0) } + ) + } + + public func loadTracks() async throws -> [LibraryTrack] { + try await loadLocalTracks(origin: nil, includeDeleted: false) + .map(\.libraryTrack) + } + + public func replaceTracks(_ tracks: [LibraryTrack]) async throws { + let retainedTracks = tracksByDeduplicationKey.values.filter { $0.origin != .syncBootstrap } + let observedAt = Date() + let replacementTracks = tracks.map { + LocalTrack( + libraryTrack: $0, + origin: .syncBootstrap, + observedAt: observedAt + ) + } + + tracksByDeduplicationKey = Dictionary( + uniqueKeysWithValues: (retainedTracks + replacementTracks).map { + ($0.deduplicationKey, $0) + } + ) + } + + public func loadLocalTracks( + origin: LocalTrackOrigin?, + includeDeleted: Bool + ) async throws -> [LocalTrack] { + tracksByDeduplicationKey.values + .filter { track in + let originMatches = origin.map { track.origin == $0 } ?? true + let deletedMatches = includeDeleted || !track.isDeleted + return originMatches && deletedMatches + } + .sorted(by: sortTracks(_:_:)) + } + + public func findTrack(trackID: String) async throws -> LocalTrack? { + tracksByDeduplicationKey.values.first(where: { $0.id == trackID }) + } + + public func findTrack(deduplicationKey: String) async throws -> LocalTrack? { + tracksByDeduplicationKey[deduplicationKey] + } + + public func findTrack( + localFilePath: String, + origin: LocalTrackOrigin? + ) async throws -> LocalTrack? { + tracksByDeduplicationKey.values + .filter { track in + let pathMatches = track.localFilePath == localFilePath + let originMatches = origin.map { track.origin == $0 } ?? true + return pathMatches && originMatches + } + .sorted { lhs, rhs in + if lhs.isDeleted != rhs.isDeleted { + return !lhs.isDeleted + } + + return lhs.updatedAt > rhs.updatedAt + } + .first + } + + public func saveLocalTrack(_ track: LocalTrack) async throws { + if let previousTrack = tracksByDeduplicationKey.values.first(where: { $0.id == track.id }), + previousTrack.deduplicationKey != track.deduplicationKey + { + tracksByDeduplicationKey.removeValue(forKey: previousTrack.deduplicationKey) + } + + tracksByDeduplicationKey[track.deduplicationKey] = track + } + + public func removeTracks(origin: LocalTrackOrigin?) async throws { + guard let origin else { + tracksByDeduplicationKey.removeAll() + return + } + + tracksByDeduplicationKey = tracksByDeduplicationKey.filter { _, track in + track.origin != origin + } + } + + public func markDeletedLocalTracks( + missingFrom scannedFilePaths: Set, + under rootFolderPath: String, + scannedAt: Date + ) async throws -> Int { + var deletedTrackCount = 0 + + for track in tracksByDeduplicationKey.values where + track.origin == .localScan + && !track.isDeleted + && isWithinRootFolder( + track.localFilePath, + rootFolderPath: rootFolderPath + ) + && !scannedFilePaths.contains(track.localFilePath) + { + var updatedTrack = track + updatedTrack.isDeleted = true + updatedTrack.deletedAt = scannedAt + updatedTrack.lastScannedAt = scannedAt + updatedTrack.updatedAt = scannedAt + tracksByDeduplicationKey[updatedTrack.deduplicationKey] = updatedTrack + deletedTrackCount += 1 + } + + return deletedTrackCount + } +} + +public typealias InMemoryLocalLibraryStore = InMemoryTrackRepository + +private func sortTracks(_ lhs: LocalTrack, _ rhs: LocalTrack) -> Bool { + let titleOrder = lhs.title.localizedCaseInsensitiveCompare(rhs.title) + if titleOrder == .orderedSame { + let pathOrder = lhs.localFilePath.localizedCaseInsensitiveCompare(rhs.localFilePath) + if pathOrder == .orderedSame { + return lhs.id.localizedCaseInsensitiveCompare(rhs.id) == .orderedAscending + } + + return pathOrder == .orderedAscending + } + + return titleOrder == .orderedAscending +} + +private func isWithinRootFolder( + _ filePath: String, + rootFolderPath: String +) -> Bool { + let normalizedRoot = rootFolderPath.hasSuffix("/") + ? String(rootFolderPath.dropLast()) + : rootFolderPath + + if filePath == normalizedRoot { + return true + } + + return filePath.hasPrefix(normalizedRoot + "/") +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift new file mode 100644 index 0000000..4e2f8cc --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift @@ -0,0 +1,273 @@ +import Foundation +import XCTest +@testable import VelodyPersistence +import VelodyDomain + +final class LocalCatalogServiceTests: XCTestCase { + func testScanPersistsTracksAndSkipsDuplicateSHA() async throws { + let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true) + let service = DefaultLocalCatalogService(repository: repository) + let modifiedAt = Date(timeIntervalSince1970: 1_000) + + let result = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Alpha", + path: "/Music/Alpha.mp3", + sha256: "sha-alpha", + modifiedAt: modifiedAt + ), + makeScannedTrack( + title: "Alpha Copy", + path: "/Music/Copies/Alpha.mp3", + sha256: "sha-alpha", + modifiedAt: modifiedAt + ), + ], + in: URL(fileURLWithPath: "/Music"), + scannedAt: Date(timeIntervalSince1970: 1_100) + ) + + XCTAssertEqual(result.insertedTrackCount, 1) + XCTAssertEqual(result.skippedDuplicateTrackCount, 1) + XCTAssertEqual(result.deletedTrackCount, 0) + XCTAssertEqual(result.tracks.count, 1) + XCTAssertEqual(result.tracks.first?.localFilePath, "/Music/Alpha.mp3") + + let storedTracks = try await repository.loadLocalTracks( + origin: .localScan, + includeDeleted: true + ) + XCTAssertEqual(storedTracks.count, 1) + XCTAssertEqual(storedTracks.first?.sha256, "sha-alpha") + XCTAssertEqual(storedTracks.first?.fileModifiedAt, modifiedAt) + } + + func testModifiedFileAtSamePathUpdatesExistingTrackInsteadOfDuplicating() async throws { + let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true) + let service = DefaultLocalCatalogService(repository: repository) + let folderURL = URL(fileURLWithPath: "/Music") + + _ = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Original", + path: "/Music/Track.mp3", + sha256: "sha-original", + modifiedAt: Date(timeIntervalSince1970: 2_000) + ), + ], + in: folderURL, + scannedAt: Date(timeIntervalSince1970: 2_100) + ) + + let result = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Updated Title", + path: "/Music/Track.mp3", + sha256: "sha-updated", + modifiedAt: Date(timeIntervalSince1970: 2_200) + ), + ], + in: folderURL, + scannedAt: Date(timeIntervalSince1970: 2_300) + ) + + XCTAssertEqual(result.insertedTrackCount, 0) + XCTAssertEqual(result.updatedTrackCount, 1) + XCTAssertEqual(result.deletedTrackCount, 0) + XCTAssertEqual(result.tracks.count, 1) + XCTAssertEqual(result.tracks.first?.title, "Updated Title") + XCTAssertEqual(result.tracks.first?.sha256, "sha-updated") + + let storedTracks = try await repository.loadLocalTracks( + origin: .localScan, + includeDeleted: true + ) + XCTAssertEqual(storedTracks.count, 1) + XCTAssertEqual(storedTracks.first?.title, "Updated Title") + XCTAssertEqual(storedTracks.first?.sha256, "sha-updated") + } + + func testRescanMarksDeletedTracksAndReactivatesExistingSHA() async throws { + let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true) + let service = DefaultLocalCatalogService(repository: repository) + let folderURL = URL(fileURLWithPath: "/Music") + + _ = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Alpha", + path: "/Music/Alpha.mp3", + sha256: "sha-alpha", + modifiedAt: Date(timeIntervalSince1970: 3_000) + ), + makeScannedTrack( + title: "Beta", + path: "/Music/Beta.mp3", + sha256: "sha-beta", + modifiedAt: Date(timeIntervalSince1970: 3_000) + ), + ], + in: folderURL, + scannedAt: Date(timeIntervalSince1970: 3_100) + ) + + let deleteResult = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Alpha", + path: "/Music/Alpha.mp3", + sha256: "sha-alpha", + modifiedAt: Date(timeIntervalSince1970: 3_200) + ), + ], + in: folderURL, + scannedAt: Date(timeIntervalSince1970: 3_300) + ) + + XCTAssertEqual(deleteResult.deletedTrackCount, 1) + XCTAssertEqual(deleteResult.tracks.count, 1) + + let reactivationResult = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Alpha", + path: "/Music/Alpha.mp3", + sha256: "sha-alpha", + modifiedAt: Date(timeIntervalSince1970: 3_400) + ), + makeScannedTrack( + title: "Beta", + path: "/Music/Recovered/Beta.mp3", + sha256: "sha-beta", + modifiedAt: Date(timeIntervalSince1970: 3_400) + ), + ], + in: folderURL, + scannedAt: Date(timeIntervalSince1970: 3_500) + ) + + XCTAssertEqual(reactivationResult.reactivatedTrackCount, 1) + XCTAssertEqual(reactivationResult.deletedTrackCount, 0) + XCTAssertEqual(reactivationResult.tracks.count, 2) + + let storedTracks = try await repository.loadLocalTracks( + origin: .localScan, + includeDeleted: true + ) + XCTAssertEqual(storedTracks.count, 2) + XCTAssertEqual( + storedTracks.first(where: { $0.sha256 == "sha-beta" })?.localFilePath, + "/Music/Recovered/Beta.mp3" + ) + XCTAssertEqual( + storedTracks.first(where: { $0.sha256 == "sha-beta" })?.isDeleted, + false + ) + } + + func testRepositoryPersistsTracksAcrossInstances() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let databaseURL = tempDirectory.appendingPathComponent("catalog.store") + let folderURL = URL(fileURLWithPath: "/Music") + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstRepository = try SwiftDataTrackRepository(databaseURL: databaseURL) + let firstService = DefaultLocalCatalogService(repository: firstRepository) + + _ = try await firstService.reconcileScanResults( + [ + makeScannedTrack( + title: "Persisted", + path: "/Music/Persisted.mp3", + sha256: "sha-persisted", + modifiedAt: Date(timeIntervalSince1970: 4_000) + ), + ], + in: folderURL, + scannedAt: Date(timeIntervalSince1970: 4_100) + ) + + let secondRepository = try SwiftDataTrackRepository(databaseURL: databaseURL) + let storedTracks = try await secondRepository.loadLocalTracks( + origin: .localScan, + includeDeleted: false + ) + + XCTAssertEqual(storedTracks.count, 1) + XCTAssertEqual(storedTracks.first?.title, "Persisted") + XCTAssertEqual(storedTracks.first?.sha256, "sha-persisted") + } + + func testSyncBootstrapReplacementLeavesLocalCatalogIntact() async throws { + let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true) + let service = DefaultLocalCatalogService(repository: repository) + + _ = try await service.reconcileScanResults( + [ + makeScannedTrack( + title: "Local Track", + path: "/Music/Local.mp3", + sha256: "sha-local", + modifiedAt: Date(timeIntervalSince1970: 5_000) + ), + ], + in: URL(fileURLWithPath: "/Music"), + scannedAt: Date(timeIntervalSince1970: 5_100) + ) + + try await repository.replaceTracks( + [ + LibraryTrack( + id: "remote-track-1", + title: "Remote Track", + artist: "Remote Artist", + album: "Remote Album", + durationSeconds: 180, + localFilePath: "", + sha256: nil + ), + ] + ) + + let localTracks = try await repository.loadLocalTracks( + origin: .localScan, + includeDeleted: false + ) + let remoteTracks = try await repository.loadLocalTracks( + origin: .syncBootstrap, + includeDeleted: false + ) + let allTracks = try await repository.loadTracks() + + XCTAssertEqual(localTracks.count, 1) + XCTAssertEqual(remoteTracks.count, 1) + XCTAssertEqual(allTracks.count, 2) + } + + private func makeScannedTrack( + title: String, + path: String, + sha256: String, + modifiedAt: Date + ) -> ScannedLocalTrack { + ScannedLocalTrack( + title: title, + artist: "Artist", + album: "Album", + durationSeconds: 180, + localFilePath: path, + sha256: sha256, + fileModifiedAt: modifiedAt + ) + } +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift b/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift index 38c2945..81e47c8 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift @@ -37,11 +37,12 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator { public func performInitialSync() async throws -> SyncResult { let bootstrap = try await apiClient.fetchSyncBootstrap() - await store.replaceTracks(bootstrap.tracks) + try await store.replaceTracks(bootstrap.tracks) + let persistedTracks = try await store.loadTracks() return SyncResult( - statusMessage: "Sync bootstrap completed. Tracks: \(bootstrap.tracks.count). Next cursor: \(bootstrap.nextCursor.value).", - tracks: bootstrap.tracks, + statusMessage: "Sync bootstrap completed. Tracks: \(persistedTracks.count). Next cursor: \(bootstrap.nextCursor.value).", + tracks: persistedTracks, cursor: bootstrap.nextCursor ) }