Implement Milestone 7.2 offline audio downloads
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user