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 = [] 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 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 localMusicScanner = FileSystemLocalMusicScanner( metadataReader: AVFoundationMetadataReader() ) 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.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() 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 keychainService.save(response.deviceId, forKey: Self.deviceIdKey) try await keychainService.save(response.bootstrapToken, forKey: Self.bootstrapTokenKey) registeredDeviceId = response.deviceId deviceRegistrationStatus = "Registered successfully at \(response.serverTime)." } 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 currentDeviceId() 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 currentDeviceId() 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: 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) } 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 } let finalizeResponse = try await apiClient.finalizeUpload( uploadId: uploadId, payload: UploadFinalizeRequest( title: track.title, artist: track.artist, album: track.album, durationMs: durationMilliseconds(from: track.durationSeconds) ) ) try await setTrackUploadState( trackID: trackID, status: .uploaded, remoteTrackId: finalizeResponse.trackId, lastUploadError: nil, progress: 1 ) lastUploadStatus = "Uploaded \(track.title) 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 restoreDeviceIdentity() async { do { let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey) let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey) registeredDeviceId = deviceId if let deviceId { if let bootstrapToken, !bootstrapToken.isEmpty { deviceRegistrationStatus = "Registered locally." } 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 currentDeviceId() async throws -> String { if let registeredDeviceId, !registeredDeviceId.isEmpty { return registeredDeviceId } if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey), !savedDeviceId.isEmpty { registeredDeviceId = savedDeviceId return savedDeviceId } throw BackendConnectionError.missingDeviceIdentity } private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient { URLSessionVelodyAPIClient(environment: environment) } 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( _ 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 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 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." } } }