434 lines
14 KiB
Swift
434 lines
14 KiB
Swift
import Foundation
|
|
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 database: SwiftDataCatalogDatabase
|
|
|
|
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)
|
|
}
|
|
|
|
let modelContainer = try ModelContainer(
|
|
for: TrackEntity.self,
|
|
configurations: configuration
|
|
)
|
|
database = SwiftDataCatalogDatabase(modelContainer: modelContainer)
|
|
}
|
|
|
|
public func loadTracks() async throws -> [LibraryTrack] {
|
|
try await database.loadTracks()
|
|
}
|
|
|
|
public func replaceTracks(_ tracks: [LibraryTrack]) async throws {
|
|
try await database.replaceTracks(tracks)
|
|
}
|
|
|
|
public func loadLocalTracks(
|
|
origin: LocalTrackOrigin?,
|
|
includeDeleted: Bool
|
|
) async throws -> [LocalTrack] {
|
|
try await database.loadLocalTracks(
|
|
origin: origin,
|
|
includeDeleted: includeDeleted
|
|
)
|
|
}
|
|
|
|
public func findTrack(trackID: String) async throws -> LocalTrack? {
|
|
try await database.findTrack(trackID: trackID)
|
|
}
|
|
|
|
public func findTrack(deduplicationKey: String) async throws -> LocalTrack? {
|
|
try await database.findTrack(deduplicationKey: deduplicationKey)
|
|
}
|
|
|
|
public func findTrack(
|
|
localFilePath: String,
|
|
origin: LocalTrackOrigin?
|
|
) async throws -> LocalTrack? {
|
|
try await database.findTrack(
|
|
localFilePath: localFilePath,
|
|
origin: origin
|
|
)
|
|
}
|
|
|
|
public func saveLocalTrack(_ track: LocalTrack) async throws {
|
|
try await database.saveLocalTrack(track)
|
|
}
|
|
|
|
public func removeTracks(origin: LocalTrackOrigin?) async throws {
|
|
try await database.removeTracks(origin: origin)
|
|
}
|
|
|
|
public func markDeletedLocalTracks(
|
|
missingFrom scannedFilePaths: Set<String>,
|
|
under rootFolderPath: String,
|
|
scannedAt: Date
|
|
) async throws -> Int {
|
|
try await database.markDeletedLocalTracks(
|
|
missingFrom: scannedFilePaths,
|
|
under: rootFolderPath,
|
|
scannedAt: scannedAt
|
|
)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
@ModelActor
|
|
private actor SwiftDataCatalogDatabase {
|
|
func loadTracks() throws -> [LibraryTrack] {
|
|
try loadLocalTracks(origin: nil, includeDeleted: false)
|
|
.map(\.libraryTrack)
|
|
}
|
|
|
|
func replaceTracks(_ tracks: [LibraryTrack]) throws {
|
|
let matchingEntities = try fetchEntities().filter { entity in
|
|
entity.originRawValue == LocalTrackOrigin.syncBootstrap.rawValue
|
|
}
|
|
|
|
for entity in matchingEntities {
|
|
modelContext.delete(entity)
|
|
}
|
|
|
|
let observedAt = Date()
|
|
for track in tracks {
|
|
modelContext.insert(
|
|
TrackEntity(
|
|
track: LocalTrack(
|
|
libraryTrack: track,
|
|
origin: .syncBootstrap,
|
|
observedAt: observedAt
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
if !matchingEntities.isEmpty || !tracks.isEmpty {
|
|
try modelContext.save()
|
|
}
|
|
}
|
|
|
|
func loadLocalTracks(
|
|
origin: LocalTrackOrigin?,
|
|
includeDeleted: Bool
|
|
) 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(_:_:))
|
|
}
|
|
|
|
func findTrack(trackID: String) throws -> LocalTrack? {
|
|
try fetchEntities()
|
|
.first(where: { $0.trackID == trackID })?
|
|
.localTrack
|
|
}
|
|
|
|
func findTrack(deduplicationKey: String) throws -> LocalTrack? {
|
|
try fetchEntities()
|
|
.first(where: { $0.deduplicationKey == deduplicationKey })?
|
|
.localTrack
|
|
}
|
|
|
|
func findTrack(
|
|
localFilePath: String,
|
|
origin: LocalTrackOrigin?
|
|
) 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
|
|
}
|
|
|
|
func saveLocalTrack(_ track: LocalTrack) 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()
|
|
}
|
|
|
|
func removeTracks(origin: LocalTrackOrigin?) 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()
|
|
}
|
|
}
|
|
|
|
func markDeletedLocalTracks(
|
|
missingFrom scannedFilePaths: Set<String>,
|
|
under rootFolderPath: String,
|
|
scannedAt: Date
|
|
) 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 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 })
|
|
}
|
|
}
|
|
|
|
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 + "/")
|
|
}
|