Add persistent local music catalog

This commit is contained in:
diyaa 2026-05-28 13:32:40 +02:00
parent 1674174a4e
commit 183d5db4a9
15 changed files with 1315 additions and 41 deletions

4
.gitignore vendored
View File

@ -118,3 +118,7 @@ playground.xcworkspace
# Package.resolved # Package.resolved
packages/apple/**/.build/ packages/apple/**/.build/
**/.build/ **/.build/
# Xcode local build artifacts
.derivedData/
DerivedData/

View File

@ -1,6 +1,6 @@
import AVFoundation import AVFoundation
import CryptoKit
import Foundation import Foundation
import VelodyDomain
import VelodyPersistence import VelodyPersistence
final class AVFoundationMetadataReader: MetadataReader { final class AVFoundationMetadataReader: MetadataReader {
@ -20,17 +20,20 @@ final class AVFoundationMetadataReader: MetadataReader {
final class FileSystemLocalMusicScanner: LocalMusicScanner { final class FileSystemLocalMusicScanner: LocalMusicScanner {
private let metadataReader: any MetadataReader private let metadataReader: any MetadataReader
private let fileHasher: SHA256FileHasher
private let fileManager: FileManager private let fileManager: FileManager
init( init(
metadataReader: any MetadataReader, metadataReader: any MetadataReader,
fileHasher: SHA256FileHasher = SHA256FileHasher(),
fileManager: FileManager = .default fileManager: FileManager = .default
) { ) {
self.metadataReader = metadataReader self.metadataReader = metadataReader
self.fileHasher = fileHasher
self.fileManager = fileManager self.fileManager = fileManager
} }
func scanFolder(at folderURL: URL) async throws -> [LibraryTrack] { func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack] {
let hasScopedAccess = folderURL.startAccessingSecurityScopedResource() let hasScopedAccess = folderURL.startAccessingSecurityScopedResource()
defer { defer {
if hasScopedAccess { if hasScopedAccess {
@ -40,13 +43,16 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
guard let enumerator = fileManager.enumerator( guard let enumerator = fileManager.enumerator(
at: folderURL, at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey], includingPropertiesForKeys: [
.isRegularFileKey,
.contentModificationDateKey,
],
options: [.skipsHiddenFiles, .skipsPackageDescendants] options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else { ) else {
throw CocoaError(.fileReadUnknown) throw CocoaError(.fileReadUnknown)
} }
var discoveredTracks: [LibraryTrack] = [] var discoveredTracks: [ScannedLocalTrack] = []
for case let fileURL as URL in enumerator { for case let fileURL as URL in enumerator {
guard shouldScan(fileURL) else { guard shouldScan(fileURL) else {
@ -54,30 +60,32 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
} }
let fallbackTitle = fileURL.deletingPathExtension().lastPathComponent let fallbackTitle = fileURL.deletingPathExtension().lastPathComponent
let sha256 = try fileHasher.hashFile(at: fileURL)
let fileModifiedAt = modificationDate(for: fileURL)
do { do {
let metadata = try await metadataReader.readMetadata(for: fileURL) let metadata = try await metadataReader.readMetadata(for: fileURL)
discoveredTracks.append( discoveredTracks.append(
LibraryTrack( ScannedLocalTrack(
id: fileURL.path,
title: metadata.title?.trimmedNonEmpty ?? fallbackTitle, title: metadata.title?.trimmedNonEmpty ?? fallbackTitle,
artist: metadata.artist?.trimmedNonEmpty ?? "Unknown Artist", artist: metadata.artist?.trimmedNonEmpty ?? "Unknown Artist",
album: metadata.album?.trimmedNonEmpty, album: metadata.album?.trimmedNonEmpty,
durationSeconds: metadata.durationSeconds, durationSeconds: metadata.durationSeconds,
localFilePath: fileURL.path, localFilePath: fileURL.path,
sha256: nil sha256: sha256,
fileModifiedAt: fileModifiedAt
) )
) )
} catch { } catch {
discoveredTracks.append( discoveredTracks.append(
LibraryTrack( ScannedLocalTrack(
id: fileURL.path,
title: fallbackTitle, title: fallbackTitle,
artist: "Unknown Artist", artist: "Unknown Artist",
album: nil, album: nil,
durationSeconds: nil, durationSeconds: nil,
localFilePath: fileURL.path, localFilePath: fileURL.path,
sha256: nil sha256: sha256,
fileModifiedAt: fileModifiedAt
) )
) )
} }
@ -101,6 +109,46 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]) let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey])
return resourceValues?.isRegularFile == true 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 { private extension Array where Element == AVMetadataItem {

View File

@ -26,10 +26,10 @@ final class MacLibraryViewModel {
var isRunningSyncBootstrap = false var isRunningSyncBootstrap = false
private let folderAccessService: any VelodyPersistence.FolderAccessService private let folderAccessService: any VelodyPersistence.FolderAccessService
private let catalogService: any LocalCatalogService
private let localMusicScanner: any LocalMusicScanner private let localMusicScanner: any LocalMusicScanner
private let keychainService: any KeychainService private let keychainService: any KeychainService
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
private let store = InMemoryLocalLibraryStore()
private var hasLoaded = false private var hasLoaded = false
init( init(
@ -40,8 +40,10 @@ final class MacLibraryViewModel {
let localMusicScanner = FileSystemLocalMusicScanner( let localMusicScanner = FileSystemLocalMusicScanner(
metadataReader: AVFoundationMetadataReader() metadataReader: AVFoundationMetadataReader()
) )
let repository = Self.makeTrackRepository()
self.folderAccessService = folderAccessService self.folderAccessService = folderAccessService
self.catalogService = DefaultLocalCatalogService(repository: repository)
self.localMusicScanner = localMusicScanner self.localMusicScanner = localMusicScanner
self.keychainService = keychainService self.keychainService = keychainService
self.userDefaults = userDefaults self.userDefaults = userDefaults
@ -57,8 +59,13 @@ final class MacLibraryViewModel {
func loadIfNeeded() async { func loadIfNeeded() async {
guard !hasLoaded else { return } guard !hasLoaded else { return }
hasLoaded = true hasLoaded = true
tracks = await store.loadTracks() do {
discoveredTrackCount = tracks.count tracks = try await catalogService.loadActiveLocalTracks()
discoveredTrackCount = tracks.count
} catch {
scanStatus = "Failed to load saved catalog: \(error.localizedDescription)"
}
await restoreDeviceIdentity() await restoreDeviceIdentity()
} }
@ -69,7 +76,11 @@ final class MacLibraryViewModel {
tracks = [] tracks = []
discoveredTrackCount = 0 discoveredTrackCount = 0
Task { 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 { do {
let discoveredTracks = try await localMusicScanner.scanFolder(at: folderURL) let discoveredTracks = try await localMusicScanner.scanFolder(at: folderURL)
await store.replaceTracks(discoveredTracks) let scanResult = try await catalogService.reconcileScanResults(
tracks = await store.loadTracks() discoveredTracks,
in: folderURL,
scannedAt: Date()
)
tracks = scanResult.tracks
discoveredTrackCount = tracks.count discoveredTrackCount = tracks.count
scanStatus = "Scan finished. Found \(discoveredTrackCount) MP3 file(s)." scanStatus = Self.scanStatus(
for: scanResult,
activeTrackCount: discoveredTrackCount
)
} catch { } catch {
scanStatus = "Scan failed: \(error.localizedDescription)" scanStatus = "Scan failed: \(error.localizedDescription)"
} }
@ -231,6 +249,21 @@ final class MacLibraryViewModel {
URLSessionVelodyAPIClient(environment: environment) 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 { private static var currentAppVersion: String {
if let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, if let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!shortVersion.isEmpty !shortVersion.isEmpty

View File

@ -11,25 +11,38 @@ final class iPhoneLibraryViewModel {
var tracks: [LibraryTrack] = [] var tracks: [LibraryTrack] = []
var syncStatus = "Offline library not synced yet" var syncStatus = "Offline library not synced yet"
private let store = InMemoryLocalLibraryStore() private let store: any LocalLibraryStore
private let syncCoordinator: PlaceholderSyncCoordinator private let syncCoordinator: PlaceholderSyncCoordinator
private var hasLoaded = false private var hasLoaded = false
init() { init() {
let repository = Self.makeTrackRepository()
let environment = ServerEnvironment( let environment = ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL, baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0" appVersion: "0.1.0"
) )
let apiClient = StubVelodyAPIClient(environment: environment) let apiClient = StubVelodyAPIClient(environment: environment)
self.store = repository
self.syncCoordinator = PlaceholderSyncCoordinator( self.syncCoordinator = PlaceholderSyncCoordinator(
apiClient: apiClient, apiClient: apiClient,
store: store store: repository
) )
} }
func loadIfNeeded() async { func loadIfNeeded() async {
guard !hasLoaded else { return } guard !hasLoaded else { return }
hasLoaded = true 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() await refreshSync()
} }
@ -42,4 +55,12 @@ final class iPhoneLibraryViewModel {
syncStatus = "Sync placeholder failed: \(error.localizedDescription)" syncStatus = "Sync placeholder failed: \(error.localizedDescription)"
} }
} }
private static func makeTrackRepository() -> any TrackRepository {
if let repository = try? SwiftDataTrackRepository() {
return repository
}
return InMemoryTrackRepository()
}
} }

View File

@ -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";

View File

@ -1,2 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql" provider = "postgresql"

View File

@ -21,5 +21,12 @@ let package = Package(
name: "VelodyPersistence", name: "VelodyPersistence",
dependencies: ["VelodyDomain"] dependencies: ["VelodyDomain"]
), ),
.testTarget(
name: "VelodyPersistenceTests",
dependencies: [
"VelodyDomain",
"VelodyPersistence",
]
),
] ]
) )

View File

@ -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)
}

View File

@ -2,22 +2,6 @@ import Foundation
import VelodyDomain import VelodyDomain
public protocol LocalLibraryStore: Sendable { public protocol LocalLibraryStore: Sendable {
func loadTracks() async -> [LibraryTrack] func loadTracks() async throws -> [LibraryTrack]
func replaceTracks(_ tracks: [LibraryTrack]) async func replaceTracks(_ tracks: [LibraryTrack]) async throws
}
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
}
} }

View File

@ -31,5 +31,5 @@ public protocol MetadataReader {
} }
public protocol LocalMusicScanner { public protocol LocalMusicScanner {
func scanFolder(at folderURL: URL) async throws -> [LibraryTrack] func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack]
} }

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String>,
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<String>,
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<TrackEntity>())
}
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<String>,
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 + "/")
}

View File

@ -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
)
}
}

View File

@ -37,11 +37,12 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator {
public func performInitialSync() async throws -> SyncResult { public func performInitialSync() async throws -> SyncResult {
let bootstrap = try await apiClient.fetchSyncBootstrap() 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( return SyncResult(
statusMessage: "Sync bootstrap completed. Tracks: \(bootstrap.tracks.count). Next cursor: \(bootstrap.nextCursor.value).", statusMessage: "Sync bootstrap completed. Tracks: \(persistedTracks.count). Next cursor: \(bootstrap.nextCursor.value).",
tracks: bootstrap.tracks, tracks: persistedTracks,
cursor: bootstrap.nextCursor cursor: bootstrap.nextCursor
) )
} }