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
@@ -1,6 +1,6 @@
import AVFoundation
import CryptoKit
import Foundation
import VelodyDomain
import VelodyPersistence
final class AVFoundationMetadataReader: MetadataReader {
@@ -20,17 +20,20 @@ final class AVFoundationMetadataReader: MetadataReader {
final class FileSystemLocalMusicScanner: LocalMusicScanner {
private let metadataReader: any MetadataReader
private let fileHasher: SHA256FileHasher
private let fileManager: FileManager
init(
metadataReader: any MetadataReader,
fileHasher: SHA256FileHasher = SHA256FileHasher(),
fileManager: FileManager = .default
) {
self.metadataReader = metadataReader
self.fileHasher = fileHasher
self.fileManager = fileManager
}
func scanFolder(at folderURL: URL) async throws -> [LibraryTrack] {
func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack] {
let hasScopedAccess = folderURL.startAccessingSecurityScopedResource()
defer {
if hasScopedAccess {
@@ -40,13 +43,16 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
guard let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey],
includingPropertiesForKeys: [
.isRegularFileKey,
.contentModificationDateKey,
],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else {
throw CocoaError(.fileReadUnknown)
}
var discoveredTracks: [LibraryTrack] = []
var discoveredTracks: [ScannedLocalTrack] = []
for case let fileURL as URL in enumerator {
guard shouldScan(fileURL) else {
@@ -54,30 +60,32 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
}
let fallbackTitle = fileURL.deletingPathExtension().lastPathComponent
let sha256 = try fileHasher.hashFile(at: fileURL)
let fileModifiedAt = modificationDate(for: fileURL)
do {
let metadata = try await metadataReader.readMetadata(for: fileURL)
discoveredTracks.append(
LibraryTrack(
id: fileURL.path,
ScannedLocalTrack(
title: metadata.title?.trimmedNonEmpty ?? fallbackTitle,
artist: metadata.artist?.trimmedNonEmpty ?? "Unknown Artist",
album: metadata.album?.trimmedNonEmpty,
durationSeconds: metadata.durationSeconds,
localFilePath: fileURL.path,
sha256: nil
sha256: sha256,
fileModifiedAt: fileModifiedAt
)
)
} catch {
discoveredTracks.append(
LibraryTrack(
id: fileURL.path,
ScannedLocalTrack(
title: fallbackTitle,
artist: "Unknown Artist",
album: nil,
durationSeconds: nil,
localFilePath: fileURL.path,
sha256: nil
sha256: sha256,
fileModifiedAt: fileModifiedAt
)
)
}
@@ -101,6 +109,46 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey])
return resourceValues?.isRegularFile == true
}
private func modificationDate(for fileURL: URL) -> Date {
if let resourceValues = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]),
let contentModificationDate = resourceValues.contentModificationDate
{
return contentModificationDate
}
if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
let modificationDate = attributes[.modificationDate] as? Date
{
return modificationDate
}
return .distantPast
}
}
final class SHA256FileHasher {
private let bufferSize = 1_048_576
func hashFile(at fileURL: URL) throws -> String {
let fileHandle = try FileHandle(forReadingFrom: fileURL)
defer {
try? fileHandle.close()
}
var hasher = SHA256()
while true {
let data = try fileHandle.read(upToCount: bufferSize) ?? Data()
if data.isEmpty {
break
}
hasher.update(data: data)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
}
private extension Array where Element == AVMetadataItem {
@@ -26,10 +26,10 @@ final class MacLibraryViewModel {
var isRunningSyncBootstrap = false
private let folderAccessService: any VelodyPersistence.FolderAccessService
private let catalogService: any LocalCatalogService
private let localMusicScanner: any LocalMusicScanner
private let keychainService: any KeychainService
private let userDefaults: UserDefaults
private let store = InMemoryLocalLibraryStore()
private var hasLoaded = false
init(
@@ -40,8 +40,10 @@ final class MacLibraryViewModel {
let localMusicScanner = FileSystemLocalMusicScanner(
metadataReader: AVFoundationMetadataReader()
)
let repository = Self.makeTrackRepository()
self.folderAccessService = folderAccessService
self.catalogService = DefaultLocalCatalogService(repository: repository)
self.localMusicScanner = localMusicScanner
self.keychainService = keychainService
self.userDefaults = userDefaults
@@ -57,8 +59,13 @@ final class MacLibraryViewModel {
func loadIfNeeded() async {
guard !hasLoaded else { return }
hasLoaded = true
tracks = await store.loadTracks()
discoveredTrackCount = tracks.count
do {
tracks = try await catalogService.loadActiveLocalTracks()
discoveredTrackCount = tracks.count
} catch {
scanStatus = "Failed to load saved catalog: \(error.localizedDescription)"
}
await restoreDeviceIdentity()
}
@@ -69,7 +76,11 @@ final class MacLibraryViewModel {
tracks = []
discoveredTrackCount = 0
Task {
await store.replaceTracks([])
do {
try await catalogService.resetLocalCatalog()
} catch {
scanStatus = "Failed to reset saved catalog: \(error.localizedDescription)"
}
}
}
}
@@ -88,10 +99,17 @@ final class MacLibraryViewModel {
do {
let discoveredTracks = try await localMusicScanner.scanFolder(at: folderURL)
await store.replaceTracks(discoveredTracks)
tracks = await store.loadTracks()
let scanResult = try await catalogService.reconcileScanResults(
discoveredTracks,
in: folderURL,
scannedAt: Date()
)
tracks = scanResult.tracks
discoveredTrackCount = tracks.count
scanStatus = "Scan finished. Found \(discoveredTrackCount) MP3 file(s)."
scanStatus = Self.scanStatus(
for: scanResult,
activeTrackCount: discoveredTrackCount
)
} catch {
scanStatus = "Scan failed: \(error.localizedDescription)"
}
@@ -231,6 +249,21 @@ final class MacLibraryViewModel {
URLSessionVelodyAPIClient(environment: environment)
}
private static func makeTrackRepository() -> any TrackRepository {
if let repository = try? SwiftDataTrackRepository() {
return repository
}
return InMemoryTrackRepository()
}
private static func scanStatus(
for result: LocalCatalogScanResult,
activeTrackCount: Int
) -> String {
"Scan finished. Active: \(activeTrackCount). Added: \(result.insertedTrackCount). Updated: \(result.updatedTrackCount). Reactivated: \(result.reactivatedTrackCount). Deleted: \(result.deletedTrackCount). Duplicates skipped: \(result.skippedDuplicateTrackCount)."
}
private static var currentAppVersion: String {
if let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!shortVersion.isEmpty