295 lines
9.9 KiB
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."
|
|
}
|
|
}
|
|
}
|