Fix SwiftData persistence concurrency stability
This commit is contained in:
parent
e5027152ab
commit
ebc187f4a1
3
.gitignore
vendored
3
.gitignore
vendored
@ -122,3 +122,6 @@ packages/apple/**/.build/
|
|||||||
# Xcode local build artifacts
|
# Xcode local build artifacts
|
||||||
.derivedData/
|
.derivedData/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
|
|
||||||
|
# Local uploaded media storage
|
||||||
|
runtime/storage/users/
|
||||||
|
|||||||
@ -54,3 +54,5 @@ This phase intentionally does not yet implement:
|
|||||||
- upload chunk transfer
|
- upload chunk transfer
|
||||||
- background downloads
|
- background downloads
|
||||||
- audio playback
|
- audio playback
|
||||||
|
|
||||||
|
The local backend now defaults to `http://localhost:3007`.
|
||||||
|
|||||||
@ -14,7 +14,7 @@ struct MacLibraryView: View {
|
|||||||
Text("Backend Connection")
|
Text("Backend Connection")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
|
||||||
TextField("http://localhost:3000", text: $viewModel.serverURLString)
|
TextField("http://localhost:3007", text: $viewModel.serverURLString)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
viewModel.persistServerURLSelection()
|
viewModel.persistServerURLSelection()
|
||||||
|
|||||||
@ -16,6 +16,7 @@ final class MacLibraryViewModel {
|
|||||||
var discoveredTrackCount = 0
|
var discoveredTrackCount = 0
|
||||||
var isScanning = false
|
var isScanning = false
|
||||||
var nowPlayingState = NowPlayingState()
|
var nowPlayingState = NowPlayingState()
|
||||||
|
var playbackErrorMessage: String?
|
||||||
|
|
||||||
var serverURLString: String
|
var serverURLString: String
|
||||||
var deviceRegistrationStatus = "Not registered."
|
var deviceRegistrationStatus = "Not registered."
|
||||||
@ -70,9 +71,11 @@ final class MacLibraryViewModel {
|
|||||||
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
||||||
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
||||||
self.nowPlayingState = playbackController.nowPlayingState
|
self.nowPlayingState = playbackController.nowPlayingState
|
||||||
|
self.playbackErrorMessage = Self.playbackErrorMessage(from: playbackController.nowPlayingState.error)
|
||||||
|
|
||||||
playbackController.onStateChange = { [weak self] state in
|
playbackController.onStateChange = { [weak self] state in
|
||||||
self?.nowPlayingState = state
|
self?.nowPlayingState = state
|
||||||
|
self?.playbackErrorMessage = Self.playbackErrorMessage(from: state.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let url = folderAccessService.storedFolderURL() {
|
if let url = folderAccessService.storedFolderURL() {
|
||||||
@ -215,10 +218,6 @@ final class MacLibraryViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var playbackErrorMessage: String? {
|
|
||||||
nowPlayingState.error?.errorDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadSelectedTrack() async {
|
func uploadSelectedTrack() async {
|
||||||
guard let selectedTrackID else {
|
guard let selectedTrackID else {
|
||||||
lastUploadStatus = "Select a local track before uploading."
|
lastUploadStatus = "Select a local track before uploading."
|
||||||
@ -789,6 +788,10 @@ final class MacLibraryViewModel {
|
|||||||
private static let deviceIdKey = "velody.device-id"
|
private static let deviceIdKey = "velody.device-id"
|
||||||
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
||||||
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
||||||
|
|
||||||
|
private static func playbackErrorMessage(from error: PlaybackError?) -> String? {
|
||||||
|
error?.errorDescription ?? error?.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum UploadOutcome {
|
private enum UploadOutcome {
|
||||||
@ -803,7 +806,7 @@ private enum BackendConnectionError: LocalizedError {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidServerURL:
|
case .invalidServerURL:
|
||||||
return "Enter a valid backend URL, such as http://localhost:3000."
|
return "Enter a valid backend URL, such as http://localhost:3007."
|
||||||
case .missingDeviceIdentity:
|
case .missingDeviceIdentity:
|
||||||
return "Register this Mac before uploading or sending a heartbeat."
|
return "Register this Mac before uploading or sending a heartbeat."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1677,7 +1677,7 @@ VPS
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3007
|
||||||
DATABASE_URL=postgresql://...
|
DATABASE_URL=postgresql://...
|
||||||
STORAGE_ROOT=/srv/velody/data
|
STORAGE_ROOT=/srv/velody/data
|
||||||
PUBLIC_BASE_URL=https://music.diyaa.de
|
PUBLIC_BASE_URL=https://music.diyaa.de
|
||||||
|
|||||||
@ -22,7 +22,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../docker/env/backend.env.example
|
- ../docker/env/backend.env.example
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3007:3007"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
4
infra/docker/env/backend.env.example
vendored
4
infra/docker/env/backend.env.example
vendored
@ -1,7 +1,7 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3007
|
||||||
DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public
|
DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public
|
||||||
STORAGE_ROOT=/app/runtime/storage
|
STORAGE_ROOT=/app/runtime/storage
|
||||||
PUBLIC_BASE_URL=http://localhost:3000
|
PUBLIC_BASE_URL=http://localhost:3007
|
||||||
DEVICE_BOOTSTRAP_SECRET=replace-me
|
DEVICE_BOOTSTRAP_SECRET=replace-me
|
||||||
MAX_UPLOAD_SIZE_BYTES=524288000
|
MAX_UPLOAD_SIZE_BYTES=524288000
|
||||||
|
|||||||
@ -3,7 +3,7 @@ server {
|
|||||||
server_name music.diyaa.de;
|
server_name music.diyaa.de;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3007;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@ -322,7 +322,7 @@ public struct SyncBootstrapResponse: Codable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ServerEnvironment: Codable, Hashable, Sendable {
|
public struct ServerEnvironment: Codable, Hashable, Sendable {
|
||||||
public static let defaultLocalBaseURL = URL(string: "http://localhost:3000")!
|
public static let defaultLocalBaseURL = URL(string: "http://localhost:3007")!
|
||||||
|
|
||||||
public var baseURL: URL
|
public var baseURL: URL
|
||||||
public var appVersion: String
|
public var appVersion: String
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
|
|
||||||
public protocol LocalCatalogService: Sendable {
|
public protocol LocalCatalogService: Actor {
|
||||||
func loadActiveLocalTracks() async throws -> [LibraryTrack]
|
func loadActiveLocalTracks() async throws -> [LibraryTrack]
|
||||||
func reconcileScanResults(
|
func reconcileScanResults(
|
||||||
_ scannedTracks: [ScannedLocalTrack],
|
_ scannedTracks: [ScannedLocalTrack],
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
|
|
||||||
public protocol LocalLibraryStore: Sendable {
|
public protocol LocalLibraryStore: Actor {
|
||||||
func loadTracks() async throws -> [LibraryTrack]
|
func loadTracks() async throws -> [LibraryTrack]
|
||||||
func replaceTracks(_ tracks: [LibraryTrack]) async throws
|
func replaceTracks(_ tracks: [LibraryTrack]) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
@preconcurrency import SwiftData
|
import SwiftData
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
|
|
||||||
public protocol TrackRepository: LocalLibraryStore {
|
public protocol TrackRepository: LocalLibraryStore {
|
||||||
@ -23,8 +23,7 @@ public protocol TrackRepository: LocalLibraryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public actor SwiftDataTrackRepository: TrackRepository {
|
public actor SwiftDataTrackRepository: TrackRepository {
|
||||||
private let modelContainer: ModelContainer
|
private let database: SwiftDataCatalogDatabase
|
||||||
private let modelContext: ModelContext
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
databaseURL: URL? = nil,
|
databaseURL: URL? = nil,
|
||||||
@ -49,111 +48,55 @@ public actor SwiftDataTrackRepository: TrackRepository {
|
|||||||
configuration = ModelConfiguration(url: storeURL)
|
configuration = ModelConfiguration(url: storeURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
modelContainer = try ModelContainer(
|
let modelContainer = try ModelContainer(
|
||||||
for: TrackEntity.self,
|
for: TrackEntity.self,
|
||||||
configurations: configuration
|
configurations: configuration
|
||||||
)
|
)
|
||||||
modelContext = ModelContext(modelContainer)
|
database = SwiftDataCatalogDatabase(modelContainer: modelContainer)
|
||||||
modelContext.autosaveEnabled = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadTracks() async throws -> [LibraryTrack] {
|
public func loadTracks() async throws -> [LibraryTrack] {
|
||||||
try await loadLocalTracks(origin: nil, includeDeleted: false)
|
try await database.loadTracks()
|
||||||
.map(\.libraryTrack)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func replaceTracks(_ tracks: [LibraryTrack]) async throws {
|
public func replaceTracks(_ tracks: [LibraryTrack]) async throws {
|
||||||
try await removeTracks(origin: .syncBootstrap)
|
try await database.replaceTracks(tracks)
|
||||||
|
|
||||||
let observedAt = Date()
|
|
||||||
for track in tracks {
|
|
||||||
try await saveLocalTrack(
|
|
||||||
LocalTrack(
|
|
||||||
libraryTrack: track,
|
|
||||||
origin: .syncBootstrap,
|
|
||||||
observedAt: observedAt
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadLocalTracks(
|
public func loadLocalTracks(
|
||||||
origin: LocalTrackOrigin?,
|
origin: LocalTrackOrigin?,
|
||||||
includeDeleted: Bool
|
includeDeleted: Bool
|
||||||
) async throws -> [LocalTrack] {
|
) async throws -> [LocalTrack] {
|
||||||
try fetchEntities()
|
try await database.loadLocalTracks(
|
||||||
.map(\.localTrack)
|
origin: origin,
|
||||||
.filter { track in
|
includeDeleted: includeDeleted
|
||||||
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? {
|
public func findTrack(trackID: String) async throws -> LocalTrack? {
|
||||||
try fetchEntities()
|
try await database.findTrack(trackID: trackID)
|
||||||
.first(where: { $0.trackID == trackID })?
|
|
||||||
.localTrack
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func findTrack(deduplicationKey: String) async throws -> LocalTrack? {
|
public func findTrack(deduplicationKey: String) async throws -> LocalTrack? {
|
||||||
try fetchEntities()
|
try await database.findTrack(deduplicationKey: deduplicationKey)
|
||||||
.first(where: { $0.deduplicationKey == deduplicationKey })?
|
|
||||||
.localTrack
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func findTrack(
|
public func findTrack(
|
||||||
localFilePath: String,
|
localFilePath: String,
|
||||||
origin: LocalTrackOrigin?
|
origin: LocalTrackOrigin?
|
||||||
) async throws -> LocalTrack? {
|
) async throws -> LocalTrack? {
|
||||||
let matches = try fetchEntities()
|
try await database.findTrack(
|
||||||
.map(\.localTrack)
|
localFilePath: localFilePath,
|
||||||
.filter { track in
|
origin: origin
|
||||||
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 {
|
public func saveLocalTrack(_ track: LocalTrack) async throws {
|
||||||
let existingEntity = try findEntity(trackID: track.id)
|
try await database.saveLocalTrack(track)
|
||||||
?? (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 {
|
public func removeTracks(origin: LocalTrackOrigin?) async throws {
|
||||||
let matchingEntities = try fetchEntities().filter { entity in
|
try await database.removeTracks(origin: origin)
|
||||||
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(
|
public func markDeletedLocalTracks(
|
||||||
@ -161,26 +104,11 @@ public actor SwiftDataTrackRepository: TrackRepository {
|
|||||||
under rootFolderPath: String,
|
under rootFolderPath: String,
|
||||||
scannedAt: Date
|
scannedAt: Date
|
||||||
) async throws -> Int {
|
) async throws -> Int {
|
||||||
let localTracks = try fetchEntities()
|
try await database.markDeletedLocalTracks(
|
||||||
.filter { entity in
|
missingFrom: scannedFilePaths,
|
||||||
entity.originRawValue == LocalTrackOrigin.localScan.rawValue
|
under: rootFolderPath,
|
||||||
&& !entity.isMarkedDeleted
|
scannedAt: scannedAt
|
||||||
&& 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 {
|
private static func defaultStoreURL(fileManager: FileManager) throws -> URL {
|
||||||
@ -195,19 +123,6 @@ public actor SwiftDataTrackRepository: TrackRepository {
|
|||||||
.appendingPathComponent("Velody", isDirectory: true)
|
.appendingPathComponent("Velody", isDirectory: true)
|
||||||
.appendingPathComponent("local-catalog.store")
|
.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 {
|
public actor InMemoryTrackRepository: TrackRepository {
|
||||||
@ -335,6 +250,159 @@ public actor InMemoryTrackRepository: TrackRepository {
|
|||||||
|
|
||||||
public typealias InMemoryLocalLibraryStore = InMemoryTrackRepository
|
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 {
|
private func sortTracks(_ lhs: LocalTrack, _ rhs: LocalTrack) -> Bool {
|
||||||
let titleOrder = lhs.title.localizedCaseInsensitiveCompare(rhs.title)
|
let titleOrder = lhs.title.localizedCaseInsensitiveCompare(rhs.title)
|
||||||
if titleOrder == .orderedSame {
|
if titleOrder == .orderedSame {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import VelodyPersistence
|
@testable import VelodyPersistence
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
@ -254,6 +255,119 @@ final class LocalCatalogServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(allTracks.count, 2)
|
XCTAssertEqual(allTracks.count, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testConcurrentScanCallsDoNotCrashAndRetainCatalogEntries() async throws {
|
||||||
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
||||||
|
let service = DefaultLocalCatalogService(repository: repository)
|
||||||
|
let folderURL = URL(fileURLWithPath: "/Music")
|
||||||
|
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
for index in 0..<12 {
|
||||||
|
group.addTask {
|
||||||
|
_ = try await service.reconcileScanResults(
|
||||||
|
[
|
||||||
|
self.makeScannedTrack(
|
||||||
|
title: "Track \(index)",
|
||||||
|
path: "/Music/Track-\(index).mp3",
|
||||||
|
sha256: "sha-\(index)",
|
||||||
|
modifiedAt: Date(timeIntervalSince1970: 6_000 + Double(index))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
in: folderURL,
|
||||||
|
scannedAt: Date(timeIntervalSince1970: 6_100 + Double(index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await group.waitForAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
let storedTracks = try await repository.loadLocalTracks(
|
||||||
|
origin: .localScan,
|
||||||
|
includeDeleted: true
|
||||||
|
)
|
||||||
|
let activeTracks = try await repository.loadLocalTracks(
|
||||||
|
origin: .localScan,
|
||||||
|
includeDeleted: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(storedTracks.count, 12)
|
||||||
|
XCTAssertLessThanOrEqual(activeTracks.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConcurrentSaveCallsDoNotCrash() async throws {
|
||||||
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
||||||
|
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
for index in 0..<24 {
|
||||||
|
group.addTask {
|
||||||
|
try await repository.saveLocalTrack(
|
||||||
|
self.makeLocalTrack(
|
||||||
|
id: "track-\(index)",
|
||||||
|
title: "Track \(index)",
|
||||||
|
path: "/Music/Track-\(index).mp3",
|
||||||
|
sha256: "sha-save-\(index)",
|
||||||
|
observedAt: Date(timeIntervalSince1970: 7_000 + Double(index))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await group.waitForAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
let storedTracks = try await repository.loadLocalTracks(
|
||||||
|
origin: .localScan,
|
||||||
|
includeDeleted: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(storedTracks.count, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadStatusPersistenceSurvivesReload() async throws {
|
||||||
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
||||||
|
let observedAt = Date(timeIntervalSince1970: 8_000)
|
||||||
|
var track = makeLocalTrack(
|
||||||
|
id: "upload-track",
|
||||||
|
title: "Uploadable",
|
||||||
|
path: "/Music/Uploadable.mp3",
|
||||||
|
sha256: "sha-uploadable",
|
||||||
|
observedAt: observedAt
|
||||||
|
)
|
||||||
|
|
||||||
|
try await repository.saveLocalTrack(track)
|
||||||
|
|
||||||
|
track.uploadStatus = .uploaded
|
||||||
|
track.remoteTrackId = "remote-track-42"
|
||||||
|
track.lastUploadError = nil
|
||||||
|
track.updatedAt = Date(timeIntervalSince1970: 8_100)
|
||||||
|
|
||||||
|
try await repository.saveLocalTrack(track)
|
||||||
|
|
||||||
|
let persistedTrack = try await repository.findTrack(trackID: track.id)
|
||||||
|
let reloadedTrack = try XCTUnwrap(persistedTrack)
|
||||||
|
|
||||||
|
XCTAssertEqual(reloadedTrack.uploadStatus, .uploaded)
|
||||||
|
XCTAssertEqual(reloadedTrack.remoteTrackId, "remote-track-42")
|
||||||
|
XCTAssertNil(reloadedTrack.lastUploadError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepositoryAndCatalogServiceAreActorTypes() throws {
|
||||||
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
||||||
|
let service = DefaultLocalCatalogService(repository: repository)
|
||||||
|
|
||||||
|
assertActorIsolation(repository)
|
||||||
|
assertActorIsolation(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepositoryDoesNotStoreModelContext() throws {
|
||||||
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
||||||
|
let storesModelContext = Mirror(reflecting: repository).children.contains { child in
|
||||||
|
child.value is ModelContext
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(storesModelContext)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeScannedTrack(
|
private func makeScannedTrack(
|
||||||
title: String,
|
title: String,
|
||||||
path: String,
|
path: String,
|
||||||
@ -270,4 +384,36 @@ final class LocalCatalogServiceTests: XCTestCase {
|
|||||||
fileModifiedAt: modifiedAt
|
fileModifiedAt: modifiedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeLocalTrack(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
path: String,
|
||||||
|
sha256: String,
|
||||||
|
observedAt: Date
|
||||||
|
) -> LocalTrack {
|
||||||
|
LocalTrack(
|
||||||
|
id: id,
|
||||||
|
origin: .localScan,
|
||||||
|
title: title,
|
||||||
|
artist: "Artist",
|
||||||
|
album: "Album",
|
||||||
|
durationSeconds: 180,
|
||||||
|
localFilePath: path,
|
||||||
|
sha256: sha256,
|
||||||
|
uploadStatus: .localOnly,
|
||||||
|
remoteTrackId: nil,
|
||||||
|
lastUploadError: nil,
|
||||||
|
fileModifiedAt: observedAt,
|
||||||
|
lastScannedAt: observedAt,
|
||||||
|
isDeleted: false,
|
||||||
|
deletedAt: nil,
|
||||||
|
createdAt: observedAt,
|
||||||
|
updatedAt: observedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertActorIsolation<T: Actor>(_ value: T) {
|
||||||
|
XCTAssertNotNil(value as AnyObject)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -278,8 +278,9 @@ public final class PlaybackController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
let fileURL = try localFileURL(for: currentTrack)
|
||||||
try engine.loadTrack(
|
try engine.loadTrack(
|
||||||
at: URL(fileURLWithPath: currentTrack.localFilePath),
|
at: fileURL,
|
||||||
startTime: startTime
|
startTime: startTime
|
||||||
)
|
)
|
||||||
loadedTrackID = currentTrack.id
|
loadedTrackID = currentTrack.id
|
||||||
@ -299,15 +300,16 @@ public final class PlaybackController {
|
|||||||
nowPlayingState.isPlaying = false
|
nowPlayingState.isPlaying = false
|
||||||
nowPlayingState.currentTrack = currentTrack
|
nowPlayingState.currentTrack = currentTrack
|
||||||
nowPlayingState.currentTime = startTime
|
nowPlayingState.currentTime = startTime
|
||||||
nowPlayingState.duration = effectiveDuration(for: currentTrack)
|
nowPlayingState.duration = currentTrack.durationSeconds ?? 0
|
||||||
applyPlaybackError(error)
|
applyPlaybackError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreTrack(_ track: LibraryTrack, position: Double) {
|
private func restoreTrack(_ track: LibraryTrack, position: Double) {
|
||||||
do {
|
do {
|
||||||
|
let fileURL = try localFileURL(for: track)
|
||||||
try engine.loadTrack(
|
try engine.loadTrack(
|
||||||
at: URL(fileURLWithPath: track.localFilePath),
|
at: fileURL,
|
||||||
startTime: position
|
startTime: position
|
||||||
)
|
)
|
||||||
loadedTrackID = track.id
|
loadedTrackID = track.id
|
||||||
@ -320,7 +322,7 @@ public final class PlaybackController {
|
|||||||
nowPlayingState.currentTrack = track
|
nowPlayingState.currentTrack = track
|
||||||
nowPlayingState.isPlaying = false
|
nowPlayingState.isPlaying = false
|
||||||
nowPlayingState.currentTime = position
|
nowPlayingState.currentTime = position
|
||||||
nowPlayingState.duration = effectiveDuration(for: track)
|
nowPlayingState.duration = track.durationSeconds ?? 0
|
||||||
applyPlaybackError(error)
|
applyPlaybackError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,6 +380,14 @@ public final class PlaybackController {
|
|||||||
return track?.durationSeconds ?? 0
|
return track?.durationSeconds ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func localFileURL(for track: LibraryTrack) throws -> URL {
|
||||||
|
guard !track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
throw PlaybackError.missingLocalFile(path: track.localFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return URL(fileURLWithPath: track.localFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
private func startProgressTimer() {
|
private func startProgressTimer() {
|
||||||
progressTimer?.invalidate()
|
progressTimer?.invalidate()
|
||||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
|
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
|
||||||
|
|||||||
@ -44,19 +44,19 @@ public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudio
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func loadTrack(at fileURL: URL, startTime: Double) throws {
|
public func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
|
||||||
throw PlaybackError.missingLocalFile(path: fileURL.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
unloadCurrentTrack()
|
||||||
|
try validatePlayableFile(at: fileURL)
|
||||||
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
||||||
audioPlayer.delegate = self
|
audioPlayer.delegate = self
|
||||||
audioPlayer.prepareToPlay()
|
audioPlayer.prepareToPlay()
|
||||||
audioPlayer.currentTime = min(max(startTime, 0), audioPlayer.duration)
|
audioPlayer.currentTime = min(max(startTime, 0), audioPlayer.duration)
|
||||||
self.audioPlayer = audioPlayer
|
self.audioPlayer = audioPlayer
|
||||||
} catch let error as PlaybackError {
|
} catch let error as PlaybackError {
|
||||||
|
unloadCurrentTrack()
|
||||||
throw error
|
throw error
|
||||||
} catch {
|
} catch {
|
||||||
|
unloadCurrentTrack()
|
||||||
throw PlaybackError.failedToLoadTrack(path: fileURL.path)
|
throw PlaybackError.failedToLoadTrack(path: fileURL.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,6 +88,38 @@ public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudio
|
|||||||
audioPlayer.currentTime = min(max(time, 0), audioPlayer.duration)
|
audioPlayer.currentTime = min(max(time, 0), audioPlayer.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func validatePlayableFile(at fileURL: URL) throws {
|
||||||
|
let filePath = Self.displayPath(for: fileURL)
|
||||||
|
guard fileURL.isFileURL else {
|
||||||
|
throw PlaybackError.missingLocalFile(path: filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !fileURL.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
throw PlaybackError.missingLocalFile(path: filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||||
|
throw PlaybackError.missingLocalFile(path: filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (try? fileURL.checkResourceIsReachable()) == true else {
|
||||||
|
throw PlaybackError.missingLocalFile(path: filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unloadCurrentTrack() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
audioPlayer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func displayPath(for fileURL: URL) -> String {
|
||||||
|
if fileURL.isFileURL {
|
||||||
|
return fileURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileURL.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated public func audioPlayerDidFinishPlaying(
|
nonisolated public func audioPlayerDidFinishPlaying(
|
||||||
_ player: AVAudioPlayer,
|
_ player: AVAudioPlayer,
|
||||||
successfully flag: Bool
|
successfully flag: Bool
|
||||||
|
|||||||
@ -18,9 +18,19 @@ public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable
|
|||||||
case .noTrackLoaded:
|
case .noTrackLoaded:
|
||||||
return "No audio track is currently loaded."
|
return "No audio track is currently loaded."
|
||||||
case .missingLocalFile(let path):
|
case .missingLocalFile(let path):
|
||||||
return "The local file could not be found: \(path)"
|
let safePath = Self.safeDisplayPath(from: path)
|
||||||
|
if safePath.isEmpty {
|
||||||
|
return "The local file could not be found."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "The local file could not be found: \(safePath)"
|
||||||
case .failedToLoadTrack(let path):
|
case .failedToLoadTrack(let path):
|
||||||
return "The audio file could not be opened: \(path)"
|
let safePath = Self.safeDisplayPath(from: path)
|
||||||
|
if safePath.isEmpty {
|
||||||
|
return "The audio file could not be opened."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "The audio file could not be opened: \(safePath)"
|
||||||
case .failedToStartPlayback:
|
case .failedToStartPlayback:
|
||||||
return "Playback could not be started."
|
return "Playback could not be started."
|
||||||
case .seekUnavailable:
|
case .seekUnavailable:
|
||||||
@ -28,3 +38,14 @@ public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension PlaybackError {
|
||||||
|
static func safeDisplayPath(from path: String) -> String {
|
||||||
|
let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedPath.isEmpty else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(trimmedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import VelodyPlayback
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AVFoundationPlaybackEngineTests: XCTestCase {
|
||||||
|
func testLoadTrackMissingFileThrowsPlaybackErrorAndLeavesEngineUnloaded() {
|
||||||
|
let engine = AVFoundationPlaybackEngine(fileManager: .default)
|
||||||
|
let fileURL = URL(fileURLWithPath: "/private/tmp/\(UUID().uuidString)-missing.mp3")
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try engine.loadTrack(at: fileURL, startTime: 0)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? PlaybackError,
|
||||||
|
.missingLocalFile(path: fileURL.path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(engine.currentTime, 0)
|
||||||
|
XCTAssertEqual(engine.duration, 0)
|
||||||
|
XCTAssertFalse(engine.isPlaying)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try engine.play()) { error in
|
||||||
|
XCTAssertEqual(error as? PlaybackError, .noTrackLoaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,6 +40,76 @@ final class PlaybackControllerTests: XCTestCase {
|
|||||||
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
||||||
XCTAssertEqual(controller.nowPlayingState.currentTime, 0)
|
XCTAssertEqual(controller.nowPlayingState.currentTime, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPlayMissingFileCapturesPlaybackErrorWithoutStartingPlayback() {
|
||||||
|
let engine = FakePlaybackEngine()
|
||||||
|
let sessionStore = InMemoryPlaybackSessionStore()
|
||||||
|
let controller = PlaybackController(
|
||||||
|
engine: engine,
|
||||||
|
sessionStore: sessionStore
|
||||||
|
)
|
||||||
|
let track = LibraryTrack(
|
||||||
|
id: "missing-track",
|
||||||
|
title: "Missing Track",
|
||||||
|
artist: "Tester",
|
||||||
|
durationSeconds: 180,
|
||||||
|
localFilePath: "/tmp/missing-track.mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine.loadTrackErrorsByPath[track.localFilePath] = .missingLocalFile(path: track.localFilePath)
|
||||||
|
|
||||||
|
controller.setCatalogTracks([track])
|
||||||
|
controller.play(trackID: track.id)
|
||||||
|
|
||||||
|
XCTAssertEqual(engine.loadTrackCallCount, 1)
|
||||||
|
XCTAssertEqual(engine.playCallCount, 0)
|
||||||
|
XCTAssertFalse(controller.nowPlayingState.isPlaying)
|
||||||
|
XCTAssertEqual(controller.nowPlayingState.currentTrackID, track.id)
|
||||||
|
XCTAssertEqual(
|
||||||
|
controller.nowPlayingState.error,
|
||||||
|
.missingLocalFile(path: track.localFilePath)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(controller.nowPlayingState.duration, track.durationSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFileKeepsQueueStableAndAllowsNextTrackPlayback() {
|
||||||
|
let engine = FakePlaybackEngine()
|
||||||
|
let sessionStore = InMemoryPlaybackSessionStore()
|
||||||
|
let controller = PlaybackController(
|
||||||
|
engine: engine,
|
||||||
|
sessionStore: sessionStore
|
||||||
|
)
|
||||||
|
let missingTrack = LibraryTrack(
|
||||||
|
id: "missing-track",
|
||||||
|
title: "Missing Track",
|
||||||
|
artist: "Tester",
|
||||||
|
durationSeconds: 180,
|
||||||
|
localFilePath: "/tmp/missing-track.mp3"
|
||||||
|
)
|
||||||
|
let nextTrack = LibraryTrack(
|
||||||
|
id: "next-track",
|
||||||
|
title: "Next Track",
|
||||||
|
artist: "Tester",
|
||||||
|
durationSeconds: 90,
|
||||||
|
localFilePath: "/tmp/next-track.mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine.loadTrackErrorsByPath[missingTrack.localFilePath] = .missingLocalFile(
|
||||||
|
path: missingTrack.localFilePath
|
||||||
|
)
|
||||||
|
engine.durationByPath[nextTrack.localFilePath] = 90
|
||||||
|
|
||||||
|
controller.setCatalogTracks([missingTrack, nextTrack])
|
||||||
|
controller.play(trackID: missingTrack.id)
|
||||||
|
controller.next()
|
||||||
|
|
||||||
|
XCTAssertEqual(controller.nowPlayingState.currentTrackID, nextTrack.id)
|
||||||
|
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
||||||
|
XCTAssertNil(controller.nowPlayingState.error)
|
||||||
|
XCTAssertEqual(controller.nowPlayingState.queueTrackIDs, [missingTrack.id, nextTrack.id])
|
||||||
|
XCTAssertEqual(engine.loadTrackCallCount, 2)
|
||||||
|
XCTAssertEqual(engine.playCallCount, 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -48,6 +118,8 @@ private final class FakePlaybackEngine: PlaybackEngine {
|
|||||||
var currentTime: Double = 0
|
var currentTime: Double = 0
|
||||||
var duration: Double = 120
|
var duration: Double = 120
|
||||||
var isPlaying = false
|
var isPlaying = false
|
||||||
|
var durationByPath: [String: Double] = [:]
|
||||||
|
var loadTrackErrorsByPath: [String: PlaybackError] = [:]
|
||||||
|
|
||||||
private(set) var loadTrackCallCount = 0
|
private(set) var loadTrackCallCount = 0
|
||||||
private(set) var playCallCount = 0
|
private(set) var playCallCount = 0
|
||||||
@ -55,8 +127,17 @@ private final class FakePlaybackEngine: PlaybackEngine {
|
|||||||
|
|
||||||
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||||
loadTrackCallCount += 1
|
loadTrackCallCount += 1
|
||||||
|
|
||||||
|
if let error = loadTrackErrorsByPath[fileURL.path] {
|
||||||
|
currentTime = 0
|
||||||
|
duration = 0
|
||||||
|
isPlaying = false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
lastLoadedStartTime = startTime
|
lastLoadedStartTime = startTime
|
||||||
currentTime = startTime
|
currentTime = startTime
|
||||||
|
duration = durationByPath[fileURL.path] ?? duration
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VelodyPlayback
|
||||||
|
|
||||||
|
final class PlaybackErrorTests: XCTestCase {
|
||||||
|
func testMissingLocalFileErrorDescriptionIncludesPath() {
|
||||||
|
let error = PlaybackError.missingLocalFile(path: "/tmp/missing-track.mp3")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
error.errorDescription,
|
||||||
|
"The local file could not be found: /tmp/missing-track.mp3"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
error.localizedDescription,
|
||||||
|
"The local file could not be found: /tmp/missing-track.mp3"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingLocalFileErrorDescriptionFallsBackForBlankPath() {
|
||||||
|
let error = PlaybackError.missingLocalFile(path: " ")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
error.errorDescription,
|
||||||
|
"The local file could not be found."
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
error.localizedDescription,
|
||||||
|
"The local file could not be found."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user