import Foundation import Observation import VelodyDomain import VelodyNetworking import VelodyPersistence import VelodyUtilities @MainActor @Observable final class MacLibraryViewModel { var tracks: [LibraryTrack] = [] var selectedFolderPath = "No folder selected" var scanStatus = "Choose a folder to begin local discovery." var discoveredTrackCount = 0 var isScanning = false var serverURLString: String var deviceRegistrationStatus = "Not registered." var registeredDeviceId: String? var lastHeartbeatStatus = "No heartbeat sent yet." var lastSyncBootstrapStatus = "No sync bootstrap run yet." var lastBootstrapTrackCount: Int? var lastBootstrapCursor: String? var isRegisteringDevice = false var isSendingHeartbeat = false var isRunningSyncBootstrap = false private let folderAccessService: any VelodyPersistence.FolderAccessService private let catalogService: any LocalCatalogService private let localMusicScanner: any LocalMusicScanner private let keychainService: any KeychainService private let userDefaults: UserDefaults private var hasLoaded = false init( userDefaults: UserDefaults = .standard, keychainService: any KeychainService = SystemKeychainService(service: "de.diyaa.velody.mac") ) { let folderAccessService = FolderAccessService() let localMusicScanner = FileSystemLocalMusicScanner( metadataReader: AVFoundationMetadataReader() ) let repository = Self.makeTrackRepository() self.folderAccessService = folderAccessService self.catalogService = DefaultLocalCatalogService(repository: repository) self.localMusicScanner = localMusicScanner self.keychainService = keychainService self.userDefaults = userDefaults self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey) ?? ServerEnvironment.defaultLocalBaseURL.absoluteString if let url = folderAccessService.storedFolderURL() { selectedFolderPath = url.path scanStatus = "Folder restored. Run a manual scan to discover MP3 files." } } 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)" } 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 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 scanStatus = Self.scanStatus( for: scanResult, activeTrackCount: discoveredTrackCount ) } catch { scanStatus = "Scan failed: \(error.localizedDescription)" } } 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 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 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 enum BackendConnectionError: LocalizedError { case invalidServerURL case missingDeviceIdentity var errorDescription: String? { switch self { case .invalidServerURL: return "Enter a valid backend URL, such as http://localhost:3000." case .missingDeviceIdentity: return "Register this Mac before sending a heartbeat." } } }