Implement offline library snapshot

This commit is contained in:
diyaa 2026-05-30 08:45:13 +02:00
parent 56f030e651
commit 8caf29f186
5 changed files with 958 additions and 100 deletions

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import VelodyDomain
struct iPhoneLibraryView: View { struct iPhoneLibraryView: View {
@State private var viewModel = iPhoneLibraryViewModel() @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 ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) { HStack(alignment: .top) {
@ -50,7 +51,7 @@ struct iPhoneLibraryView: View {
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(statusColor(for: track.statusText), in: Capsule()) .background(statusColor(for: track.status), in: Capsule())
.foregroundStyle(.white) .foregroundStyle(.white)
} }
Text("Duration: \(track.durationText)") Text("Duration: \(track.durationText)")
@ -60,9 +61,13 @@ struct iPhoneLibraryView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.textSelection(.enabled) .textSelection(.enabled)
Text("Asset ID: \(track.assetID)")
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
HStack { HStack {
Button("Download") { Button(track.downloadButtonTitle) {
Task { Task {
await viewModel.downloadTrack(trackID: track.id) await viewModel.downloadTrack(trackID: track.id)
} }
@ -79,7 +84,7 @@ struct iPhoneLibraryView: View {
} }
if let error = track.lastDownloadError, if let error = track.lastDownloadError,
track.statusText == "Failed" (track.status == .failed || track.status == .missing)
{ {
Text(error) Text(error)
.font(.caption) .font(.caption)
@ -89,6 +94,47 @@ struct iPhoneLibraryView: View {
.padding(.vertical, 4) .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 { .overlay {
overlayView overlayView
@ -158,16 +204,18 @@ struct iPhoneLibraryView: View {
} }
} }
private func statusColor(for status: String) -> Color { private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color {
switch status { switch status {
case "Downloading": case .notDownloaded:
return .orange
case "Downloaded":
return .green
case "Failed":
return .red
default:
return .gray return .gray
case .downloading:
return .blue
case .downloaded:
return .green
case .missing:
return .orange
case .failed:
return .red
} }
} }
} }

View File

@ -175,6 +175,7 @@ final class iPhoneLibraryViewModel {
} }
var remoteTracks: [RemoteTrackRowViewData] = [] var remoteTracks: [RemoteTrackRowViewData] = []
var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = []
var syncStatus = "Remote library not synced yet." var syncStatus = "Remote library not synced yet."
var state: ViewState = .idle var state: ViewState = .idle
var nowPlaying = iPhoneNowPlayingState( var nowPlaying = iPhoneNowPlayingState(
@ -188,10 +189,12 @@ final class iPhoneLibraryViewModel {
private let environment: ServerEnvironment private let environment: ServerEnvironment
private let apiClient: any VelodyAPIClient private let apiClient: any VelodyAPIClient
private let syncService: RemoteLibrarySyncService private let syncService: RemoteLibrarySyncService
private let offlineLibraryService: OfflineLibraryService
private let keychainService: any KeychainService private let keychainService: any KeychainService
private let player: any iPhoneLocalAudioPlaying private let player: any iPhoneLocalAudioPlaying
private var cachedRemoteTracks: [RemoteTrack] = [] private var cachedRemoteTracksByID: [String: RemoteTrack] = [:]
private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:] private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = []
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
private var hasLoaded = false private var hasLoaded = false
init( init(
@ -208,17 +211,23 @@ final class iPhoneLibraryViewModel {
let store = Self.makeRemoteLibraryStore() let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore() let audioFileStore = Self.makeOfflineAudioFileStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
)
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
self.environment = environment self.environment = environment
self.apiClient = apiClient self.apiClient = apiClient
self.keychainService = keychainService self.keychainService = keychainService
self.player = player ?? iPhoneLocalAudioPlayer() self.player = player ?? iPhoneLocalAudioPlayer()
self.syncService = RemoteLibrarySyncService( self.syncService = syncService
repository: DefaultRemoteLibraryRepository( self.offlineLibraryService = OfflineLibraryService(
apiClient: apiClient, syncService: syncService,
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore audioFileStore: audioFileStore
) )
self.player.onStateChange = { [weak self] state in self.player.onStateChange = { [weak self] state in
@ -231,10 +240,8 @@ final class iPhoneLibraryViewModel {
hasLoaded = true hasLoaded = true
do { do {
cachedRemoteTracks = try await syncService.loadCachedRemoteTracks() let snapshot = try await reloadOfflineLibrary()
downloadStatesByTrackID = try await loadDownloadStateDictionary() applyRestoredTracks(snapshot)
rebuildRows()
applyRestoredTracks(cachedRemoteTracks)
} catch { } catch {
state = .idle state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
@ -247,10 +254,9 @@ final class iPhoneLibraryViewModel {
do { do {
let deviceId = try await currentOrRegisterDeviceID() let deviceId = try await currentOrRegisterDeviceID()
cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId) _ = try await syncService.syncRemoteLibrary(deviceId: deviceId)
downloadStatesByTrackID = try await loadDownloadStateDictionary() let snapshot = try await reloadOfflineLibrary()
rebuildRows() applySyncedTracks(snapshot)
applySyncedTracks(cachedRemoteTracks)
} catch { } catch {
state = .networkError("Remote library sync failed.") state = .networkError("Remote library sync failed.")
syncStatus = "Remote library sync failed: \(error.localizedDescription)" syncStatus = "Remote library sync failed: \(error.localizedDescription)"
@ -258,75 +264,73 @@ final class iPhoneLibraryViewModel {
} }
func downloadTrack(trackID: String) async { func downloadTrack(trackID: String) async {
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else { guard let track = cachedRemoteTracksByID[trackID] else {
return return
} }
let currentState = downloadStatesByTrackID[trackID] updateRemoteTrack(trackID: trackID) { remoteTrack in
if currentState?.downloadStatus == .downloaded { var updatedTrack = remoteTrack
return 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() rebuildRows()
syncStatus = "Downloading \(track.title)..." syncStatus = "Downloading \(track.title)..."
do { do {
let deviceId = try await currentOrRegisterDeviceID() let deviceId = try await currentOrRegisterDeviceID()
let downloadState = try await syncService.downloadTrack(track, deviceId: deviceId) _ = try await syncService.downloadTrack(track, deviceId: deviceId)
downloadStatesByTrackID[track.trackId] = downloadState _ = try await reloadOfflineLibrary()
rebuildRows()
syncStatus = "Downloaded \(track.title)." syncStatus = "Downloaded \(track.title)."
} catch { } catch {
downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID _ = try? await reloadOfflineLibrary()
rebuildRows()
syncStatus = "Download failed for \(track.title): \(error.localizedDescription)" syncStatus = "Download failed for \(track.title): \(error.localizedDescription)"
} }
} }
func togglePlayback(trackID: String) { func togglePlayback(trackID: String) {
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else { guard let track = cachedRemoteTracksByID[trackID] else {
return return
} }
guard let downloadState = downloadStatesByTrackID[track.trackId], if nowPlaying.trackID == track.trackId, nowPlaying.isPlaying {
downloadState.downloadStatus == .downloaded, player.pause()
downloadState.hasLocalFile
else {
syncStatus = "Download the track before playing it offline."
return return
} }
let fileURL = URL(fileURLWithPath: downloadState.localFilePath) if nowPlaying.trackID == track.trackId, !nowPlaying.isPlaying {
guard FileManager.default.fileExists(atPath: fileURL.path) else { do {
syncStatus = "The downloaded file for \(track.title) is missing." try player.resume()
return } catch {
} syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
refreshOfflineLibraryInBackground()
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
)
} }
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 { } catch {
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)" syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
refreshOfflineLibraryInBackground()
} }
} }
@ -354,23 +358,23 @@ final class iPhoneLibraryViewModel {
return response.deviceId return response.deviceId
} }
private func applyRestoredTracks(_ tracks: [RemoteTrack]) { private func applyRestoredTracks(_ snapshot: OfflineLibrarySnapshot) {
if tracks.isEmpty { if snapshot.remoteTracks.isEmpty {
state = .idle state = .idle
syncStatus = "Tap Sync Remote Library to load remote metadata." syncStatus = "Tap Sync Remote Library to load remote metadata."
} else { } else {
state = .success 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]) { private func applySyncedTracks(_ snapshot: OfflineLibrarySnapshot) {
if tracks.isEmpty { if snapshot.remoteTracks.isEmpty {
state = .empty state = .empty
syncStatus = "Remote library is empty." syncStatus = "Remote library is empty."
} else { } else {
state = .success 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() return InMemoryOfflineAudioFileStore()
} }
private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] { private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
Dictionary( let snapshot = try await offlineLibraryService.loadSnapshot()
uniqueKeysWithValues: try await syncService
.loadDownloadStates() cachedRemoteLibraryTracks = snapshot.remoteTracks
.map { ($0.remoteTrackId, $0) } cachedAvailableOfflineTracks = snapshot.availableTracks
cachedRemoteTracksByID = Dictionary(
uniqueKeysWithValues: snapshot.remoteTracks.map { ($0.remoteTrack.trackId, $0.remoteTrack) }
) )
rebuildRows()
return snapshot
} }
private func rebuildRows() { private func rebuildRows() {
remoteTracks = cachedRemoteTracks.map { track in remoteTracks = cachedRemoteLibraryTracks.map { track in
RemoteTrackRowViewData( RemoteTrackRowViewData(
track: track, track: track,
downloadState: downloadStatesByTrackID[track.trackId], nowPlaying: nowPlaying
)
}
availableOfflineTracks = cachedAvailableOfflineTracks.map { track in
AvailableOfflineTrackRowViewData(
track: track,
nowPlaying: nowPlaying nowPlaying: nowPlaying
) )
} }
@ -421,6 +435,29 @@ final class iPhoneLibraryViewModel {
rebuildRows() 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) #if canImport(UIKit)
private static var currentDeviceName: String { private static var currentDeviceName: String {
UIDevice.current.name UIDevice.current.name
@ -439,39 +476,43 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let artist: String let artist: String
let durationText: String let durationText: String
let remoteTrackID: String let remoteTrackID: String
let assetID: String
let status: OfflineLibraryRemoteTrackStatus
let statusText: String let statusText: String
let canDownload: Bool let canDownload: Bool
let downloadButtonTitle: String
let canPlay: Bool let canPlay: Bool
let playButtonTitle: String let playButtonTitle: String
let lastDownloadError: String? let lastDownloadError: String?
init( init(
track: RemoteTrack, track: OfflineLibraryRemoteTrack,
downloadState: RemoteTrackDownloadState?,
nowPlaying: iPhoneNowPlayingState nowPlaying: iPhoneNowPlayingState
) { ) {
id = track.trackId id = track.remoteTrack.trackId
title = track.title title = track.remoteTrack.title
artist = track.artist artist = track.remoteTrack.artist
durationText = Self.formatDuration(seconds: track.durationSeconds) durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
remoteTrackID = track.trackId remoteTrackID = track.remoteTrack.trackId
let status = downloadState?.downloadStatus ?? .notDownloaded assetID = track.remoteTrack.assetId
statusText = Self.statusText(for: status) status = track.status
canDownload = status == .notDownloaded || status == .failed statusText = Self.statusText(for: track.status)
canPlay = status == .downloaded canDownload = track.status == .notDownloaded || track.status == .failed || track.status == .missing
playButtonTitle = nowPlaying.trackID == track.trackId && nowPlaying.isPlaying downloadButtonTitle = Self.downloadButtonTitle(for: track.status)
canPlay = track.isFileAvailable
playButtonTitle = nowPlaying.trackID == track.remoteTrack.trackId && nowPlaying.isPlaying
? "Pause" ? "Pause"
: "Play" : "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 minutes = seconds / 60
let remainingSeconds = seconds % 60 let remainingSeconds = seconds % 60
return "\(minutes):\(String(format: "%02d", remainingSeconds))" return "\(minutes):\(String(format: "%02d", remainingSeconds))"
} }
private static func statusText(for status: RemoteTrackDownloadStatus) -> String { private static func statusText(for status: OfflineLibraryRemoteTrackStatus) -> String {
switch status { switch status {
case .notDownloaded: case .notDownloaded:
return "Not downloaded" return "Not downloaded"
@ -479,8 +520,48 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
return "Downloading" return "Downloading"
case .downloaded: case .downloaded:
return "Downloaded" return "Downloaded"
case .missing:
return "Missing"
case .failed: case .failed:
return "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"
}
} }

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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()
}