Implement offline library snapshot
This commit is contained in:
parent
56f030e651
commit
8caf29f186
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user