Fix SwiftData persistence concurrency stability

This commit is contained in:
diyaa 2026-05-29 00:06:57 +02:00
parent e5027152ab
commit ebc187f4a1
19 changed files with 554 additions and 132 deletions

3
.gitignore vendored
View File

@ -122,3 +122,6 @@ packages/apple/**/.build/
# Xcode local build artifacts
.derivedData/
DerivedData/
# Local uploaded media storage
runtime/storage/users/

View File

@ -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`.

View File

@ -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()

View File

@ -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."
}

View File

@ -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

View File

@ -22,7 +22,7 @@ services:
env_file:
- ../docker/env/backend.env.example
ports:
- "3000:3000"
- "3007:3007"
depends_on:
postgres:
condition: service_healthy

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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],

View File

@ -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
}

View File

@ -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<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 {
@ -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<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 {

View File

@ -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<T: Actor>(_ value: T) {
XCTAssertNotNil(value as AnyObject)
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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."
)
}
}