Add persistent local music catalog
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user