Add persistent local music catalog
This commit is contained in:
parent
1674174a4e
commit
183d5db4a9
4
.gitignore
vendored
4
.gitignore
vendored
@ -118,3 +118,7 @@ playground.xcworkspace
|
|||||||
# Package.resolved
|
# Package.resolved
|
||||||
packages/apple/**/.build/
|
packages/apple/**/.build/
|
||||||
**/.build/
|
**/.build/
|
||||||
|
|
||||||
|
# Xcode local build artifacts
|
||||||
|
.derivedData/
|
||||||
|
DerivedData/
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
@ -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"
|
||||||
|
|||||||
@ -21,5 +21,12 @@ let package = Package(
|
|||||||
name: "VelodyPersistence",
|
name: "VelodyPersistence",
|
||||||
dependencies: ["VelodyDomain"]
|
dependencies: ["VelodyDomain"]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "VelodyPersistenceTests",
|
||||||
|
dependencies: [
|
||||||
|
"VelodyDomain",
|
||||||
|
"VelodyPersistence",
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 + "/")
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user