Implement Milestone 7.2 offline audio downloads

This commit is contained in:
diyaa
2026-05-30 07:11:20 +02:00
parent 6e73c1878e
commit 56f030e651
19 changed files with 1822 additions and 31 deletions
@@ -6,6 +6,7 @@ import VelodyPersistence
public protocol RemoteLibraryRepository: Actor {
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data
}
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
@@ -30,4 +31,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
try await store.replaceRemoteTracks(tracks)
return tracks
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
try await apiClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId)
}
}
@@ -1,18 +1,184 @@
import Foundation
import VelodyDomain
import VelodyPersistence
public actor RemoteLibrarySyncService {
private let repository: any RemoteLibraryRepository
private let downloadStateStore: any RemoteTrackDownloadStateStore
private let audioFileStore: any OfflineAudioFileStore
public init(repository: any RemoteLibraryRepository) {
public init(
repository: any RemoteLibraryRepository,
downloadStateStore: any RemoteTrackDownloadStateStore,
audioFileStore: any OfflineAudioFileStore
) {
self.repository = repository
self.downloadStateStore = downloadStateStore
self.audioFileStore = audioFileStore
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
try await repository.loadCachedRemoteTracks()
}
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
let states = try await downloadStateStore.loadDownloadStates()
return try await reconcileDownloadedLocalFilePaths(in: states)
}
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
try await repository.syncRemoteTracks(deviceId: deviceId)
let tracks = try await repository.syncRemoteTracks(deviceId: deviceId)
try await ensureDownloadStates(for: tracks)
return tracks
}
public func downloadTrack(
_ track: RemoteTrack,
deviceId: String
) async throws -> RemoteTrackDownloadState {
let currentState = try await currentDownloadState(for: track)
if currentState.downloadStatus == .downloaded,
currentState.assetId == track.assetId,
currentState.hasLocalFile,
await audioFileStore.fileExists(at: currentState.localFilePath)
{
return currentState
}
let downloadingState = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: currentState.assetId == track.assetId ? currentState.localFilePath : "",
downloadedAt: currentState.assetId == track.assetId ? currentState.downloadedAt : nil,
downloadStatus: .downloading,
lastDownloadError: nil
)
try await downloadStateStore.saveDownloadState(downloadingState)
do {
let audioData = try await repository.downloadAudioAsset(
assetId: track.assetId,
deviceId: deviceId
)
let localFilePath = try await audioFileStore.saveAudioFile(
audioData,
assetId: track.assetId,
sha256: track.sha256
)
let downloadedState = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: localFilePath,
downloadedAt: Date(),
downloadStatus: .downloaded,
lastDownloadError: nil
)
try await downloadStateStore.saveDownloadState(downloadedState)
return downloadedState
} catch {
let failedState = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: "",
downloadedAt: nil,
downloadStatus: .failed,
lastDownloadError: error.localizedDescription
)
try await downloadStateStore.saveDownloadState(failedState)
throw error
}
}
private func ensureDownloadStates(for tracks: [RemoteTrack]) async throws {
guard !tracks.isEmpty else {
return
}
var statesByTrackID = Dictionary(
uniqueKeysWithValues: try await loadDownloadStates()
.map { ($0.remoteTrackId, $0) }
)
var didChange = false
for track in tracks {
guard var existingState = statesByTrackID[track.trackId] else {
statesByTrackID[track.trackId] = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
downloadStatus: .notDownloaded
)
didChange = true
continue
}
if existingState.assetId != track.assetId {
existingState.assetId = track.assetId
existingState.localFilePath = ""
existingState.downloadedAt = nil
existingState.downloadStatus = .notDownloaded
existingState.lastDownloadError = nil
statesByTrackID[track.trackId] = existingState
didChange = true
}
}
if didChange {
try await downloadStateStore.saveDownloadStates(Array(statesByTrackID.values))
}
}
private func currentDownloadState(
for track: RemoteTrack
) async throws -> RemoteTrackDownloadState {
if let existingState = try await loadDownloadStates()
.first(where: { $0.remoteTrackId == track.trackId })
{
if existingState.assetId == track.assetId {
return existingState
}
}
return RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
downloadStatus: .notDownloaded
)
}
private func reconcileDownloadedLocalFilePaths(
in states: [RemoteTrackDownloadState]
) async throws -> [RemoteTrackDownloadState] {
guard !states.isEmpty else {
return states
}
var reconciledStates = states
var didChange = false
for index in reconciledStates.indices {
let state = reconciledStates[index]
guard state.downloadStatus == .downloaded else {
continue
}
guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
persistedLocalFilePath: state.localFilePath,
assetId: state.assetId
) else {
continue
}
if state.localFilePath != resolvedLocalFilePath {
reconciledStates[index].localFilePath = resolvedLocalFilePath
didChange = true
}
}
if didChange {
try await downloadStateStore.saveDownloadStates(reconciledStates)
}
return reconciledStates
}
}