diff --git a/.gitignore b/.gitignore index 9956104..83b2519 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,6 @@ packages/apple/**/.build/ # Xcode local build artifacts .derivedData/ DerivedData/ + +# Local uploaded media storage +runtime/storage/users/ diff --git a/README.md b/README.md index 75474de..37204e6 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,5 @@ This phase intentionally does not yet implement: - upload chunk transfer - background downloads - audio playback + +The local backend now defaults to `http://localhost:3007`. diff --git a/apps/apple/VelodyMac/Sources/MacLibraryView.swift b/apps/apple/VelodyMac/Sources/MacLibraryView.swift index e0eacd7..cab9254 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryView.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryView.swift @@ -14,7 +14,7 @@ struct MacLibraryView: View { Text("Backend Connection") .font(.title2) - TextField("http://localhost:3000", text: $viewModel.serverURLString) + TextField("http://localhost:3007", text: $viewModel.serverURLString) .textFieldStyle(.roundedBorder) .onSubmit { viewModel.persistServerURLSelection() diff --git a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift index c0e318a..0b34765 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift @@ -16,6 +16,7 @@ final class MacLibraryViewModel { var discoveredTrackCount = 0 var isScanning = false var nowPlayingState = NowPlayingState() + var playbackErrorMessage: String? var serverURLString: String var deviceRegistrationStatus = "Not registered." @@ -70,9 +71,11 @@ final class MacLibraryViewModel { self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey) ?? ServerEnvironment.defaultLocalBaseURL.absoluteString self.nowPlayingState = playbackController.nowPlayingState + self.playbackErrorMessage = Self.playbackErrorMessage(from: playbackController.nowPlayingState.error) playbackController.onStateChange = { [weak self] state in self?.nowPlayingState = state + self?.playbackErrorMessage = Self.playbackErrorMessage(from: state.error) } if let url = folderAccessService.storedFolderURL() { @@ -215,10 +218,6 @@ final class MacLibraryViewModel { } } - var playbackErrorMessage: String? { - nowPlayingState.error?.errorDescription - } - func uploadSelectedTrack() async { guard let selectedTrackID else { lastUploadStatus = "Select a local track before uploading." @@ -789,6 +788,10 @@ final class MacLibraryViewModel { private static let deviceIdKey = "velody.device-id" private static let bootstrapTokenKey = "velody.bootstrap-token" private static let playbackSessionDefaultsKey = "velody.playback.session" + + private static func playbackErrorMessage(from error: PlaybackError?) -> String? { + error?.errorDescription ?? error?.localizedDescription + } } private enum UploadOutcome { @@ -803,7 +806,7 @@ private enum BackendConnectionError: LocalizedError { var errorDescription: String? { switch self { 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: return "Register this Mac before uploading or sending a heartbeat." } diff --git a/docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md b/docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md index ae24e00..9cbc6ce 100644 --- a/docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md +++ b/docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md @@ -1677,7 +1677,7 @@ VPS ```text NODE_ENV=production -PORT=3000 +PORT=3007 DATABASE_URL=postgresql://... STORAGE_ROOT=/srv/velody/data PUBLIC_BASE_URL=https://music.diyaa.de diff --git a/infra/docker/compose.local.yml b/infra/docker/compose.local.yml index aa3c26a..65baebb 100644 --- a/infra/docker/compose.local.yml +++ b/infra/docker/compose.local.yml @@ -22,7 +22,7 @@ services: env_file: - ../docker/env/backend.env.example ports: - - "3000:3000" + - "3007:3007" depends_on: postgres: condition: service_healthy diff --git a/infra/docker/env/backend.env.example b/infra/docker/env/backend.env.example index 18d5aaf..5cc1d18 100644 --- a/infra/docker/env/backend.env.example +++ b/infra/docker/env/backend.env.example @@ -1,7 +1,7 @@ NODE_ENV=development -PORT=3000 +PORT=3007 DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public STORAGE_ROOT=/app/runtime/storage -PUBLIC_BASE_URL=http://localhost:3000 +PUBLIC_BASE_URL=http://localhost:3007 DEVICE_BOOTSTRAP_SECRET=replace-me MAX_UPLOAD_SIZE_BYTES=524288000 diff --git a/infra/nginx/music.diyaa.de.conf b/infra/nginx/music.diyaa.de.conf index 471cf38..c1189e1 100644 --- a/infra/nginx/music.diyaa.de.conf +++ b/infra/nginx/music.diyaa.de.conf @@ -3,7 +3,7 @@ server { server_name music.diyaa.de; location / { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3007; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift index 1631d22..3c0d0b7 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift @@ -322,7 +322,7 @@ public struct SyncBootstrapResponse: 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 appVersion: String diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift index d6ba5b8..cda07a7 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift @@ -1,7 +1,7 @@ import Foundation import VelodyDomain -public protocol LocalCatalogService: Sendable { +public protocol LocalCatalogService: Actor { func loadActiveLocalTracks() async throws -> [LibraryTrack] func reconcileScanResults( _ scannedTracks: [ScannedLocalTrack], diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift index 9ce12c3..a55ec9e 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalLibraryStore.swift @@ -1,7 +1,7 @@ import Foundation import VelodyDomain -public protocol LocalLibraryStore: Sendable { +public protocol LocalLibraryStore: Actor { func loadTracks() async throws -> [LibraryTrack] func replaceTracks(_ tracks: [LibraryTrack]) async throws } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift index 451eaaf..bb5747c 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackRepository.swift @@ -1,5 +1,5 @@ import Foundation -@preconcurrency import SwiftData +import SwiftData import VelodyDomain public protocol TrackRepository: LocalLibraryStore { @@ -23,8 +23,7 @@ public protocol TrackRepository: LocalLibraryStore { } public actor SwiftDataTrackRepository: TrackRepository { - private let modelContainer: ModelContainer - private let modelContext: ModelContext + private let database: SwiftDataCatalogDatabase public init( databaseURL: URL? = nil, @@ -49,111 +48,55 @@ public actor SwiftDataTrackRepository: TrackRepository { configuration = ModelConfiguration(url: storeURL) } - modelContainer = try ModelContainer( + let modelContainer = try ModelContainer( for: TrackEntity.self, configurations: configuration ) - modelContext = ModelContext(modelContainer) - modelContext.autosaveEnabled = false + database = SwiftDataCatalogDatabase(modelContainer: modelContainer) } public func loadTracks() async throws -> [LibraryTrack] { - try await loadLocalTracks(origin: nil, includeDeleted: false) - .map(\.libraryTrack) + try await database.loadTracks() } 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 - ) - ) - } + try await database.replaceTracks(tracks) } 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(_:_:)) + try await database.loadLocalTracks( + origin: origin, + includeDeleted: includeDeleted + ) } public func findTrack(trackID: String) async throws -> LocalTrack? { - try fetchEntities() - .first(where: { $0.trackID == trackID })? - .localTrack + try await database.findTrack(trackID: trackID) } public func findTrack(deduplicationKey: String) async throws -> LocalTrack? { - try fetchEntities() - .first(where: { $0.deduplicationKey == deduplicationKey })? - .localTrack + try await database.findTrack(deduplicationKey: deduplicationKey) } 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 + try await database.findTrack( + localFilePath: localFilePath, + origin: origin + ) } 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() + try await database.saveLocalTrack(track) } 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() - } + try await database.removeTracks(origin: origin) } public func markDeletedLocalTracks( @@ -161,26 +104,11 @@ public actor SwiftDataTrackRepository: TrackRepository { 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 + try await database.markDeletedLocalTracks( + missingFrom: scannedFilePaths, + under: rootFolderPath, + scannedAt: scannedAt + ) } private static func defaultStoreURL(fileManager: FileManager) throws -> URL { @@ -195,19 +123,6 @@ public actor SwiftDataTrackRepository: TrackRepository { .appendingPathComponent("Velody", isDirectory: true) .appendingPathComponent("local-catalog.store") } - - private func fetchEntities() throws -> [TrackEntity] { - try modelContext.fetch(FetchDescriptor()) - } - - 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 { @@ -335,6 +250,159 @@ public actor InMemoryTrackRepository: TrackRepository { 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, + 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()) + } + + 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 { diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift index 4e2f8cc..41a8f0f 100644 --- a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftData import XCTest @testable import VelodyPersistence import VelodyDomain @@ -254,6 +255,119 @@ final class LocalCatalogServiceTests: XCTestCase { 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( title: String, path: String, @@ -270,4 +384,36 @@ final class LocalCatalogServiceTests: XCTestCase { 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(_ value: T) { + XCTAssertNotNil(value as AnyObject) + } } diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift index 625407c..fa4fcee 100644 --- a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackController.swift @@ -278,8 +278,9 @@ public final class PlaybackController { } do { + let fileURL = try localFileURL(for: currentTrack) try engine.loadTrack( - at: URL(fileURLWithPath: currentTrack.localFilePath), + at: fileURL, startTime: startTime ) loadedTrackID = currentTrack.id @@ -299,15 +300,16 @@ public final class PlaybackController { nowPlayingState.isPlaying = false nowPlayingState.currentTrack = currentTrack nowPlayingState.currentTime = startTime - nowPlayingState.duration = effectiveDuration(for: currentTrack) + nowPlayingState.duration = currentTrack.durationSeconds ?? 0 applyPlaybackError(error) } } private func restoreTrack(_ track: LibraryTrack, position: Double) { do { + let fileURL = try localFileURL(for: track) try engine.loadTrack( - at: URL(fileURLWithPath: track.localFilePath), + at: fileURL, startTime: position ) loadedTrackID = track.id @@ -320,7 +322,7 @@ public final class PlaybackController { nowPlayingState.currentTrack = track nowPlayingState.isPlaying = false nowPlayingState.currentTime = position - nowPlayingState.duration = effectiveDuration(for: track) + nowPlayingState.duration = track.durationSeconds ?? 0 applyPlaybackError(error) } @@ -378,6 +380,14 @@ public final class PlaybackController { 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() { progressTimer?.invalidate() progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift index 769dcd7..809c65a 100644 --- a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackEngine.swift @@ -44,19 +44,19 @@ public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudio } public func loadTrack(at fileURL: URL, startTime: Double) throws { - guard fileManager.fileExists(atPath: fileURL.path) else { - throw PlaybackError.missingLocalFile(path: fileURL.path) - } - do { + unloadCurrentTrack() + try validatePlayableFile(at: fileURL) let audioPlayer = try AVAudioPlayer(contentsOf: fileURL) audioPlayer.delegate = self audioPlayer.prepareToPlay() audioPlayer.currentTime = min(max(startTime, 0), audioPlayer.duration) self.audioPlayer = audioPlayer } catch let error as PlaybackError { + unloadCurrentTrack() throw error } catch { + unloadCurrentTrack() 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) } + 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( _ player: AVAudioPlayer, successfully flag: Bool diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift index 657720d..f7b6ced 100644 --- a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackError.swift @@ -18,9 +18,19 @@ public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable case .noTrackLoaded: return "No audio track is currently loaded." 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): - 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: return "Playback could not be started." 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) + } +} diff --git a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/AVFoundationPlaybackEngineTests.swift b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/AVFoundationPlaybackEngineTests.swift new file mode 100644 index 0000000..6af105d --- /dev/null +++ b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/AVFoundationPlaybackEngineTests.swift @@ -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) + } + } +} diff --git a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift index cff53e0..365ceaf 100644 --- a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift +++ b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackControllerTests.swift @@ -40,6 +40,76 @@ final class PlaybackControllerTests: XCTestCase { XCTAssertTrue(controller.nowPlayingState.isPlaying) 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 @@ -48,6 +118,8 @@ private final class FakePlaybackEngine: PlaybackEngine { var currentTime: Double = 0 var duration: Double = 120 var isPlaying = false + var durationByPath: [String: Double] = [:] + var loadTrackErrorsByPath: [String: PlaybackError] = [:] private(set) var loadTrackCallCount = 0 private(set) var playCallCount = 0 @@ -55,8 +127,17 @@ private final class FakePlaybackEngine: PlaybackEngine { func loadTrack(at fileURL: URL, startTime: Double) throws { loadTrackCallCount += 1 + + if let error = loadTrackErrorsByPath[fileURL.path] { + currentTime = 0 + duration = 0 + isPlaying = false + throw error + } + lastLoadedStartTime = startTime currentTime = startTime + duration = durationByPath[fileURL.path] ?? duration isPlaying = false } diff --git a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackErrorTests.swift b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackErrorTests.swift new file mode 100644 index 0000000..dd116d5 --- /dev/null +++ b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackErrorTests.swift @@ -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." + ) + } +}