From 8caf29f186c1e465c8f682a7caacf2afd95e3fd9 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sat, 30 May 2026 08:45:13 +0200 Subject: [PATCH] Implement offline library snapshot --- .../Sources/iPhoneLibraryView.swift | 72 ++- .../Sources/iPhoneLibraryViewModel.swift | 257 ++++++--- .../VelodyDomain/OfflineLibraryModels.swift | 85 +++ .../VelodySync/OfflineLibraryService.swift | 121 ++++ .../OfflineLibraryServiceTests.swift | 523 ++++++++++++++++++ 5 files changed, 958 insertions(+), 100 deletions(-) create mode 100644 packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift create mode 100644 packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift create mode 100644 packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index fbeb140..fb48491 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import VelodyDomain struct iPhoneLibraryView: View { @State private var viewModel = iPhoneLibraryViewModel() @@ -33,7 +34,7 @@ struct iPhoneLibraryView: View { } } - Section("Remote tracks: \(viewModel.remoteTracks.count)") { + Section("Remote Library: \(viewModel.remoteTracks.count)") { ForEach(viewModel.remoteTracks) { track in VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { @@ -50,7 +51,7 @@ struct iPhoneLibraryView: View { .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 4) - .background(statusColor(for: track.statusText), in: Capsule()) + .background(statusColor(for: track.status), in: Capsule()) .foregroundStyle(.white) } Text("Duration: \(track.durationText)") @@ -60,9 +61,13 @@ struct iPhoneLibraryView: View { .font(.caption) .foregroundStyle(.tertiary) .textSelection(.enabled) + Text("Asset ID: \(track.assetID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) HStack { - Button("Download") { + Button(track.downloadButtonTitle) { Task { await viewModel.downloadTrack(trackID: track.id) } @@ -79,7 +84,7 @@ struct iPhoneLibraryView: View { } if let error = track.lastDownloadError, - track.statusText == "Failed" + (track.status == .failed || track.status == .missing) { Text(error) .font(.caption) @@ -89,6 +94,47 @@ struct iPhoneLibraryView: View { .padding(.vertical, 4) } } + + Section("Available Offline: \(viewModel.availableOfflineTracks.count)") { + if viewModel.availableOfflineTracks.isEmpty { + Text("Downloaded tracks with a verified local MP3 will appear here.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.availableOfflineTracks) { track in + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.headline) + Text(track.artist) + .foregroundStyle(.secondary) + } + + Spacer() + + Button(track.playButtonTitle) { + viewModel.togglePlayback(trackID: track.id) + } + .buttonStyle(.borderedProminent) + } + + Text("Duration: \(track.durationText)") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Remote track ID: \(track.remoteTrackID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + Text("Asset ID: \(track.assetID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + .padding(.vertical, 4) + } + } + } } .overlay { overlayView @@ -158,16 +204,18 @@ struct iPhoneLibraryView: View { } } - private func statusColor(for status: String) -> Color { + private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color { switch status { - case "Downloading": - return .orange - case "Downloaded": - return .green - case "Failed": - return .red - default: + case .notDownloaded: return .gray + case .downloading: + return .blue + case .downloaded: + return .green + case .missing: + return .orange + case .failed: + return .red } } } diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index cc0fcde..81dcde3 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -175,6 +175,7 @@ final class iPhoneLibraryViewModel { } var remoteTracks: [RemoteTrackRowViewData] = [] + var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = [] var syncStatus = "Remote library not synced yet." var state: ViewState = .idle var nowPlaying = iPhoneNowPlayingState( @@ -188,10 +189,12 @@ final class iPhoneLibraryViewModel { private let environment: ServerEnvironment private let apiClient: any VelodyAPIClient private let syncService: RemoteLibrarySyncService + private let offlineLibraryService: OfflineLibraryService private let keychainService: any KeychainService private let player: any iPhoneLocalAudioPlaying - private var cachedRemoteTracks: [RemoteTrack] = [] - private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:] + private var cachedRemoteTracksByID: [String: RemoteTrack] = [:] + private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = [] + private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = [] private var hasLoaded = false init( @@ -208,17 +211,23 @@ final class iPhoneLibraryViewModel { let store = Self.makeRemoteLibraryStore() let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() let audioFileStore = Self.makeOfflineAudioFileStore() + let repository = DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: store + ) + let syncService = RemoteLibrarySyncService( + repository: repository, + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore + ) self.environment = environment self.apiClient = apiClient self.keychainService = keychainService self.player = player ?? iPhoneLocalAudioPlayer() - self.syncService = RemoteLibrarySyncService( - repository: DefaultRemoteLibraryRepository( - apiClient: apiClient, - store: store - ), - downloadStateStore: downloadStateStore, + self.syncService = syncService + self.offlineLibraryService = OfflineLibraryService( + syncService: syncService, audioFileStore: audioFileStore ) self.player.onStateChange = { [weak self] state in @@ -231,10 +240,8 @@ final class iPhoneLibraryViewModel { hasLoaded = true do { - cachedRemoteTracks = try await syncService.loadCachedRemoteTracks() - downloadStatesByTrackID = try await loadDownloadStateDictionary() - rebuildRows() - applyRestoredTracks(cachedRemoteTracks) + let snapshot = try await reloadOfflineLibrary() + applyRestoredTracks(snapshot) } catch { state = .idle syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" @@ -247,10 +254,9 @@ final class iPhoneLibraryViewModel { do { let deviceId = try await currentOrRegisterDeviceID() - cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId) - downloadStatesByTrackID = try await loadDownloadStateDictionary() - rebuildRows() - applySyncedTracks(cachedRemoteTracks) + _ = try await syncService.syncRemoteLibrary(deviceId: deviceId) + let snapshot = try await reloadOfflineLibrary() + applySyncedTracks(snapshot) } catch { state = .networkError("Remote library sync failed.") syncStatus = "Remote library sync failed: \(error.localizedDescription)" @@ -258,75 +264,73 @@ final class iPhoneLibraryViewModel { } func downloadTrack(trackID: String) async { - guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else { + guard let track = cachedRemoteTracksByID[trackID] else { return } - let currentState = downloadStatesByTrackID[trackID] - if currentState?.downloadStatus == .downloaded { - return + updateRemoteTrack(trackID: trackID) { remoteTrack in + var updatedTrack = remoteTrack + updatedTrack.status = .downloading + updatedTrack.lastDownloadError = nil + return updatedTrack } - - downloadStatesByTrackID[trackID] = RemoteTrackDownloadState( - remoteTrackId: track.trackId, - assetId: track.assetId, - localFilePath: currentState?.localFilePath ?? "", - downloadedAt: currentState?.downloadedAt, - downloadStatus: .downloading, - lastDownloadError: nil - ) rebuildRows() syncStatus = "Downloading \(track.title)..." do { let deviceId = try await currentOrRegisterDeviceID() - let downloadState = try await syncService.downloadTrack(track, deviceId: deviceId) - downloadStatesByTrackID[track.trackId] = downloadState - rebuildRows() + _ = try await syncService.downloadTrack(track, deviceId: deviceId) + _ = try await reloadOfflineLibrary() syncStatus = "Downloaded \(track.title)." } catch { - downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID - rebuildRows() + _ = try? await reloadOfflineLibrary() syncStatus = "Download failed for \(track.title): \(error.localizedDescription)" } } func togglePlayback(trackID: String) { - guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else { + guard let track = cachedRemoteTracksByID[trackID] else { return } - guard let downloadState = downloadStatesByTrackID[track.trackId], - downloadState.downloadStatus == .downloaded, - downloadState.hasLocalFile - else { - syncStatus = "Download the track before playing it offline." + if nowPlaying.trackID == track.trackId, nowPlaying.isPlaying { + player.pause() return } - let fileURL = URL(fileURLWithPath: downloadState.localFilePath) - guard FileManager.default.fileExists(atPath: fileURL.path) else { - syncStatus = "The downloaded file for \(track.title) is missing." - return - } - - do { - if nowPlaying.trackID == track.trackId { - if nowPlaying.isPlaying { - player.pause() - } else { - try player.resume() - } - } else { - try player.play( - trackID: track.trackId, - title: track.title, - artist: track.artist, - fileURL: fileURL - ) + if nowPlaying.trackID == track.trackId, !nowPlaying.isPlaying { + do { + try player.resume() + } catch { + syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)" + refreshOfflineLibraryInBackground() } + return + } + + guard let offlineTrack = cachedAvailableOfflineTracks + .first(where: { $0.remoteTrackId == track.trackId }) + else { + if cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == track.trackId })?.status == .missing { + syncStatus = "The downloaded file for \(track.title) is missing." + } else { + syncStatus = "Download the track before playing it offline." + } + refreshOfflineLibraryInBackground() + return + } + + let fileURL = URL(fileURLWithPath: offlineTrack.localFilePath) + do { + try player.play( + trackID: track.trackId, + title: track.title, + artist: track.artist, + fileURL: fileURL + ) } catch { syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)" + refreshOfflineLibraryInBackground() } } @@ -354,23 +358,23 @@ final class iPhoneLibraryViewModel { return response.deviceId } - private func applyRestoredTracks(_ tracks: [RemoteTrack]) { - if tracks.isEmpty { + private func applyRestoredTracks(_ snapshot: OfflineLibrarySnapshot) { + if snapshot.remoteTracks.isEmpty { state = .idle syncStatus = "Tap Sync Remote Library to load remote metadata." } else { state = .success - syncStatus = "Restored \(tracks.count) cached remote track(s)." + syncStatus = "Restored \(snapshot.remoteTracks.count) cached remote track(s). Offline available: \(snapshot.availableTracks.count)." } } - private func applySyncedTracks(_ tracks: [RemoteTrack]) { - if tracks.isEmpty { + private func applySyncedTracks(_ snapshot: OfflineLibrarySnapshot) { + if snapshot.remoteTracks.isEmpty { state = .empty syncStatus = "Remote library is empty." } else { state = .success - syncStatus = "Sync Remote Library completed. Remote tracks: \(tracks.count)." + syncStatus = "Sync Remote Library completed. Remote tracks: \(snapshot.remoteTracks.count). Offline available: \(snapshot.availableTracks.count)." } } @@ -398,19 +402,29 @@ final class iPhoneLibraryViewModel { return InMemoryOfflineAudioFileStore() } - private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] { - Dictionary( - uniqueKeysWithValues: try await syncService - .loadDownloadStates() - .map { ($0.remoteTrackId, $0) } + private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot { + let snapshot = try await offlineLibraryService.loadSnapshot() + + cachedRemoteLibraryTracks = snapshot.remoteTracks + cachedAvailableOfflineTracks = snapshot.availableTracks + cachedRemoteTracksByID = Dictionary( + uniqueKeysWithValues: snapshot.remoteTracks.map { ($0.remoteTrack.trackId, $0.remoteTrack) } ) + rebuildRows() + + return snapshot } private func rebuildRows() { - remoteTracks = cachedRemoteTracks.map { track in + remoteTracks = cachedRemoteLibraryTracks.map { track in RemoteTrackRowViewData( track: track, - downloadState: downloadStatesByTrackID[track.trackId], + nowPlaying: nowPlaying + ) + } + availableOfflineTracks = cachedAvailableOfflineTracks.map { track in + AvailableOfflineTrackRowViewData( + track: track, nowPlaying: nowPlaying ) } @@ -421,6 +435,29 @@ final class iPhoneLibraryViewModel { rebuildRows() } + private func updateRemoteTrack( + trackID: String, + transform: (OfflineLibraryRemoteTrack) -> OfflineLibraryRemoteTrack + ) { + guard let index = cachedRemoteLibraryTracks + .firstIndex(where: { $0.remoteTrack.trackId == trackID }) + else { + return + } + + cachedRemoteLibraryTracks[index] = transform(cachedRemoteLibraryTracks[index]) + } + + private func refreshOfflineLibraryInBackground() { + Task { @MainActor [weak self] in + guard let self else { + return + } + + _ = try? await self.reloadOfflineLibrary() + } + } + #if canImport(UIKit) private static var currentDeviceName: String { UIDevice.current.name @@ -439,39 +476,43 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { let artist: String let durationText: String let remoteTrackID: String + let assetID: String + let status: OfflineLibraryRemoteTrackStatus let statusText: String let canDownload: Bool + let downloadButtonTitle: String let canPlay: Bool let playButtonTitle: String let lastDownloadError: String? init( - track: RemoteTrack, - downloadState: RemoteTrackDownloadState?, + track: OfflineLibraryRemoteTrack, nowPlaying: iPhoneNowPlayingState ) { - id = track.trackId - title = track.title - artist = track.artist - durationText = Self.formatDuration(seconds: track.durationSeconds) - remoteTrackID = track.trackId - let status = downloadState?.downloadStatus ?? .notDownloaded - statusText = Self.statusText(for: status) - canDownload = status == .notDownloaded || status == .failed - canPlay = status == .downloaded - playButtonTitle = nowPlaying.trackID == track.trackId && nowPlaying.isPlaying + id = track.remoteTrack.trackId + title = track.remoteTrack.title + artist = track.remoteTrack.artist + durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds) + remoteTrackID = track.remoteTrack.trackId + assetID = track.remoteTrack.assetId + status = track.status + statusText = Self.statusText(for: track.status) + canDownload = track.status == .notDownloaded || track.status == .failed || track.status == .missing + downloadButtonTitle = Self.downloadButtonTitle(for: track.status) + canPlay = track.isFileAvailable + playButtonTitle = nowPlaying.trackID == track.remoteTrack.trackId && nowPlaying.isPlaying ? "Pause" : "Play" - lastDownloadError = downloadState?.lastDownloadError + lastDownloadError = track.lastDownloadError } - private static func formatDuration(seconds: Int) -> String { + static func formatDuration(seconds: Int) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" } - private static func statusText(for status: RemoteTrackDownloadStatus) -> String { + private static func statusText(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: return "Not downloaded" @@ -479,8 +520,48 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { return "Downloading" case .downloaded: return "Downloaded" + case .missing: + return "Missing" case .failed: return "Failed" } } + + private static func downloadButtonTitle(for status: OfflineLibraryRemoteTrackStatus) -> String { + switch status { + case .notDownloaded: + return "Download" + case .downloading, .downloaded: + return "Download" + case .missing: + return "Re-download" + case .failed: + return "Retry" + } + } +} + +struct AvailableOfflineTrackRowViewData: Identifiable, Equatable { + let id: String + let title: String + let artist: String + let durationText: String + let remoteTrackID: String + let assetID: String + let playButtonTitle: String + + init( + track: OfflineLibraryTrack, + nowPlaying: iPhoneNowPlayingState + ) { + id = track.remoteTrackId + title = track.title + artist = track.artist + durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds) + remoteTrackID = track.remoteTrackId + assetID = track.assetId + playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying + ? "Pause" + : "Play" + } } diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift new file mode 100644 index 0000000..e3c5703 --- /dev/null +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable { + public var id: String { remoteTrackId } + + public var remoteTrackId: String + public var assetId: String + public var title: String + public var artist: String + public var durationSeconds: Int + public var sha256: String + public var localFilePath: String + public var downloadedAt: Date? + public var isFileAvailable: Bool + + public init( + remoteTrackId: String, + assetId: String, + title: String, + artist: String, + durationSeconds: Int, + sha256: String, + localFilePath: String, + downloadedAt: Date?, + isFileAvailable: Bool + ) { + self.remoteTrackId = remoteTrackId + self.assetId = assetId + self.title = title + self.artist = artist + self.durationSeconds = durationSeconds + self.sha256 = sha256 + self.localFilePath = localFilePath + self.downloadedAt = downloadedAt + self.isFileAvailable = isFileAvailable + } +} + +public enum OfflineLibraryRemoteTrackStatus: String, Hashable, Sendable, CaseIterable { + case notDownloaded + case downloading + case downloaded + case missing + case failed +} + +public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable { + public var id: String { remoteTrack.trackId } + + public var remoteTrack: RemoteTrack + public var localFilePath: String + public var downloadedAt: Date? + public var isFileAvailable: Bool + public var status: OfflineLibraryRemoteTrackStatus + public var lastDownloadError: String? + + public init( + remoteTrack: RemoteTrack, + localFilePath: String, + downloadedAt: Date?, + isFileAvailable: Bool, + status: OfflineLibraryRemoteTrackStatus, + lastDownloadError: String? + ) { + self.remoteTrack = remoteTrack + self.localFilePath = localFilePath + self.downloadedAt = downloadedAt + self.isFileAvailable = isFileAvailable + self.status = status + self.lastDownloadError = lastDownloadError + } +} + +public struct OfflineLibrarySnapshot: Hashable, Sendable { + public var remoteTracks: [OfflineLibraryRemoteTrack] + public var availableTracks: [OfflineLibraryTrack] + + public init( + remoteTracks: [OfflineLibraryRemoteTrack], + availableTracks: [OfflineLibraryTrack] + ) { + self.remoteTracks = remoteTracks + self.availableTracks = availableTracks + } +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift b/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift new file mode 100644 index 0000000..84941d1 --- /dev/null +++ b/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift @@ -0,0 +1,121 @@ +import Foundation +import VelodyDomain +import VelodyPersistence + +public actor OfflineLibraryService { + private let syncService: RemoteLibrarySyncService + private let audioFileStore: any OfflineAudioFileStore + + public init( + syncService: RemoteLibrarySyncService, + audioFileStore: any OfflineAudioFileStore + ) { + self.syncService = syncService + self.audioFileStore = audioFileStore + } + + public func loadSnapshot() async throws -> OfflineLibrarySnapshot { + let remoteTracks = try await syncService.loadCachedRemoteTracks() + let downloadStates = try await syncService.loadDownloadStates() + let downloadStatesByTrackID = Dictionary( + uniqueKeysWithValues: downloadStates.map { ($0.remoteTrackId, $0) } + ) + + var remoteLibraryTracks: [OfflineLibraryRemoteTrack] = [] + var availableOfflineTracks: [OfflineLibraryTrack] = [] + + for remoteTrack in remoteTracks { + let downloadState = downloadStatesByTrackID[remoteTrack.trackId] + let isFileAvailable = await Self.isFileAvailable( + for: downloadState, + audioFileStore: audioFileStore + ) + let localFilePath = downloadState?.localFilePath ?? "" + let downloadedAt = downloadState?.downloadedAt + let status = Self.remoteStatus(for: downloadState, isFileAvailable: isFileAvailable) + let lastDownloadError = Self.lastDownloadError( + for: downloadState, + status: status + ) + + remoteLibraryTracks.append( + OfflineLibraryRemoteTrack( + remoteTrack: remoteTrack, + localFilePath: localFilePath, + downloadedAt: downloadedAt, + isFileAvailable: isFileAvailable, + status: status, + lastDownloadError: lastDownloadError + ) + ) + + guard isFileAvailable else { + continue + } + + availableOfflineTracks.append( + OfflineLibraryTrack( + remoteTrackId: remoteTrack.trackId, + assetId: remoteTrack.assetId, + title: remoteTrack.title, + artist: remoteTrack.artist, + durationSeconds: remoteTrack.durationSeconds, + sha256: remoteTrack.sha256, + localFilePath: localFilePath, + downloadedAt: downloadedAt, + isFileAvailable: true + ) + ) + } + + return OfflineLibrarySnapshot( + remoteTracks: remoteLibraryTracks, + availableTracks: availableOfflineTracks + ) + } + + private static func isFileAvailable( + for downloadState: RemoteTrackDownloadState?, + audioFileStore: any OfflineAudioFileStore + ) async -> Bool { + guard let downloadState, + downloadState.downloadStatus == .downloaded, + downloadState.hasLocalFile + else { + return false + } + + return await audioFileStore.fileExists(at: downloadState.localFilePath) + } + + private static func remoteStatus( + for downloadState: RemoteTrackDownloadState?, + isFileAvailable: Bool + ) -> OfflineLibraryRemoteTrackStatus { + guard let downloadState else { + return .notDownloaded + } + + switch downloadState.downloadStatus { + case .notDownloaded: + return .notDownloaded + case .downloading: + return .downloading + case .downloaded: + return isFileAvailable ? .downloaded : .missing + case .failed: + return .failed + } + } + + private static func lastDownloadError( + for downloadState: RemoteTrackDownloadState?, + status: OfflineLibraryRemoteTrackStatus + ) -> String? { + if status == .missing { + return "The downloaded MP3 file is missing." + } + + return downloadState?.lastDownloadError + } +} diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift new file mode 100644 index 0000000..877dab7 --- /dev/null +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift @@ -0,0 +1,523 @@ +import CryptoKit +import Foundation +import XCTest +import VelodyDomain +import VelodyNetworking +import VelodyPersistence +@testable import VelodySync + +final class OfflineLibraryServiceTests: XCTestCase { + func testOfflineLibraryContainsOnlyTracksWithExistingLocalFiles() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) + let availableTrack = makeRemoteTrack( + trackId: "track-available", + assetId: "asset-available", + title: "1 Mai 2026" + ) + let missingTrack = makeRemoteTrack( + trackId: "track-missing", + assetId: "asset-missing", + title: "2 Mai 2026" + ) + let availableBytes = sampleMp3Data(seed: availableTrack.assetId) + let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) + let availableLocalFilePath = try await audioFileStore.saveAudioFile( + availableBytes, + assetId: availableTrack.assetId, + sha256: availableTrack.sha256 + ) + let snapshot = try await makeOfflineLibraryService( + remoteTracks: [availableTrack, missingTrack], + downloadStates: [ + RemoteTrackDownloadState( + remoteTrackId: availableTrack.trackId, + assetId: availableTrack.assetId, + localFilePath: availableLocalFilePath, + downloadedAt: Date(timeIntervalSince1970: 1_000), + downloadStatus: .downloaded + ), + RemoteTrackDownloadState( + remoteTrackId: missingTrack.trackId, + assetId: missingTrack.assetId, + localFilePath: audioDirectory.appendingPathComponent("asset-missing.mp3").path, + downloadedAt: Date(timeIntervalSince1970: 2_000), + downloadStatus: .downloaded + ), + ], + audioFileStore: audioFileStore + ).loadSnapshot() + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + XCTAssertEqual(snapshot.availableTracks.map(\.remoteTrackId), [availableTrack.trackId]) + XCTAssertEqual( + snapshot.remoteTracks.first(where: { $0.remoteTrack.trackId == availableTrack.trackId })?.status, + .downloaded + ) + XCTAssertEqual( + snapshot.remoteTracks.first(where: { $0.remoteTrack.trackId == missingTrack.trackId })?.status, + .missing + ) + } + + func testMissingDownloadedFileBecomesMissingAndNotAvailable() async throws { + let missingTrack = makeRemoteTrack( + trackId: "track-missing", + assetId: "asset-missing", + title: "Missing Track" + ) + let missingState = RemoteTrackDownloadState( + remoteTrackId: missingTrack.trackId, + assetId: missingTrack.assetId, + localFilePath: "/tmp/missing-track.mp3", + downloadedAt: Date(timeIntervalSince1970: 1_000), + downloadStatus: .downloaded + ) + let snapshot = try await makeOfflineLibraryService( + remoteTracks: [missingTrack], + downloadStates: [missingState], + audioFileStore: InMemoryOfflineAudioFileStore() + ).loadSnapshot() + let remoteTrack = try XCTUnwrap(snapshot.remoteTracks.first) + + XCTAssertEqual(remoteTrack.status, .missing) + XCTAssertEqual(remoteTrack.lastDownloadError, "The downloaded MP3 file is missing.") + XCTAssertTrue(snapshot.availableTracks.isEmpty) + } + + func testOfflineLibraryRepairsStaleLocalPathsWhenCurrentAudioDirectoryHasFile() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json") + let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true) + let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true) + let track = makeRemoteTrack( + trackId: "track-123", + assetId: "asset-456", + title: "1 Mai 2026" + ) + let audioData = sampleMp3Data(seed: track.assetId) + let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory) + let firstLocalFilePath = try await firstAudioStore.saveAudioFile( + audioData, + assetId: track.assetId, + sha256: track.sha256 + ) + let recreatedFileURL = secondAudioDirectory.appendingPathComponent("\(track.assetId).mp3") + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL).saveDownloadStates([ + RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: firstLocalFilePath, + downloadedAt: Date(timeIntervalSince1970: 1_000), + downloadStatus: .downloaded + ), + ]) + + try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true) + try fileManager.moveItem( + at: URL(fileURLWithPath: firstLocalFilePath), + to: recreatedFileURL + ) + + let downloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL) + let repository = InMemoryRemoteLibraryRepository(tracks: [track]) + let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory) + let syncService = RemoteLibrarySyncService( + repository: repository, + downloadStateStore: downloadStateStore, + audioFileStore: relaunchedAudioStore + ) + let offlineLibraryService = OfflineLibraryService( + syncService: syncService, + audioFileStore: relaunchedAudioStore + ) + + let snapshot = try await offlineLibraryService.loadSnapshot() + let availableTrack = try XCTUnwrap(snapshot.availableTracks.first) + let persistedState = try await downloadStateStore.loadDownloadStates().first + + XCTAssertEqual(availableTrack.localFilePath, recreatedFileURL.standardizedFileURL.path) + XCTAssertEqual(persistedState?.localFilePath, recreatedFileURL.standardizedFileURL.path) + } + + func testRedownloadAfterMissingFileRestoresPlayableOfflineState() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) + let track = makeRemoteTrack( + trackId: "track-redownload", + assetId: "asset-redownload", + title: "Re-download Me" + ) + let remoteLibraryStore = InMemoryRemoteLibraryStore(tracks: [track]) + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() + let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) + let syncService = RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)), + store: remoteLibraryStore + ), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore + ) + let offlineLibraryService = OfflineLibraryService( + syncService: syncService, + audioFileStore: audioFileStore + ) + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let originalState = try await syncService.downloadTrack(track, deviceId: "device-123") + try fileManager.removeItem(at: URL(fileURLWithPath: originalState.localFilePath)) + + let missingSnapshot = try await offlineLibraryService.loadSnapshot() + XCTAssertEqual(missingSnapshot.remoteTracks.first?.status, .missing) + XCTAssertTrue(missingSnapshot.availableTracks.isEmpty) + + _ = try await syncService.downloadTrack(track, deviceId: "device-123") + + let restoredSnapshot = try await offlineLibraryService.loadSnapshot() + XCTAssertEqual(restoredSnapshot.remoteTracks.first?.status, .downloaded) + XCTAssertEqual(restoredSnapshot.availableTracks.map(\.remoteTrackId), [track.trackId]) + let restoredLocalFilePath = try XCTUnwrap(restoredSnapshot.availableTracks.first?.localFilePath) + let fileExists = await audioFileStore.fileExists(at: restoredLocalFilePath) + XCTAssertTrue(fileExists) + } + + func testMetadataSyncDoesNotEraseDownloadedOfflineAvailability() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) + let track = makeRemoteTrack( + trackId: "track-sync", + assetId: "asset-sync", + title: "Sync Safe" + ) + let remoteLibraryStore = InMemoryRemoteLibraryStore() + let audioData = sampleMp3Data(seed: track.assetId) + let apiClient = OfflineLibraryMockAPIClient( + remoteLibraryResponse: RemoteLibraryResponseDTO( + tracks: [makeRemoteTrackDTO(from: track)] + ), + audioAssetData: audioData + ) + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() + let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) + let syncService = RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: remoteLibraryStore + ), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore + ) + let offlineLibraryService = OfflineLibraryService( + syncService: syncService, + audioFileStore: audioFileStore + ) + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") + _ = try await syncService.downloadTrack(track, deviceId: "device-123") + + let beforeResync = try await offlineLibraryService.loadSnapshot() + _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") + let afterResync = try await offlineLibraryService.loadSnapshot() + + XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId]) + XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId]) + XCTAssertEqual(afterResync.remoteTracks.first?.status, .downloaded) + } + + func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json") + let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json") + let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) + let tracks = [ + makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1"), + makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2"), + ] + let apiClient = OfflineLibraryMockAPIClient( + remoteLibraryResponse: RemoteLibraryResponseDTO( + tracks: tracks.map { makeRemoteTrackDTO(from: $0) } + ), + audioAssetDataByAssetID: [ + "asset-1": sampleMp3Data(seed: "asset-1"), + "asset-2": sampleMp3Data(seed: "asset-2"), + ] + ) + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstRepository = DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL) + ) + let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL) + let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) + let firstSyncService = RemoteLibrarySyncService( + repository: firstRepository, + downloadStateStore: firstDownloadStateStore, + audioFileStore: firstAudioStore + ) + let firstOfflineLibraryService = OfflineLibraryService( + syncService: firstSyncService, + audioFileStore: firstAudioStore + ) + + _ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123") + for track in tracks { + _ = try await firstSyncService.downloadTrack(track, deviceId: "device-123") + } + + let beforeRelaunch = try await firstOfflineLibraryService.loadSnapshot() + + let relaunchedRepository = DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL) + ) + let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL) + let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) + let relaunchedSyncService = RemoteLibrarySyncService( + repository: relaunchedRepository, + downloadStateStore: relaunchedDownloadStateStore, + audioFileStore: relaunchedAudioStore + ) + let relaunchedOfflineLibraryService = OfflineLibraryService( + syncService: relaunchedSyncService, + audioFileStore: relaunchedAudioStore + ) + + let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot() + + XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId)) + XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId)) + XCTAssertEqual(afterRelaunch.remoteTracks.map(\.status), [.downloaded, .downloaded]) + } +} + +private func makeOfflineLibraryService( + remoteTracks: [RemoteTrack], + downloadStates: [RemoteTrackDownloadState], + audioFileStore: any OfflineAudioFileStore +) -> OfflineLibraryService { + let syncService = RemoteLibrarySyncService( + repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks), + downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates), + audioFileStore: audioFileStore + ) + + return OfflineLibraryService( + syncService: syncService, + audioFileStore: audioFileStore + ) +} + +private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository { + private var tracks: [RemoteTrack] + + init(tracks: [RemoteTrack]) { + self.tracks = tracks + } + + func loadCachedRemoteTracks() async throws -> [RemoteTrack] { + tracks + } + + func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { + _ = deviceId + return tracks + } + + func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data { + _ = assetId + _ = deviceId + return Data() + } +} + +private struct OfflineLibraryMockAPIClient: VelodyAPIClient { + let remoteLibraryResponse: RemoteLibraryResponseDTO? + let audioAssetData: Data? + let audioAssetDataByAssetID: [String: Data] + + init( + remoteLibraryResponse: RemoteLibraryResponseDTO? = nil, + audioAssetData: Data? = nil, + audioAssetDataByAssetID: [String: Data] = [:] + ) { + self.remoteLibraryResponse = remoteLibraryResponse + self.audioAssetData = audioAssetData + self.audioAssetDataByAssetID = audioAssetDataByAssetID + } + + func registerDevice( + _ payload: DeviceRegistrationPayload + ) async throws -> DeviceRegistrationResponse { + _ = payload + return DeviceRegistrationResponse( + deviceId: UUID().uuidString, + bootstrapToken: UUID().uuidString, + serverTime: "2026-05-30T08:00:00.000Z" + ) + } + + func sendHeartbeat( + _ payload: DeviceHeartbeatPayload + ) async throws -> DeviceHeartbeatResponse { + _ = payload + return DeviceHeartbeatResponse( + ok: true, + serverTime: "2026-05-30T08:00:00.000Z" + ) + } + + func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { + SyncBootstrapResponse( + nextCursor: SyncCursor(value: "0"), + tracks: [], + events: [], + deletedTrackIds: [], + serverTime: "2026-05-30T08:00:00.000Z" + ) + } + + func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO { + _ = deviceId + return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: []) + } + + func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data { + _ = deviceId + return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data() + } + + func prepareUpload( + _ payload: UploadPrepareRequest + ) async throws -> UploadPrepareResponse { + _ = payload + return UploadPrepareResponse(status: .uploadRequired, uploadId: UUID().uuidString, nextOffset: 0) + } + + func fetchUploadStatus( + uploadId: String + ) async throws -> UploadSessionStatusResponse { + UploadSessionStatusResponse( + uploadId: uploadId, + status: .completed, + receivedBytes: "0", + expectedSizeBytes: "0", + nextOffset: "0" + ) + } + + func uploadFile( + uploadId: String, + fileURL: URL, + mimeType: String + ) async throws -> UploadSessionStatusResponse { + _ = uploadId + _ = fileURL + _ = mimeType + return UploadSessionStatusResponse( + uploadId: UUID().uuidString, + status: .completed, + receivedBytes: "0", + expectedSizeBytes: "0", + nextOffset: "0" + ) + } + + func finalizeUpload( + uploadId: String, + payload: UploadFinalizeRequest + ) async throws -> UploadFinalizeResponse { + _ = uploadId + _ = payload + return UploadFinalizeResponse( + trackId: UUID().uuidString, + assetId: UUID().uuidString + ) + } +} + +private func makeRemoteTrack( + trackId: String, + assetId: String, + title: String +) -> RemoteTrack { + let bytes = sampleMp3Data(seed: assetId) + + return RemoteTrack( + trackId: trackId, + title: title, + artist: "Remote Artist", + durationSeconds: 245, + sha256: sha256Hex(bytes), + assetId: assetId, + createdAt: "2026-05-30T08:00:00.000Z", + updatedAt: "2026-05-30T08:05:00.000Z" + ) +} + +private func makeRemoteTrackDTO(from track: RemoteTrack) -> RemoteTrackDTO { + RemoteTrackDTO( + trackId: track.trackId, + title: track.title, + artist: track.artist, + durationSeconds: track.durationSeconds, + sha256: track.sha256, + assetId: track.assetId, + createdAt: track.createdAt, + updatedAt: track.updatedAt + ) +} + +private func sampleMp3Data(seed: String) -> Data { + Data([ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, + ] + Array(seed.utf8)) +} + +private func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +}