velody/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.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 + "/")
}