velody/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift

295 lines
9.9 KiB
Swift

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 localMusicScanner: any LocalMusicScanner
private let keychainService: any KeychainService
private let userDefaults: UserDefaults
private let store = InMemoryLocalLibraryStore()
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()
)
self.folderAccessService = folderAccessService
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
tracks = await store.loadTracks()
discoveredTrackCount = tracks.count
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 {
await store.replaceTracks([])
}
}
}
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)
await store.replaceTracks(discoveredTracks)
tracks = await store.loadTracks()
discoveredTrackCount = tracks.count
scanStatus = "Scan finished. Found \(discoveredTrackCount) MP3 file(s)."
} 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 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."
}
}
}