velody/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift
2026-06-09 12:05:15 +02:00

939 lines
32 KiB
Swift

import Foundation
import Observation
import VelodyDomain
import VelodyNetworking
import VelodyPlayback
import VelodyPersistence
import VelodyUtilities
@MainActor
@Observable
final class MacLibraryViewModel {
var tracks: [LibraryTrack] = []
var selectedTrackID: String?
var selectedFolderPath = "No folder selected"
var scanStatus = "Choose a folder to begin local discovery."
var discoveredTrackCount = 0
var isScanning = false
var nowPlayingState = NowPlayingState()
var playbackErrorMessage: String?
var serverURLString: String
var deviceRegistrationStatus = "Not registered."
var registeredDeviceId: String?
var lastHeartbeatStatus = "No heartbeat sent yet."
var lastSyncBootstrapStatus = "No sync bootstrap run yet."
var lastUploadStatus = "No uploads run yet."
var lastBootstrapTrackCount: Int?
var lastBootstrapCursor: String?
var isRegisteringDevice = false
var isSendingHeartbeat = false
var isRunningSyncBootstrap = false
var isUploadingAllTracks = false
var activeUploadTrackIDs: Set<String> = []
var uploadProgressByTrackID: [String: Double] = [:]
private let folderAccessService: any VelodyPersistence.FolderAccessService
private let catalogService: any LocalCatalogService
private let trackRepository: any TrackRepository
private let localMusicScanner: any LocalMusicScanner
private let localArtworkStore: any LocalArtworkStore
private let artworkUploadPayloadBuilder: LocalTrackArtworkUploadPayloadBuilder
private let playbackController: PlaybackController
private let keychainService: any KeychainService
private let userDefaults: UserDefaults
private let fileManager: FileManager
private var hasLoaded = false
init(
userDefaults: UserDefaults = .standard,
keychainService: any KeychainService = SystemKeychainService(service: "de.diyaa.velody.mac"),
fileManager: FileManager = .default
) {
let folderAccessService = FolderAccessService()
let localArtworkStore = Self.makeLocalArtworkStore()
let localMusicScanner = FileSystemLocalMusicScanner(
metadataReader: AVFoundationMetadataReader(),
artworkStore: localArtworkStore
)
let repository = Self.makeTrackRepository()
let playbackController = PlaybackController(
sessionStore: UserDefaultsPlaybackSessionStore(
userDefaults: userDefaults,
storageKey: Self.playbackSessionDefaultsKey
)
)
self.folderAccessService = folderAccessService
self.catalogService = DefaultLocalCatalogService(repository: repository)
self.trackRepository = repository
self.localMusicScanner = localMusicScanner
self.localArtworkStore = localArtworkStore
self.artworkUploadPayloadBuilder = LocalTrackArtworkUploadPayloadBuilder(
artworkStore: localArtworkStore
)
self.playbackController = playbackController
self.keychainService = keychainService
self.userDefaults = userDefaults
self.fileManager = fileManager
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() {
selectedFolderPath = url.path
scanStatus = "Folder restored. Run a manual scan to discover MP3 files."
}
}
var isUploadingAnyTrack: Bool {
!activeUploadTrackIDs.isEmpty
}
func loadIfNeeded() async {
guard !hasLoaded else { return }
hasLoaded = true
do {
tracks = try await catalogService.loadActiveLocalTracks()
discoveredTrackCount = tracks.count
} catch {
scanStatus = "Failed to load saved catalog: \(error.localizedDescription)"
}
playbackController.setCatalogTracks(tracks)
refreshSelectedTrackIfNeeded()
await restoreDeviceIdentity()
}
func chooseFolder() {
if let url = folderAccessService.chooseFolder() {
selectedFolderPath = url.path
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
tracks = []
discoveredTrackCount = 0
selectedTrackID = nil
playbackController.setCatalogTracks([])
Task {
do {
try await catalogService.resetLocalCatalog()
} catch {
scanStatus = "Failed to reset saved catalog: \(error.localizedDescription)"
}
}
}
}
func scanMP3Files() async {
guard let folderURL = folderAccessService.storedFolderURL() else {
scanStatus = "Choose a folder before scanning."
return
}
isScanning = true
scanStatus = "Scanning MP3 files..."
defer {
isScanning = false
}
do {
let discoveredTracks = try await localMusicScanner.scanFolder(at: folderURL)
let scanResult = try await catalogService.reconcileScanResults(
discoveredTracks,
in: folderURL,
scannedAt: Date()
)
tracks = scanResult.tracks
discoveredTrackCount = tracks.count
playbackController.setCatalogTracks(tracks)
refreshSelectedTrackIfNeeded()
scanStatus = Self.scanStatus(
for: scanResult,
activeTrackCount: discoveredTrackCount
)
} catch {
scanStatus = "Scan failed: \(error.localizedDescription)"
}
}
func togglePlayback(for track: LibraryTrack) {
if nowPlayingState.currentTrackID == track.id {
playbackController.playPause()
} else {
playbackController.play(trackID: track.id)
}
}
func togglePlayPause() {
playbackController.playPause()
}
func stopPlayback() {
playbackController.stop()
}
func seekPlayback(to time: Double) {
playbackController.seek(to: time)
}
func playNextTrack() {
playbackController.next()
}
func playPreviousTrack() {
playbackController.previous()
}
func toggleShuffle() {
playbackController.toggleShuffle()
}
func cycleRepeatMode() {
playbackController.cycleRepeatMode()
}
func isCurrentTrack(_ track: LibraryTrack) -> Bool {
nowPlayingState.currentTrackID == track.id
}
func isPlaying(_ track: LibraryTrack) -> Bool {
isCurrentTrack(track) && nowPlayingState.isPlaying
}
func playbackButtonSymbol(for track: LibraryTrack) -> String {
if isPlaying(track) {
return "pause.circle.fill"
}
if isCurrentTrack(track) {
return "play.circle.fill"
}
return "play.circle"
}
var repeatButtonSymbol: String {
switch nowPlayingState.repeatMode {
case .off, .all:
return "repeat"
case .one:
return "repeat.1"
}
}
func uploadSelectedTrack() async {
guard let selectedTrackID else {
lastUploadStatus = "Select a local track before uploading."
return
}
_ = await uploadTrack(trackID: selectedTrackID)
}
func uploadAllLocalTracks() async {
guard !tracks.isEmpty else {
lastUploadStatus = "Scan a folder before starting uploads."
return
}
isUploadingAllTracks = true
lastUploadStatus = "Uploading all local tracks..."
defer {
isUploadingAllTracks = false
}
var uploadedCount = 0
var failedCount = 0
var skippedDuplicateCount = 0
var uploadedTrackIDsBySHA: [String: String] = [:]
var seenSHA256Values = Set<String>()
for track in tracks {
let sha256: String
do {
sha256 = try await ensureSHA256(forTrackID: track.id)
} catch {
failedCount += 1
lastUploadStatus = "Upload failed for \(track.title): \(error.localizedDescription)"
try? await setTrackUploadState(
trackID: track.id,
status: .failed,
remoteTrackId: track.remoteTrackId,
lastUploadError: error.localizedDescription,
progress: nil
)
continue
}
if !seenSHA256Values.insert(sha256).inserted {
skippedDuplicateCount += 1
if let remoteTrackID = uploadedTrackIDsBySHA[sha256] {
try? await setTrackUploadState(
trackID: track.id,
status: .uploaded,
remoteTrackId: remoteTrackID,
lastUploadError: nil,
progress: 1
)
}
continue
}
switch await uploadTrack(trackID: track.id, knownSHA256: sha256) {
case .success(let remoteTrackID):
uploadedCount += 1
if let remoteTrackID {
uploadedTrackIDsBySHA[sha256] = remoteTrackID
}
case .failure:
failedCount += 1
}
}
lastUploadStatus = "Bulk upload finished. Uploaded: \(uploadedCount). Failed: \(failedCount). Duplicates skipped: \(skippedDuplicateCount)."
}
func uploadStatus(for track: LibraryTrack) -> LocalUploadStatus {
track.uploadStatus ?? .localOnly
}
func uploadStatusLabel(for track: LibraryTrack) -> String {
switch uploadStatus(for: track) {
case .localOnly:
return "Local only"
case .preparing:
return "Preparing"
case .uploading:
return "Uploading"
case .uploaded:
return "Uploaded"
case .failed:
return "Failed"
}
}
func uploadProgress(for track: LibraryTrack) -> Double? {
uploadProgressByTrackID[track.id]
}
func persistServerURLSelection() {
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
return
}
serverURLString = serverURL.absoluteString
userDefaults.set(serverURLString, forKey: Self.serverURLDefaultsKey)
}
func registerThisMac() async {
isRegisteringDevice = true
deviceRegistrationStatus = "Registering this Mac..."
defer {
isRegisteringDevice = false
}
do {
let environment = try currentEnvironment()
let payload = DeviceRegistrationPayload(
platform: .macos,
deviceName: Self.currentDeviceName,
appVersion: environment.appVersion
)
let response = try await makeAPIClient(for: environment).registerDevice(payload)
try await persistRegisteredDevice(response)
} catch {
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
}
}
func sendHeartbeat() async {
isSendingHeartbeat = true
lastHeartbeatStatus = "Sending heartbeat..."
defer {
isSendingHeartbeat = false
}
do {
let environment = try currentEnvironment()
let deviceId = try await currentOrRegisterDeviceId(for: environment)
let response = try await makeAPIClient(for: environment).sendHeartbeat(
DeviceHeartbeatPayload(
deviceId: deviceId,
appVersion: environment.appVersion
)
)
registeredDeviceId = deviceId
lastHeartbeatStatus = response.ok
? "Heartbeat succeeded at \(response.serverTime)."
: "Heartbeat did not succeed."
} catch {
lastHeartbeatStatus = "Heartbeat failed: \(error.localizedDescription)"
}
}
func syncBootstrap() async {
isRunningSyncBootstrap = true
lastSyncBootstrapStatus = "Running sync bootstrap..."
defer {
isRunningSyncBootstrap = false
}
do {
let environment = try currentEnvironment()
let response = try await makeAPIClient(for: environment).fetchSyncBootstrap()
lastBootstrapTrackCount = response.tracks.count
lastBootstrapCursor = response.nextCursor.value
lastSyncBootstrapStatus = "Sync bootstrap succeeded. Tracks: \(response.tracks.count). Next cursor: \(response.nextCursor.value)."
} catch {
lastSyncBootstrapStatus = "Sync bootstrap failed: \(error.localizedDescription)"
}
}
private func uploadTrack(
trackID: String,
knownSHA256: String? = nil
) async -> UploadOutcome {
guard !activeUploadTrackIDs.contains(trackID) else {
return .failure
}
guard let initialTrack = track(for: trackID) else {
lastUploadStatus = "The selected track is no longer in the local catalog."
return .failure
}
activeUploadTrackIDs.insert(trackID)
defer {
activeUploadTrackIDs.remove(trackID)
}
do {
let environment = try currentEnvironment()
let deviceId = try await currentOrRegisterDeviceId(for: environment)
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
try await withStoredFolderAccess {
guard fileManager.fileExists(atPath: fileURL.path) else {
throw UploadPipelineError.localFileMissing
}
}
try await setTrackUploadState(
trackID: trackID,
status: .preparing,
remoteTrackId: initialTrack.remoteTrackId,
lastUploadError: nil,
progress: 0.15
)
let sha256 = try await ensureSHA256(
forTrackID: trackID,
knownValue: knownSHA256
)
let sizeBytes = try await withStoredFolderAccess {
try fileSize(at: fileURL)
}
let apiClient = makeAPIClient(for: environment)
let prepareResponse = try await apiClient.prepareUpload(
UploadPrepareRequest(
deviceId: deviceId,
sha256: sha256,
originalFilename: fileURL.lastPathComponent,
sizeBytes: sizeBytes
)
)
switch prepareResponse.status {
case .exists:
if let uploadId = prepareResponse.uploadId,
let track = currentTrack(for: trackID),
track.artwork != nil
{
try await setTrackUploadState(
trackID: trackID,
status: .uploading,
remoteTrackId: prepareResponse.trackId ?? track.remoteTrackId,
lastUploadError: nil,
progress: 0.7
)
let finalizeResponse = try await finalizePreparedUpload(
trackID: trackID,
uploadId: uploadId,
apiClient: apiClient
)
try await setTrackUploadState(
trackID: trackID,
status: .uploaded,
remoteTrackId: finalizeResponse.trackId,
lastUploadError: nil,
progress: 1
)
lastUploadStatus = "Updated remote artwork for \(track.title) on track \(finalizeResponse.trackId)."
return .success(remoteTrackId: finalizeResponse.trackId)
}
let remoteTrackID = prepareResponse.trackId ?? currentTrack(for: trackID)?.remoteTrackId
try await setTrackUploadState(
trackID: trackID,
status: .uploaded,
remoteTrackId: remoteTrackID,
lastUploadError: nil,
progress: 1
)
lastUploadStatus = remoteTrackID.map {
"Track already exists on the server as \($0)."
} ?? "Track already exists on the server."
return .success(remoteTrackId: remoteTrackID)
case .uploadRequired:
guard let uploadId = prepareResponse.uploadId else {
throw UploadPipelineError.invalidPrepareResponse
}
try await setTrackUploadState(
trackID: trackID,
status: .uploading,
remoteTrackId: currentTrack(for: trackID)?.remoteTrackId,
lastUploadError: nil,
progress: 0.55
)
let uploadResponse = try await withStoredFolderAccess {
try await apiClient.uploadFile(
uploadId: uploadId,
fileURL: fileURL,
mimeType: "audio/mpeg"
)
}
guard uploadResponse.status == .completed else {
throw UploadPipelineError.uploadDidNotComplete(uploadResponse.status.rawValue)
}
let finalizeResponse = try await finalizePreparedUpload(
trackID: trackID,
uploadId: uploadId,
apiClient: apiClient
)
try await setTrackUploadState(
trackID: trackID,
status: .uploaded,
remoteTrackId: finalizeResponse.trackId,
lastUploadError: nil,
progress: 1
)
let uploadedTrackTitle = currentTrack(for: trackID)?.title ?? initialTrack.title
lastUploadStatus = "Uploaded \(uploadedTrackTitle) as remote track \(finalizeResponse.trackId)."
return .success(remoteTrackId: finalizeResponse.trackId)
}
} catch {
let remoteTrackID = currentTrack(for: trackID)?.remoteTrackId
try? await setTrackUploadState(
trackID: trackID,
status: .failed,
remoteTrackId: remoteTrackID,
lastUploadError: error.localizedDescription,
progress: nil
)
lastUploadStatus = "Upload failed for \(initialTrack.title): \(error.localizedDescription)"
return .failure
}
}
private func finalizePreparedUpload(
trackID: String,
uploadId: String,
apiClient: any VelodyAPIClient
) async throws -> UploadFinalizeResponse {
try await setTrackUploadState(
trackID: trackID,
status: .uploading,
remoteTrackId: currentTrack(for: trackID)?.remoteTrackId,
lastUploadError: nil,
progress: 0.85
)
guard let track = currentTrack(for: trackID) else {
throw UploadPipelineError.trackMissing
}
return try await apiClient.finalizeUpload(
uploadId: uploadId,
payload: UploadFinalizeRequest(
title: track.title,
artist: track.artist,
album: track.album,
durationMs: durationMilliseconds(from: track.durationSeconds),
artwork: try await artworkUploadPayloadBuilder.makePayload(
for: track.artwork
)
)
)
}
private func restoreDeviceIdentity() async {
do {
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
let deviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
)
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
registeredDeviceId = deviceId
if let deviceId {
if let deviceAccessToken, !deviceAccessToken.isEmpty {
deviceRegistrationStatus = "Registered locally."
} else if let bootstrapToken, !bootstrapToken.isEmpty {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Re-register to get a device access token."
} else {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
}
} else {
deviceRegistrationStatus = "Not registered."
}
} catch {
deviceRegistrationStatus = "Failed to load saved device identity: \(error.localizedDescription)"
}
}
private func currentEnvironment() throws -> ServerEnvironment {
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
throw BackendConnectionError.invalidServerURL
}
serverURLString = serverURL.absoluteString
userDefaults.set(serverURLString, forKey: Self.serverURLDefaultsKey)
return ServerEnvironment(
baseURL: serverURL,
appVersion: Self.currentAppVersion
)
}
private func currentOrRegisterDeviceId(
for environment: ServerEnvironment
) async throws -> String {
if let registeredDeviceId, !registeredDeviceId.isEmpty {
if let savedDeviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
), !savedDeviceAccessToken.isEmpty {
return registeredDeviceId
}
}
if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey),
let savedDeviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
),
!savedDeviceAccessToken.isEmpty,
!savedDeviceId.isEmpty
{
registeredDeviceId = savedDeviceId
return savedDeviceId
}
let payload = DeviceRegistrationPayload(
platform: .macos,
deviceName: Self.currentDeviceName,
appVersion: environment.appVersion
)
let response = try await makeAPIClient(for: environment).registerDevice(payload)
try await persistRegisteredDevice(response)
return response.deviceId
}
private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient {
URLSessionVelodyAPIClient(
environment: environment,
deviceAccessTokenProvider: { [self] in
try await self.keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
}
)
}
private func persistRegisteredDevice(
_ response: DeviceRegistrationResponse
) async throws {
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
try await keychainService.save(
response.deviceAccessToken,
forKey: Self.deviceAccessTokenKey
)
try await keychainService.save(
response.bootstrapToken,
forKey: Self.bootstrapTokenKey
)
registeredDeviceId = response.deviceId
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
}
private func currentTrack(for trackID: String) -> LibraryTrack? {
tracks.first(where: { $0.id == trackID })
}
private func track(for trackID: String) -> LibraryTrack? {
tracks.first(where: { $0.id == trackID })
}
private func ensureSHA256(
forTrackID trackID: String,
knownValue: String? = nil
) async throws -> String {
if let knownValue, !knownValue.isEmpty {
return knownValue
}
guard let track = currentTrack(for: trackID) else {
throw UploadPipelineError.trackMissing
}
if let sha256 = track.sha256, !sha256.isEmpty {
return sha256
}
let fileURL = URL(fileURLWithPath: track.localFilePath)
let sha256 = try await withStoredFolderAccess {
try await Task.detached(priority: .utility) {
try SHA256FileHasher().hashFile(at: fileURL)
}.value
}
try await setTrackUploadState(
trackID: trackID,
status: track.uploadStatus ?? .localOnly,
remoteTrackId: track.remoteTrackId,
lastUploadError: track.lastUploadError,
progress: uploadProgressByTrackID[trackID],
sha256: sha256
)
return sha256
}
private func setTrackUploadState(
trackID: String,
status: LocalUploadStatus,
remoteTrackId: String?,
lastUploadError: String?,
progress: Double? = nil,
sha256: String? = nil
) async throws {
guard let index = tracks.firstIndex(where: { $0.id == trackID }) else {
throw UploadPipelineError.trackMissing
}
var updatedTrack = tracks[index]
updatedTrack.uploadStatus = status
updatedTrack.remoteTrackId = remoteTrackId
updatedTrack.lastUploadError = lastUploadError
if let sha256 {
updatedTrack.sha256 = sha256
}
tracks[index] = updatedTrack
playbackController.setCatalogTracks(tracks)
if let progress {
uploadProgressByTrackID[trackID] = progress
} else {
uploadProgressByTrackID.removeValue(forKey: trackID)
}
try await persistTrackState(updatedTrack)
}
private func persistTrackState(_ track: LibraryTrack) async throws {
guard var localTrack = try await trackRepository.findTrack(trackID: track.id) else {
throw UploadPipelineError.trackMissing
}
localTrack.title = track.title
localTrack.artist = track.artist
localTrack.album = track.album
localTrack.durationSeconds = track.durationSeconds
localTrack.localFilePath = track.localFilePath
localTrack.sha256 = track.sha256
localTrack.uploadStatus = track.uploadStatus ?? .localOnly
localTrack.remoteTrackId = track.remoteTrackId
localTrack.lastUploadError = track.lastUploadError
localTrack.updatedAt = Date()
try await trackRepository.saveLocalTrack(localTrack)
}
private func fileSize(at fileURL: URL) throws -> Int {
if let fileSize = try fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
return fileSize
}
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
if let fileSize = attributes[.size] as? NSNumber {
return fileSize.intValue
}
throw UploadPipelineError.unreadableLocalFile
}
private func durationMilliseconds(from durationSeconds: Double?) -> Int? {
guard let durationSeconds, durationSeconds > 0 else {
return nil
}
return Int((durationSeconds * 1000).rounded())
}
private func withStoredFolderAccess<T>(
_ operation: () async throws -> T
) async throws -> T {
guard let folderURL = folderAccessService.storedFolderURL() else {
return try await operation()
}
let hasScopedAccess = folderURL.startAccessingSecurityScopedResource()
defer {
if hasScopedAccess {
folderURL.stopAccessingSecurityScopedResource()
}
}
return try await operation()
}
private func refreshSelectedTrackIfNeeded() {
guard let selectedTrackID else { return }
if !tracks.contains(where: { $0.id == selectedTrackID }) {
self.selectedTrackID = nil
}
}
private static func makeTrackRepository() -> any TrackRepository {
if let repository = try? SwiftDataTrackRepository() {
return repository
}
return InMemoryTrackRepository()
}
private static func makeLocalArtworkStore() -> any LocalArtworkStore {
if let store = try? FileLocalArtworkStore() {
return store
}
return InMemoryLocalArtworkStore()
}
private static func scanStatus(
for result: LocalCatalogScanResult,
activeTrackCount: Int
) -> String {
"Scan finished. Active: \(activeTrackCount). Added: \(result.insertedTrackCount). Updated: \(result.updatedTrackCount). Reactivated: \(result.reactivatedTrackCount). Deleted: \(result.deletedTrackCount). Duplicates skipped: \(result.skippedDuplicateTrackCount)."
}
private static var currentAppVersion: String {
if let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!shortVersion.isEmpty
{
return shortVersion
}
if let buildVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String,
!buildVersion.isEmpty
{
return buildVersion
}
return "0.1.0"
}
private static var currentDeviceName: String {
let localizedName = Host.current().localizedName?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let localizedName, !localizedName.isEmpty {
return localizedName
}
return "Velody Mac"
}
private static func normalizedServerURL(from rawValue: String) -> URL? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard
!trimmed.isEmpty,
let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
url.host != nil
else {
return nil
}
return url
}
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
private static let deviceIdKey = "velody.device-id"
private static let deviceAccessTokenKey = "velody.device-access-token"
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 {
case success(remoteTrackId: String?)
case failure
}
private enum BackendConnectionError: LocalizedError {
case invalidServerURL
case missingDeviceIdentity
var errorDescription: String? {
switch self {
case .invalidServerURL:
return "Enter a valid backend URL, such as http://localhost:3007."
case .missingDeviceIdentity:
return "Register this Mac before uploading or sending a heartbeat."
}
}
}
private enum UploadPipelineError: LocalizedError {
case localFileMissing
case unreadableLocalFile
case invalidPrepareResponse
case uploadDidNotComplete(String)
case trackMissing
var errorDescription: String? {
switch self {
case .localFileMissing:
return "The local MP3 file could not be found."
case .unreadableLocalFile:
return "The local MP3 file could not be read."
case .invalidPrepareResponse:
return "The backend did not return an upload session."
case let .uploadDidNotComplete(status):
return "The backend reported upload status \(status) instead of COMPLETED."
case .trackMissing:
return "The selected track is no longer available in the local catalog."
}
}
}