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
|
||||
.derivedData/
|
||||
DerivedData/
|
||||
|
||||
# Local uploaded media storage
|
||||
runtime/storage/users/
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,7 +22,7 @@ services:
|
||||
env_file:
|
||||
- ../docker/env/backend.env.example
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3007:3007"
|
||||
depends_on:
|
||||
postgres:
|
||||
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
|
||||
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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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