Add persistent local music catalog
This commit is contained in:
@@ -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
|
||||
|
||||
public protocol LocalLibraryStore: Sendable {
|
||||
func loadTracks() async -> [LibraryTrack]
|
||||
func replaceTracks(_ tracks: [LibraryTrack]) async
|
||||
}
|
||||
|
||||
public actor InMemoryLocalLibraryStore: LocalLibraryStore {
|
||||
private var tracks: [LibraryTrack]
|
||||
|
||||
public init(tracks: [LibraryTrack] = []) {
|
||||
self.tracks = tracks
|
||||
}
|
||||
|
||||
public func loadTracks() async -> [LibraryTrack] {
|
||||
tracks
|
||||
}
|
||||
|
||||
public func replaceTracks(_ tracks: [LibraryTrack]) async {
|
||||
self.tracks = tracks
|
||||
}
|
||||
func loadTracks() async throws -> [LibraryTrack]
|
||||
func replaceTracks(_ tracks: [LibraryTrack]) async throws
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,5 +31,5 @@ public protocol MetadataReader {
|
||||
}
|
||||
|
||||
public protocol LocalMusicScanner {
|
||||
func scanFolder(at folderURL: URL) async throws -> [LibraryTrack]
|
||||
func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack]
|
||||
}
|
||||
|
||||
@@ -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 + "/")
|
||||
}
|
||||
Reference in New Issue
Block a user