899 lines
30 KiB
Swift
899 lines
30 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 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:
|
|
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 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<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 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."
|
|
}
|
|
}
|
|
}
|