Implement Milestone 7.2 offline audio downloads
This commit is contained in:
parent
6e73c1878e
commit
56f030e651
@ -6,13 +6,53 @@ struct iPhoneLibraryView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
|
if let currentTitle = viewModel.nowPlaying.title {
|
||||||
|
Section("Now Playing") {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(currentTitle)
|
||||||
|
.font(.headline)
|
||||||
|
if let artist = viewModel.nowPlaying.artist {
|
||||||
|
Text(artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(viewModel.nowPlaying.isPlaying ? "Playing offline" : "Paused")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let trackID = viewModel.nowPlaying.trackID {
|
||||||
|
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
|
||||||
|
viewModel.togglePlayback(trackID: trackID)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Remote tracks: \(viewModel.remoteTracks.count)") {
|
Section("Remote tracks: \(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) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(track.title)
|
Text(track.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(track.artist)
|
Text(track.artist)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(track.statusText)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(statusColor(for: track.statusText), in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
Text("Duration: \(track.durationText)")
|
Text("Duration: \(track.durationText)")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -20,6 +60,31 @@ struct iPhoneLibraryView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Download") {
|
||||||
|
Task {
|
||||||
|
await viewModel.downloadTrack(trackID: track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(!track.canDownload)
|
||||||
|
|
||||||
|
if track.canPlay {
|
||||||
|
Button(track.playButtonTitle) {
|
||||||
|
viewModel.togglePlayback(trackID: track.id)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = track.lastDownloadError,
|
||||||
|
track.statusText == "Failed"
|
||||||
|
{
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
@ -40,9 +105,17 @@ struct iPhoneLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let playbackError = viewModel.nowPlaying.errorMessage {
|
||||||
|
Text(playbackError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
Text(viewModel.syncStatus)
|
Text(viewModel.syncStatus)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
@ -84,4 +157,17 @@ struct iPhoneLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func statusColor(for status: String) -> Color {
|
||||||
|
switch status {
|
||||||
|
case "Downloading":
|
||||||
|
return .orange
|
||||||
|
case "Downloaded":
|
||||||
|
return .green
|
||||||
|
case "Failed":
|
||||||
|
return .red
|
||||||
|
default:
|
||||||
|
return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,163 @@ import VelodyPersistence
|
|||||||
import VelodySync
|
import VelodySync
|
||||||
import VelodyUtilities
|
import VelodyUtilities
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
|
import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol iPhoneLocalAudioPlaying: AnyObject {
|
||||||
|
var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set }
|
||||||
|
var state: iPhoneNowPlayingState { get }
|
||||||
|
|
||||||
|
func play(
|
||||||
|
trackID: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
fileURL: URL
|
||||||
|
) throws
|
||||||
|
func resume() throws
|
||||||
|
func pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct iPhoneNowPlayingState: Equatable {
|
||||||
|
var trackID: String?
|
||||||
|
var title: String?
|
||||||
|
var artist: String?
|
||||||
|
var isPlaying: Bool
|
||||||
|
var errorMessage: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class iPhoneLocalAudioPlayer: NSObject, iPhoneLocalAudioPlaying, AVAudioPlayerDelegate {
|
||||||
|
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
|
||||||
|
private(set) var state = iPhoneNowPlayingState(
|
||||||
|
trackID: nil,
|
||||||
|
title: nil,
|
||||||
|
artist: nil,
|
||||||
|
isPlaying: false,
|
||||||
|
errorMessage: nil
|
||||||
|
) {
|
||||||
|
didSet {
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audioPlayer: AVAudioPlayer?
|
||||||
|
|
||||||
|
func play(
|
||||||
|
trackID: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
fileURL: URL
|
||||||
|
) throws {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
state = iPhoneNowPlayingState(
|
||||||
|
trackID: trackID,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
isPlaying: false,
|
||||||
|
errorMessage: "The downloaded file could not be found."
|
||||||
|
)
|
||||||
|
throw NSError(
|
||||||
|
domain: "VelodyiPhonePlayback",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try configureAudioSession()
|
||||||
|
audioPlayer?.stop()
|
||||||
|
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
||||||
|
audioPlayer.delegate = self
|
||||||
|
audioPlayer.prepareToPlay()
|
||||||
|
self.audioPlayer = audioPlayer
|
||||||
|
|
||||||
|
guard audioPlayer.play() else {
|
||||||
|
state = iPhoneNowPlayingState(
|
||||||
|
trackID: trackID,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
isPlaying: false,
|
||||||
|
errorMessage: "Playback could not be started."
|
||||||
|
)
|
||||||
|
throw NSError(
|
||||||
|
domain: "VelodyiPhonePlayback",
|
||||||
|
code: 2,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Playback could not be started."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = iPhoneNowPlayingState(
|
||||||
|
trackID: trackID,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
isPlaying: true,
|
||||||
|
errorMessage: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
if state.trackID == nil {
|
||||||
|
state = iPhoneNowPlayingState(
|
||||||
|
trackID: trackID,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
isPlaying: false,
|
||||||
|
errorMessage: "The downloaded audio file could not be opened."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() throws {
|
||||||
|
guard let audioPlayer else {
|
||||||
|
state.errorMessage = "No downloaded track is loaded."
|
||||||
|
throw NSError(
|
||||||
|
domain: "VelodyiPhonePlayback",
|
||||||
|
code: 3,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No downloaded track is loaded."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard audioPlayer.play() else {
|
||||||
|
state.errorMessage = "Playback could not be resumed."
|
||||||
|
throw NSError(
|
||||||
|
domain: "VelodyiPhonePlayback",
|
||||||
|
code: 4,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Playback could not be resumed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isPlaying = true
|
||||||
|
state.errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
state.isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func audioPlayerDidFinishPlaying(
|
||||||
|
_ player: AVAudioPlayer,
|
||||||
|
successfully flag: Bool
|
||||||
|
) {
|
||||||
|
guard flag else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.state.isPlaying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAudioSession() throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playback, mode: .default)
|
||||||
|
try session.setActive(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class iPhoneLibraryViewModel {
|
final class iPhoneLibraryViewModel {
|
||||||
@ -23,14 +177,25 @@ final class iPhoneLibraryViewModel {
|
|||||||
var remoteTracks: [RemoteTrackRowViewData] = []
|
var remoteTracks: [RemoteTrackRowViewData] = []
|
||||||
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(
|
||||||
|
trackID: nil,
|
||||||
|
title: nil,
|
||||||
|
artist: nil,
|
||||||
|
isPlaying: false,
|
||||||
|
errorMessage: nil
|
||||||
|
)
|
||||||
|
|
||||||
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 keychainService: any KeychainService
|
private let keychainService: any KeychainService
|
||||||
|
private let player: any iPhoneLocalAudioPlaying
|
||||||
|
private var cachedRemoteTracks: [RemoteTrack] = []
|
||||||
|
private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:]
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
player: (any iPhoneLocalAudioPlaying)? = nil,
|
||||||
keychainService: any KeychainService = SystemKeychainService(
|
keychainService: any KeychainService = SystemKeychainService(
|
||||||
service: "de.diyaa.velody.iphone"
|
service: "de.diyaa.velody.iphone"
|
||||||
)
|
)
|
||||||
@ -41,16 +206,24 @@ final class iPhoneLibraryViewModel {
|
|||||||
)
|
)
|
||||||
let apiClient = URLSessionVelodyAPIClient(environment: environment)
|
let apiClient = URLSessionVelodyAPIClient(environment: environment)
|
||||||
let store = Self.makeRemoteLibraryStore()
|
let store = Self.makeRemoteLibraryStore()
|
||||||
|
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||||
|
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||||
|
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.keychainService = keychainService
|
self.keychainService = keychainService
|
||||||
|
self.player = player ?? iPhoneLocalAudioPlayer()
|
||||||
self.syncService = RemoteLibrarySyncService(
|
self.syncService = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: store
|
store: store
|
||||||
|
),
|
||||||
|
downloadStateStore: downloadStateStore,
|
||||||
|
audioFileStore: audioFileStore
|
||||||
)
|
)
|
||||||
)
|
self.player.onStateChange = { [weak self] state in
|
||||||
|
self?.handleNowPlayingStateChange(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadIfNeeded() async {
|
func loadIfNeeded() async {
|
||||||
@ -58,8 +231,10 @@ final class iPhoneLibraryViewModel {
|
|||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let persistedTracks = try await syncService.loadCachedRemoteTracks()
|
cachedRemoteTracks = try await syncService.loadCachedRemoteTracks()
|
||||||
applyRestoredTracks(persistedTracks)
|
downloadStatesByTrackID = try await loadDownloadStateDictionary()
|
||||||
|
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)"
|
||||||
@ -72,14 +247,89 @@ final class iPhoneLibraryViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let deviceId = try await currentOrRegisterDeviceID()
|
let deviceId = try await currentOrRegisterDeviceID()
|
||||||
let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
|
cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
|
||||||
applySyncedTracks(tracks)
|
downloadStatesByTrackID = try await loadDownloadStateDictionary()
|
||||||
|
rebuildRows()
|
||||||
|
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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadTrack(trackID: String) async {
|
||||||
|
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentState = downloadStatesByTrackID[trackID]
|
||||||
|
if currentState?.downloadStatus == .downloaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadStatesByTrackID[trackID] = RemoteTrackDownloadState(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
assetId: track.assetId,
|
||||||
|
localFilePath: currentState?.localFilePath ?? "",
|
||||||
|
downloadedAt: currentState?.downloadedAt,
|
||||||
|
downloadStatus: .downloading,
|
||||||
|
lastDownloadError: nil
|
||||||
|
)
|
||||||
|
rebuildRows()
|
||||||
|
syncStatus = "Downloading \(track.title)..."
|
||||||
|
|
||||||
|
do {
|
||||||
|
let deviceId = try await currentOrRegisterDeviceID()
|
||||||
|
let downloadState = try await syncService.downloadTrack(track, deviceId: deviceId)
|
||||||
|
downloadStatesByTrackID[track.trackId] = downloadState
|
||||||
|
rebuildRows()
|
||||||
|
syncStatus = "Downloaded \(track.title)."
|
||||||
|
} catch {
|
||||||
|
downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID
|
||||||
|
rebuildRows()
|
||||||
|
syncStatus = "Download failed for \(track.title): \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayback(trackID: String) {
|
||||||
|
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let downloadState = downloadStatesByTrackID[track.trackId],
|
||||||
|
downloadState.downloadStatus == .downloaded,
|
||||||
|
downloadState.hasLocalFile
|
||||||
|
else {
|
||||||
|
syncStatus = "Download the track before playing it offline."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileURL = URL(fileURLWithPath: downloadState.localFilePath)
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
syncStatus = "The downloaded file for \(track.title) is missing."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if nowPlaying.trackID == track.trackId {
|
||||||
|
if nowPlaying.isPlaying {
|
||||||
|
player.pause()
|
||||||
|
} else {
|
||||||
|
try player.resume()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try player.play(
|
||||||
|
trackID: track.trackId,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
fileURL: fileURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func currentOrRegisterDeviceID() async throws -> String {
|
private func currentOrRegisterDeviceID() async throws -> String {
|
||||||
if let existingDeviceID = try await keychainService.loadValue(
|
if let existingDeviceID = try await keychainService.loadValue(
|
||||||
forKey: Self.deviceIDKey
|
forKey: Self.deviceIDKey
|
||||||
@ -105,8 +355,6 @@ final class iPhoneLibraryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyRestoredTracks(_ tracks: [RemoteTrack]) {
|
private func applyRestoredTracks(_ tracks: [RemoteTrack]) {
|
||||||
remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:))
|
|
||||||
|
|
||||||
if tracks.isEmpty {
|
if tracks.isEmpty {
|
||||||
state = .idle
|
state = .idle
|
||||||
syncStatus = "Tap Sync Remote Library to load remote metadata."
|
syncStatus = "Tap Sync Remote Library to load remote metadata."
|
||||||
@ -117,8 +365,6 @@ final class iPhoneLibraryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applySyncedTracks(_ tracks: [RemoteTrack]) {
|
private func applySyncedTracks(_ tracks: [RemoteTrack]) {
|
||||||
remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:))
|
|
||||||
|
|
||||||
if tracks.isEmpty {
|
if tracks.isEmpty {
|
||||||
state = .empty
|
state = .empty
|
||||||
syncStatus = "Remote library is empty."
|
syncStatus = "Remote library is empty."
|
||||||
@ -136,6 +382,45 @@ final class iPhoneLibraryViewModel {
|
|||||||
return InMemoryRemoteLibraryStore()
|
return InMemoryRemoteLibraryStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeRemoteTrackDownloadStateStore() -> any RemoteTrackDownloadStateStore {
|
||||||
|
if let store = try? FileRemoteTrackDownloadStateStore() {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
return InMemoryRemoteTrackDownloadStateStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
|
||||||
|
if let store = try? FileOfflineAudioFileStore() {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
return InMemoryOfflineAudioFileStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] {
|
||||||
|
Dictionary(
|
||||||
|
uniqueKeysWithValues: try await syncService
|
||||||
|
.loadDownloadStates()
|
||||||
|
.map { ($0.remoteTrackId, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildRows() {
|
||||||
|
remoteTracks = cachedRemoteTracks.map { track in
|
||||||
|
RemoteTrackRowViewData(
|
||||||
|
track: track,
|
||||||
|
downloadState: downloadStatesByTrackID[track.trackId],
|
||||||
|
nowPlaying: nowPlaying
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
|
||||||
|
nowPlaying = state
|
||||||
|
rebuildRows()
|
||||||
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
private static var currentDeviceName: String {
|
private static var currentDeviceName: String {
|
||||||
UIDevice.current.name
|
UIDevice.current.name
|
||||||
@ -154,13 +439,30 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|||||||
let artist: String
|
let artist: String
|
||||||
let durationText: String
|
let durationText: String
|
||||||
let remoteTrackID: String
|
let remoteTrackID: String
|
||||||
|
let statusText: String
|
||||||
|
let canDownload: Bool
|
||||||
|
let canPlay: Bool
|
||||||
|
let playButtonTitle: String
|
||||||
|
let lastDownloadError: String?
|
||||||
|
|
||||||
init(track: RemoteTrack) {
|
init(
|
||||||
|
track: RemoteTrack,
|
||||||
|
downloadState: RemoteTrackDownloadState?,
|
||||||
|
nowPlaying: iPhoneNowPlayingState
|
||||||
|
) {
|
||||||
id = track.trackId
|
id = track.trackId
|
||||||
title = track.title
|
title = track.title
|
||||||
artist = track.artist
|
artist = track.artist
|
||||||
durationText = Self.formatDuration(seconds: track.durationSeconds)
|
durationText = Self.formatDuration(seconds: track.durationSeconds)
|
||||||
remoteTrackID = track.trackId
|
remoteTrackID = track.trackId
|
||||||
|
let status = downloadState?.downloadStatus ?? .notDownloaded
|
||||||
|
statusText = Self.statusText(for: status)
|
||||||
|
canDownload = status == .notDownloaded || status == .failed
|
||||||
|
canPlay = status == .downloaded
|
||||||
|
playButtonTitle = nowPlaying.trackID == track.trackId && nowPlaying.isPlaying
|
||||||
|
? "Pause"
|
||||||
|
: "Play"
|
||||||
|
lastDownloadError = downloadState?.lastDownloadError
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func formatDuration(seconds: Int) -> String {
|
private static func formatDuration(seconds: Int) -> String {
|
||||||
@ -168,4 +470,17 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|||||||
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 {
|
||||||
|
switch status {
|
||||||
|
case .notDownloaded:
|
||||||
|
return "Not downloaded"
|
||||||
|
case .downloading:
|
||||||
|
return "Downloading"
|
||||||
|
case .downloaded:
|
||||||
|
return "Downloaded"
|
||||||
|
case .failed:
|
||||||
|
return "Failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,46 @@
|
|||||||
{
|
{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/assets/{assetId}/download": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "AssetsController_download_v1",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "assetId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deviceId",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"audio/mpeg": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"assets"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/health": {
|
"/api/v1/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "HealthController_getHealth_v1",
|
"operationId": "HealthController_getHealth_v1",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsModule } from './modules/assets/assets.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DevicesModule } from './modules/devices/devices.module';
|
import { DevicesModule } from './modules/devices/devices.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
@ -9,6 +10,7 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
AssetsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
DevicesModule,
|
DevicesModule,
|
||||||
UploadsModule,
|
UploadsModule,
|
||||||
|
|||||||
39
backend/src/modules/assets/assets.controller.ts
Normal file
39
backend/src/modules/assets/assets.controller.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Controller, Get, Param, Query, Res, StreamableFile } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { AssetDownloadQueryDto } from './assets.dto';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@ApiTags('assets')
|
||||||
|
@Controller({
|
||||||
|
path: 'assets',
|
||||||
|
version: '1',
|
||||||
|
})
|
||||||
|
export class AssetsController {
|
||||||
|
constructor(private readonly assetsService: AssetsService) {}
|
||||||
|
|
||||||
|
@Get(':assetId/download')
|
||||||
|
@ApiProduces('audio/mpeg')
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async download(
|
||||||
|
@Param('assetId') assetId: string,
|
||||||
|
@Query() query: AssetDownloadQueryDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const download = await this.assetsService.getOwnedAudioAssetDownload(
|
||||||
|
assetId,
|
||||||
|
query.deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.setHeader('Content-Type', 'audio/mpeg');
|
||||||
|
response.setHeader('Content-Length', String(download.contentLength));
|
||||||
|
|
||||||
|
return new StreamableFile(createReadStream(download.filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/modules/assets/assets.dto.ts
Normal file
8
backend/src/modules/assets/assets.dto.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class AssetDownloadQueryDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
12
backend/src/modules/assets/assets.module.ts
Normal file
12
backend/src/modules/assets/assets.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { AssetsController } from './assets.controller';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, StorageModule],
|
||||||
|
controllers: [AssetsController],
|
||||||
|
providers: [AssetsService],
|
||||||
|
})
|
||||||
|
export class AssetsModule {}
|
||||||
126
backend/src/modules/assets/assets.service.spec.ts
Normal file
126
backend/src/modules/assets/assets.service.spec.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
|
|
||||||
|
function createPrismaMock() {
|
||||||
|
const devices = new Map<string, any>();
|
||||||
|
const audioAssets = new Map<string, any>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
prismaMock: {
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return devices.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
audioAsset: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return audioAssets.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
state: {
|
||||||
|
devices,
|
||||||
|
audioAssets,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAppConfig(storageRoot: string): AppConfigService {
|
||||||
|
return {
|
||||||
|
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||||
|
storageRoot,
|
||||||
|
} as AppConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AssetsService', () => {
|
||||||
|
let service: AssetsService;
|
||||||
|
let state: MockState;
|
||||||
|
let storageRoot: string;
|
||||||
|
let storageService: LocalFilesystemStorageService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mock = createPrismaMock();
|
||||||
|
state = mock.state;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
||||||
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
|
service = new AssetsService(mock.prismaMock, storageService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a local file path and content length for the owning device user', async () => {
|
||||||
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
|
const assetId = randomUUID();
|
||||||
|
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
||||||
|
const assetBytes = Buffer.from('ID3-owner-track', 'utf8');
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
|
state.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId,
|
||||||
|
storageKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filePath = storageService.resolve(storageKey);
|
||||||
|
await storageService.ensureParentDirectory(filePath);
|
||||||
|
await writeFile(filePath, assetBytes);
|
||||||
|
|
||||||
|
const download = await service.getOwnedAudioAssetDownload(assetId, deviceId);
|
||||||
|
|
||||||
|
expect(download.filePath).toBe(filePath);
|
||||||
|
expect(download.contentLength).toBe(assetBytes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects download attempts from a different user device', async () => {
|
||||||
|
const ownerId = randomUUID();
|
||||||
|
const otherUserId = randomUUID();
|
||||||
|
const ownerDeviceId = randomUUID();
|
||||||
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
||||||
|
state.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId: ownerId,
|
||||||
|
storageKey: join('users', ownerId, 'audio', 'owner.mp3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getOwnedAudioAssetDownload(assetId, ownerDeviceId),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found when the asset file is missing from storage', async () => {
|
||||||
|
const userId = randomUUID();
|
||||||
|
const deviceId = randomUUID();
|
||||||
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
|
state.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId,
|
||||||
|
storageKey: join('users', userId, 'audio', 'missing.mp3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getOwnedAudioAssetDownload(assetId, deviceId),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found when the device does not exist', async () => {
|
||||||
|
await expect(
|
||||||
|
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
backend/src/modules/assets/assets.service.ts
Normal file
74
backend/src/modules/assets/assets.service.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
|
export interface AudioAssetDownload {
|
||||||
|
filePath: string;
|
||||||
|
contentLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getOwnedAudioAssetDownload(
|
||||||
|
assetId: string,
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<AudioAssetDownload> {
|
||||||
|
const device = await this.prismaService.device.findUnique({
|
||||||
|
where: { id: deviceId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await this.prismaService.audioAsset.findUnique({
|
||||||
|
where: { id: assetId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
storageKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
throw new NotFoundException('Audio asset not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.userId !== device.userId) {
|
||||||
|
throw new ForbiddenException('Audio asset does not belong to this device user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.storageService.resolve(asset.storageKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStats = await stat(filePath);
|
||||||
|
|
||||||
|
if (!fileStats.isFile()) {
|
||||||
|
throw new NotFoundException('Audio asset file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
contentLength: fileStats.size,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException('Audio asset file not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,19 @@
|
|||||||
import { randomUUID, createHash } from 'node:crypto';
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
INestApplication,
|
||||||
|
NotFoundException,
|
||||||
|
ValidationPipe,
|
||||||
|
VersioningType,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { AppModule } from '../../src/app.module';
|
import { AppModule } from '../../src/app.module';
|
||||||
|
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||||
|
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
||||||
import { AppConfigService } from '../../src/modules/config/config.service';
|
import { AppConfigService } from '../../src/modules/config/config.service';
|
||||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||||
import { HealthController } from '../../src/modules/health/health.controller';
|
import { HealthController } from '../../src/modules/health/health.controller';
|
||||||
@ -37,6 +45,18 @@ function createUploadRequest(data: Buffer): any {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
for await (const chunkValue of stream) {
|
||||||
|
chunks.push(
|
||||||
|
Buffer.isBuffer(chunkValue) ? chunkValue : Buffer.from(chunkValue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
const users = new Map<string, any>();
|
const users = new Map<string, any>();
|
||||||
const devices = new Map<string, any>();
|
const devices = new Map<string, any>();
|
||||||
@ -254,6 +274,7 @@ function createPrismaMock() {
|
|||||||
|
|
||||||
describe('Velody API wiring (e2e)', () => {
|
describe('Velody API wiring (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
let assetsController: AssetsController;
|
||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
let devicesController: DevicesController;
|
let devicesController: DevicesController;
|
||||||
let libraryController: LibraryController;
|
let libraryController: LibraryController;
|
||||||
@ -293,6 +314,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
);
|
);
|
||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
|
assetsController = moduleRef.get(AssetsController);
|
||||||
healthController = moduleRef.get(HealthController);
|
healthController = moduleRef.get(HealthController);
|
||||||
devicesController = moduleRef.get(DevicesController);
|
devicesController = moduleRef.get(DevicesController);
|
||||||
libraryController = moduleRef.get(LibraryController);
|
libraryController = moduleRef.get(LibraryController);
|
||||||
@ -344,6 +366,153 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(changesResponse.nextCursor).toBe('0');
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('downloads audio asset bytes for the owning device user', async () => {
|
||||||
|
const registerResponse = await devicesController.register({
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Playback iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
const assetId = randomUUID();
|
||||||
|
const trackId = randomUUID();
|
||||||
|
const bytes = sampleMp3Bytes('owner-download');
|
||||||
|
const storageKey = join(
|
||||||
|
'users',
|
||||||
|
prismaState.defaultUser.id,
|
||||||
|
'audio',
|
||||||
|
'owner-download.mp3',
|
||||||
|
);
|
||||||
|
|
||||||
|
prismaState.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId: prismaState.defaultUser.id,
|
||||||
|
trackId,
|
||||||
|
sha256: sha256Hex(bytes),
|
||||||
|
storageKey,
|
||||||
|
originalFilename: 'owner-download.mp3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: BigInt(bytes.length),
|
||||||
|
durationMs: 180000,
|
||||||
|
sourceDeviceId: registerResponse.deviceId,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filePath = join(storageRoot, storageKey);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, bytes);
|
||||||
|
|
||||||
|
const headers = new Map<string, string>();
|
||||||
|
const responseMock = {
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
headers.set(name.toLowerCase(), String(value));
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const streamable = await assetsController.download(
|
||||||
|
assetId,
|
||||||
|
{ deviceId: registerResponse.deviceId },
|
||||||
|
responseMock,
|
||||||
|
);
|
||||||
|
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
||||||
|
|
||||||
|
expect(downloadedBytes.equals(bytes)).toBe(true);
|
||||||
|
expect(headers.get('content-type')).toBe('audio/mpeg');
|
||||||
|
expect(headers.get('content-length')).toBe(String(bytes.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthorized asset download requests for another user asset', async () => {
|
||||||
|
const registerResponse = await devicesController.register({
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Playback iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
const assetId = randomUUID();
|
||||||
|
const otherUserId = randomUUID();
|
||||||
|
|
||||||
|
prismaState.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId: otherUserId,
|
||||||
|
trackId: randomUUID(),
|
||||||
|
sha256: 'sha-other',
|
||||||
|
storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
|
||||||
|
originalFilename: 'other.mp3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: BigInt(10),
|
||||||
|
durationMs: 180000,
|
||||||
|
sourceDeviceId: randomUUID(),
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assetsController.download(
|
||||||
|
assetId,
|
||||||
|
{ deviceId: registerResponse.deviceId },
|
||||||
|
{ setHeader() {} } as any,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing audio asset files cleanly', async () => {
|
||||||
|
const registerResponse = await devicesController.register({
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Playback iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
const assetId = randomUUID();
|
||||||
|
|
||||||
|
prismaState.audioAssets.set(assetId, {
|
||||||
|
id: assetId,
|
||||||
|
userId: prismaState.defaultUser.id,
|
||||||
|
trackId: randomUUID(),
|
||||||
|
sha256: 'sha-missing-file',
|
||||||
|
storageKey: join(
|
||||||
|
'users',
|
||||||
|
prismaState.defaultUser.id,
|
||||||
|
'audio',
|
||||||
|
'missing-file.mp3',
|
||||||
|
),
|
||||||
|
originalFilename: 'missing-file.mp3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: BigInt(10),
|
||||||
|
durationMs: 180000,
|
||||||
|
sourceDeviceId: registerResponse.deviceId,
|
||||||
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assetsController.download(
|
||||||
|
assetId,
|
||||||
|
{ deviceId: registerResponse.deviceId },
|
||||||
|
{ setHeader() {} } as any,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid asset download device id query', async () => {
|
||||||
|
const validationPipe = new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
validationPipe.transform(
|
||||||
|
{ deviceId: 'not-a-uuid' },
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
metatype: AssetDownloadQueryDto,
|
||||||
|
data: '',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
message: expect.arrayContaining(['deviceId must be a UUID']),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns remote library metadata for the requesting device owner', async () => {
|
it('returns remote library metadata for the requesting device owner', async () => {
|
||||||
const primaryDevice = await devicesController.register({
|
const primaryDevice = await devicesController.register({
|
||||||
platform: 'IPHONE',
|
platform: 'IPHONE',
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum RemoteTrackDownloadStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||||
|
case notDownloaded
|
||||||
|
case downloading
|
||||||
|
case downloaded
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RemoteTrackDownloadState: Codable, Hashable, Sendable {
|
||||||
|
public var remoteTrackId: String
|
||||||
|
public var assetId: String
|
||||||
|
public var localFilePath: String
|
||||||
|
public var downloadedAt: Date?
|
||||||
|
public var downloadStatus: RemoteTrackDownloadStatus
|
||||||
|
public var lastDownloadError: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
remoteTrackId: String,
|
||||||
|
assetId: String,
|
||||||
|
localFilePath: String = "",
|
||||||
|
downloadedAt: Date? = nil,
|
||||||
|
downloadStatus: RemoteTrackDownloadStatus,
|
||||||
|
lastDownloadError: String? = nil
|
||||||
|
) {
|
||||||
|
self.remoteTrackId = remoteTrackId
|
||||||
|
self.assetId = assetId
|
||||||
|
self.localFilePath = localFilePath
|
||||||
|
self.downloadedAt = downloadedAt
|
||||||
|
self.downloadStatus = downloadStatus
|
||||||
|
self.lastDownloadError = lastDownloadError
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hasLocalFile: Bool {
|
||||||
|
!localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,11 @@ public protocol VelodyAPIClient: Sendable {
|
|||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> RemoteLibraryResponseDTO
|
) async throws -> RemoteLibraryResponseDTO
|
||||||
|
|
||||||
|
func downloadAudioAsset(
|
||||||
|
assetId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data
|
||||||
|
|
||||||
func prepareUpload(
|
func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse
|
) async throws -> UploadPrepareResponse
|
||||||
@ -123,6 +128,23 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func downloadAudioAsset(
|
||||||
|
assetId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data {
|
||||||
|
let request = try buildRequest(
|
||||||
|
method: "GET",
|
||||||
|
pathComponents: ["api", "v1", "assets", assetId, "download"],
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "deviceId", value: deviceId),
|
||||||
|
],
|
||||||
|
bodyData: nil,
|
||||||
|
acceptType: "audio/mpeg"
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await executeData(request)
|
||||||
|
}
|
||||||
|
|
||||||
public func prepareUpload(
|
public func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
@ -236,7 +258,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
queryItems: [URLQueryItem],
|
queryItems: [URLQueryItem],
|
||||||
bodyData: Data?,
|
bodyData: Data?,
|
||||||
contentType: String? = nil
|
contentType: String? = nil,
|
||||||
|
acceptType: String = "application/json"
|
||||||
) throws -> URLRequest {
|
) throws -> URLRequest {
|
||||||
guard let url = endpointURL(
|
guard let url = endpointURL(
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
@ -247,7 +270,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = method
|
request.httpMethod = method
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
request.setValue(acceptType, forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
if let bodyData {
|
if let bodyData {
|
||||||
request.httpBody = bodyData
|
request.httpBody = bodyData
|
||||||
@ -280,6 +303,20 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func executeData(_ request: URLRequest) async throws -> Data {
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
|
||||||
|
do {
|
||||||
|
(data, response) = try await session.data(for: request)
|
||||||
|
} catch {
|
||||||
|
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
try validate(response: response, data: data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
private func decodeResponse<Response: Decodable>(
|
private func decodeResponse<Response: Decodable>(
|
||||||
data: Data,
|
data: Data,
|
||||||
response: URLResponse,
|
response: URLResponse,
|
||||||
@ -401,6 +438,18 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func downloadAudioAsset(
|
||||||
|
assetId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data {
|
||||||
|
_ = assetId
|
||||||
|
_ = deviceId
|
||||||
|
|
||||||
|
return Data([
|
||||||
|
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
public func prepareUpload(
|
public func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
|
|||||||
@ -0,0 +1,209 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OfflineAudioFileStoreError: LocalizedError, Equatable, Sendable {
|
||||||
|
case emptyAudioData
|
||||||
|
case sha256Mismatch(expected: String, actual: String)
|
||||||
|
case missingLocalFile(path: String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyAudioData:
|
||||||
|
return "The downloaded audio file was empty."
|
||||||
|
case let .sha256Mismatch(expected, actual):
|
||||||
|
return "The downloaded audio file hash did not match. Expected \(expected), received \(actual)."
|
||||||
|
case let .missingLocalFile(path):
|
||||||
|
return "The local audio file is missing: \(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol OfflineAudioFileStore: Actor {
|
||||||
|
func saveAudioFile(
|
||||||
|
_ data: Data,
|
||||||
|
assetId: String,
|
||||||
|
sha256: String?
|
||||||
|
) async throws -> String
|
||||||
|
func readAudioFile(at localFilePath: String) async throws -> Data
|
||||||
|
func fileExists(at localFilePath: String) async -> Bool
|
||||||
|
func resolveLocalFilePath(
|
||||||
|
persistedLocalFilePath: String,
|
||||||
|
assetId: String
|
||||||
|
) async -> String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor FileOfflineAudioFileStore: OfflineAudioFileStore {
|
||||||
|
private let baseDirectoryURL: URL
|
||||||
|
private let fileManager: FileManager
|
||||||
|
|
||||||
|
public init(
|
||||||
|
baseDirectoryURL: URL? = nil,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
if let baseDirectoryURL {
|
||||||
|
self.baseDirectoryURL = baseDirectoryURL
|
||||||
|
} else {
|
||||||
|
self.baseDirectoryURL = try Self.defaultBaseDirectoryURL(fileManager: fileManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveAudioFile(
|
||||||
|
_ data: Data,
|
||||||
|
assetId: String,
|
||||||
|
sha256: String?
|
||||||
|
) async throws -> String {
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
throw OfflineAudioFileStoreError.emptyAudioData
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: baseDirectoryURL,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let fileURL = localFileURL(for: assetId)
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
|
||||||
|
let storedData = try Data(contentsOf: fileURL)
|
||||||
|
guard !storedData.isEmpty else {
|
||||||
|
try? fileManager.removeItem(at: fileURL)
|
||||||
|
throw OfflineAudioFileStoreError.emptyAudioData
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sha256 {
|
||||||
|
let actualHash = Self.sha256Hex(for: storedData)
|
||||||
|
if actualHash != sha256 {
|
||||||
|
try? fileManager.removeItem(at: fileURL)
|
||||||
|
throw OfflineAudioFileStoreError.sha256Mismatch(
|
||||||
|
expected: sha256,
|
||||||
|
actual: actualHash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileURL.standardizedFileURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readAudioFile(at localFilePath: String) async throws -> Data {
|
||||||
|
guard let resolvedLocalFilePath = await resolveLocalFilePath(
|
||||||
|
persistedLocalFilePath: localFilePath,
|
||||||
|
assetId: URL(fileURLWithPath: localFilePath).deletingPathExtension().lastPathComponent
|
||||||
|
) else {
|
||||||
|
throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try Data(contentsOf: URL(fileURLWithPath: resolvedLocalFilePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fileExists(at localFilePath: String) async -> Bool {
|
||||||
|
let resolvedLocalFilePath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
|
||||||
|
return fileManager.fileExists(atPath: resolvedLocalFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolveLocalFilePath(
|
||||||
|
persistedLocalFilePath: String,
|
||||||
|
assetId: String
|
||||||
|
) async -> String? {
|
||||||
|
let trimmedPersistedPath = persistedLocalFilePath
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedPersistedPath.isEmpty {
|
||||||
|
let persistedURL = URL(fileURLWithPath: trimmedPersistedPath).standardizedFileURL
|
||||||
|
if fileManager.fileExists(atPath: persistedURL.path) {
|
||||||
|
return persistedURL.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentFileURL = localFileURL(for: assetId).standardizedFileURL
|
||||||
|
guard fileManager.fileExists(atPath: currentFileURL.path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentFileURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultBaseDirectoryURL(fileManager: FileManager) throws -> URL {
|
||||||
|
guard let applicationSupportURL = fileManager.urls(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask
|
||||||
|
).first else {
|
||||||
|
throw CocoaError(.fileNoSuchFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationSupportURL
|
||||||
|
.appendingPathComponent("Velody", isDirectory: true)
|
||||||
|
.appendingPathComponent("audio", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localFileURL(for assetId: String) -> URL {
|
||||||
|
baseDirectoryURL.appendingPathComponent("\(assetId).mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sha256Hex(for data: Data) -> String {
|
||||||
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor InMemoryOfflineAudioFileStore: OfflineAudioFileStore {
|
||||||
|
private var files: [String: Data]
|
||||||
|
|
||||||
|
public init(files: [String: Data] = [:]) {
|
||||||
|
self.files = files
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveAudioFile(
|
||||||
|
_ data: Data,
|
||||||
|
assetId: String,
|
||||||
|
sha256: String?
|
||||||
|
) async throws -> String {
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
throw OfflineAudioFileStoreError.emptyAudioData
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sha256 {
|
||||||
|
let actualHash = SHA256.hash(data: data)
|
||||||
|
.map { String(format: "%02x", $0) }
|
||||||
|
.joined()
|
||||||
|
if actualHash != sha256 {
|
||||||
|
throw OfflineAudioFileStoreError.sha256Mismatch(
|
||||||
|
expected: sha256,
|
||||||
|
actual: actualHash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let localFilePath = "/in-memory/\(assetId).mp3"
|
||||||
|
files[localFilePath] = data
|
||||||
|
return localFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readAudioFile(at localFilePath: String) async throws -> Data {
|
||||||
|
guard let data = files[localFilePath] else {
|
||||||
|
throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fileExists(at localFilePath: String) async -> Bool {
|
||||||
|
files[localFilePath] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolveLocalFilePath(
|
||||||
|
persistedLocalFilePath: String,
|
||||||
|
assetId: String
|
||||||
|
) async -> String? {
|
||||||
|
let trimmedPersistedPath = persistedLocalFilePath
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if files[trimmedPersistedPath] != nil {
|
||||||
|
return trimmedPersistedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackLocalFilePath = "/in-memory/\(assetId).mp3"
|
||||||
|
guard files[fallbackLocalFilePath] != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackLocalFilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
import VelodyDomain
|
||||||
|
|
||||||
|
public protocol RemoteTrackDownloadStateStore: Actor {
|
||||||
|
func loadDownloadStates() async throws -> [RemoteTrackDownloadState]
|
||||||
|
func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension RemoteTrackDownloadStateStore {
|
||||||
|
func saveDownloadState(_ state: RemoteTrackDownloadState) async throws {
|
||||||
|
var states = try await loadDownloadStates()
|
||||||
|
|
||||||
|
if let existingIndex = states.firstIndex(where: { $0.remoteTrackId == state.remoteTrackId }) {
|
||||||
|
states[existingIndex] = state
|
||||||
|
} else {
|
||||||
|
states.append(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await saveDownloadStates(states)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor FileRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore {
|
||||||
|
private let fileURL: URL
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL? = nil,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
if let fileURL {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
} else {
|
||||||
|
self.fileURL = try Self.defaultFileURL(fileManager: fileManager)
|
||||||
|
}
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
|
||||||
|
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
return try decoder.decode([RemoteTrackDownloadState].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let sortedStates = states.sorted { lhs, rhs in
|
||||||
|
lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending
|
||||||
|
}
|
||||||
|
let data = try encoder.encode(sortedStates)
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultFileURL(fileManager: FileManager) throws -> URL {
|
||||||
|
guard let applicationSupportURL = fileManager.urls(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask
|
||||||
|
).first else {
|
||||||
|
throw CocoaError(.fileNoSuchFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationSupportURL
|
||||||
|
.appendingPathComponent("Velody", isDirectory: true)
|
||||||
|
.appendingPathComponent("remote-download-states.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor InMemoryRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore {
|
||||||
|
private var states: [RemoteTrackDownloadState]
|
||||||
|
|
||||||
|
public init(states: [RemoteTrackDownloadState] = []) {
|
||||||
|
self.states = states
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
|
||||||
|
states
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws {
|
||||||
|
self.states = states
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import VelodyPersistence
|
||||||
|
|
||||||
|
final class OfflineAudioFileStoreTests: XCTestCase {
|
||||||
|
func testFileOfflineAudioFileStoreWritesAndReadsAudioData() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory)
|
||||||
|
let bytes = sampleMp3Data(seed: "offline-audio")
|
||||||
|
|
||||||
|
let localFilePath = try await store.saveAudioFile(
|
||||||
|
bytes,
|
||||||
|
assetId: "asset-123",
|
||||||
|
sha256: sha256Hex(bytes)
|
||||||
|
)
|
||||||
|
let storedBytes = try await store.readAudioFile(at: localFilePath)
|
||||||
|
let fileExists = await store.fileExists(at: localFilePath)
|
||||||
|
|
||||||
|
XCTAssertEqual(storedBytes, bytes)
|
||||||
|
XCTAssertTrue(fileExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileOfflineAudioFileStoreRejectsEmptyAudioData() async throws {
|
||||||
|
let store = try FileOfflineAudioFileStore(
|
||||||
|
baseDirectoryURL: FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
)
|
||||||
|
|
||||||
|
await XCTAssertThrowsErrorAsync {
|
||||||
|
_ = try await store.saveAudioFile(
|
||||||
|
Data(),
|
||||||
|
assetId: "asset-123",
|
||||||
|
sha256: nil
|
||||||
|
)
|
||||||
|
} assertion: { error in
|
||||||
|
XCTAssertEqual(error as? OfflineAudioFileStoreError, .emptyAudioData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileOfflineAudioFileStoreRejectsShaMismatch() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory)
|
||||||
|
let bytes = sampleMp3Data(seed: "sha-mismatch")
|
||||||
|
|
||||||
|
await XCTAssertThrowsErrorAsync {
|
||||||
|
_ = try await store.saveAudioFile(
|
||||||
|
bytes,
|
||||||
|
assetId: "asset-123",
|
||||||
|
sha256: String(repeating: "f", count: 64)
|
||||||
|
)
|
||||||
|
} assertion: { error in
|
||||||
|
guard case .sha256Mismatch = error as? OfflineAudioFileStoreError else {
|
||||||
|
return XCTFail("Expected a sha256Mismatch error.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileOfflineAudioFileStoreResolvesCurrentBaseDirectoryWhenPersistedPathIsStale() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
|
||||||
|
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
|
||||||
|
let bytes = sampleMp3Data(seed: "path-repair")
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let staleFilePath = firstAudioDirectory
|
||||||
|
.appendingPathComponent("asset-123.mp3")
|
||||||
|
.standardizedFileURL
|
||||||
|
.path
|
||||||
|
let secondStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
|
||||||
|
let currentFilePath = try await secondStore.saveAudioFile(
|
||||||
|
bytes,
|
||||||
|
assetId: "asset-123",
|
||||||
|
sha256: sha256Hex(bytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
let resolvedFilePath = await secondStore.resolveLocalFilePath(
|
||||||
|
persistedLocalFilePath: staleFilePath,
|
||||||
|
assetId: "asset-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(resolvedFilePath, currentFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func XCTAssertThrowsErrorAsync(
|
||||||
|
_ expression: @escaping () async throws -> Void,
|
||||||
|
assertion: (Error) -> Void,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) async {
|
||||||
|
do {
|
||||||
|
try await expression()
|
||||||
|
XCTFail("Expected expression to throw an error.", file: file, line: line)
|
||||||
|
} catch {
|
||||||
|
assertion(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
import VelodyDomain
|
||||||
|
@testable import VelodyPersistence
|
||||||
|
|
||||||
|
final class RemoteTrackDownloadStateStoreTests: XCTestCase {
|
||||||
|
func testFileDownloadStateStorePersistsAcrossInstances() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let fileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL)
|
||||||
|
let states = [
|
||||||
|
RemoteTrackDownloadState(
|
||||||
|
remoteTrackId: "track-123",
|
||||||
|
assetId: "asset-456",
|
||||||
|
localFilePath: "/tmp/asset-456.mp3",
|
||||||
|
downloadedAt: Date(timeIntervalSince1970: 1_000),
|
||||||
|
downloadStatus: .downloaded,
|
||||||
|
lastDownloadError: nil
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
try await firstStore.saveDownloadStates(states)
|
||||||
|
|
||||||
|
let secondStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL)
|
||||||
|
let restoredStates = try await secondStore.loadDownloadStates()
|
||||||
|
|
||||||
|
XCTAssertEqual(restoredStates, states)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import VelodyPersistence
|
|||||||
public protocol RemoteLibraryRepository: Actor {
|
public protocol RemoteLibraryRepository: Actor {
|
||||||
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
|
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
|
||||||
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
|
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
|
||||||
|
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||||
@ -30,4 +31,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
|||||||
try await store.replaceRemoteTracks(tracks)
|
try await store.replaceRemoteTracks(tracks)
|
||||||
return 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 Foundation
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
|
import VelodyPersistence
|
||||||
|
|
||||||
public actor RemoteLibrarySyncService {
|
public actor RemoteLibrarySyncService {
|
||||||
private let repository: any RemoteLibraryRepository
|
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.repository = repository
|
||||||
|
self.downloadStateStore = downloadStateStore
|
||||||
|
self.audioFileStore = audioFileStore
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||||
try await repository.loadCachedRemoteTracks()
|
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] {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import XCTest
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
@ -8,6 +9,7 @@ import VelodyPersistence
|
|||||||
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||||
func testSuccessfulSyncPersistsRemoteTracks() async throws {
|
func testSuccessfulSyncPersistsRemoteTracks() async throws {
|
||||||
let store = InMemoryRemoteLibraryStore()
|
let store = InMemoryRemoteLibraryStore()
|
||||||
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||||
let service = RemoteLibrarySyncService(
|
let service = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
@ -27,15 +29,19 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
store: store
|
store: store
|
||||||
)
|
),
|
||||||
|
downloadStateStore: downloadStateStore,
|
||||||
|
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||||
|
let downloadStates = try await service.loadDownloadStates()
|
||||||
|
|
||||||
XCTAssertEqual(tracks.count, 1)
|
XCTAssertEqual(tracks.count, 1)
|
||||||
XCTAssertEqual(cachedTracks, tracks)
|
XCTAssertEqual(cachedTracks, tracks)
|
||||||
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
|
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
|
||||||
|
XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
|
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
|
||||||
@ -53,20 +59,33 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(
|
||||||
|
states: [
|
||||||
|
RemoteTrackDownloadState(
|
||||||
|
remoteTrackId: "track-123",
|
||||||
|
assetId: "asset-123",
|
||||||
|
downloadStatus: .downloaded
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
let service = RemoteLibrarySyncService(
|
let service = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
|
||||||
),
|
),
|
||||||
store: store
|
store: store
|
||||||
)
|
),
|
||||||
|
downloadStateStore: downloadStateStore,
|
||||||
|
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||||
|
let downloadStates = try await service.loadDownloadStates()
|
||||||
|
|
||||||
XCTAssertEqual(tracks, [])
|
XCTAssertEqual(tracks, [])
|
||||||
XCTAssertEqual(cachedTracks, [])
|
XCTAssertEqual(cachedTracks, [])
|
||||||
|
XCTAssertEqual(downloadStates.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
|
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
|
||||||
@ -81,13 +100,16 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||||
)
|
)
|
||||||
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
|
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
|
||||||
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||||
let service = RemoteLibrarySyncService(
|
let service = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
|
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
|
||||||
),
|
),
|
||||||
store: store
|
store: store
|
||||||
)
|
),
|
||||||
|
downloadStateStore: downloadStateStore,
|
||||||
|
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
await XCTAssertThrowsErrorAsync {
|
await XCTAssertThrowsErrorAsync {
|
||||||
@ -97,18 +119,159 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||||
XCTAssertEqual(cachedTracks, [cachedTrack])
|
XCTAssertEqual(cachedTracks, [cachedTrack])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
|
||||||
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||||
|
let audioFileStore = InMemoryOfflineAudioFileStore()
|
||||||
|
let service = RemoteLibrarySyncService(
|
||||||
|
repository: DefaultRemoteLibraryRepository(
|
||||||
|
apiClient: MockVelodyAPIClient(
|
||||||
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||||
|
audioAssetData: sampleMp3Data(seed: "download-success")
|
||||||
|
),
|
||||||
|
store: InMemoryRemoteLibraryStore()
|
||||||
|
),
|
||||||
|
downloadStateStore: downloadStateStore,
|
||||||
|
audioFileStore: audioFileStore
|
||||||
|
)
|
||||||
|
let track = RemoteTrack(
|
||||||
|
trackId: "track-123",
|
||||||
|
title: "Remote Title",
|
||||||
|
artist: "Remote Artist",
|
||||||
|
durationSeconds: 245,
|
||||||
|
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
|
||||||
|
assetId: "asset-456",
|
||||||
|
createdAt: "2026-05-29T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
let state = try await service.downloadTrack(track, deviceId: "device-123")
|
||||||
|
let storedStates = try await service.loadDownloadStates()
|
||||||
|
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
|
||||||
|
|
||||||
|
XCTAssertEqual(state.downloadStatus, .downloaded)
|
||||||
|
XCTAssertEqual(state.assetId, "asset-456")
|
||||||
|
XCTAssertFalse(state.localFilePath.isEmpty)
|
||||||
|
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
|
||||||
|
XCTAssertTrue(fileExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDownloadTrackPersistsFailureState() async throws {
|
||||||
|
let service = RemoteLibrarySyncService(
|
||||||
|
repository: DefaultRemoteLibraryRepository(
|
||||||
|
apiClient: MockVelodyAPIClient(
|
||||||
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||||
|
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
|
||||||
|
),
|
||||||
|
store: InMemoryRemoteLibraryStore()
|
||||||
|
),
|
||||||
|
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||||
|
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||||
|
)
|
||||||
|
let track = RemoteTrack(
|
||||||
|
trackId: "track-123",
|
||||||
|
title: "Remote Title",
|
||||||
|
artist: "Remote Artist",
|
||||||
|
durationSeconds: 245,
|
||||||
|
sha256: sha256Hex(sampleMp3Data(seed: "download-failure")),
|
||||||
|
assetId: "asset-456",
|
||||||
|
createdAt: "2026-05-29T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
await XCTAssertThrowsErrorAsync {
|
||||||
|
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
let storedStates = try await service.loadDownloadStates()
|
||||||
|
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
|
||||||
|
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
|
||||||
|
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
|
||||||
|
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||||
|
let audioData = sampleMp3Data(seed: "relaunch-repair")
|
||||||
|
let track = RemoteTrack(
|
||||||
|
trackId: "track-123",
|
||||||
|
title: "1 Mai 2026",
|
||||||
|
artist: "Remote Artist",
|
||||||
|
durationSeconds: 245,
|
||||||
|
sha256: sha256Hex(audioData),
|
||||||
|
assetId: "asset-456",
|
||||||
|
createdAt: "2026-05-29T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstService = RemoteLibrarySyncService(
|
||||||
|
repository: DefaultRemoteLibraryRepository(
|
||||||
|
apiClient: MockVelodyAPIClient(
|
||||||
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||||
|
audioAssetData: audioData
|
||||||
|
),
|
||||||
|
store: InMemoryRemoteLibraryStore()
|
||||||
|
),
|
||||||
|
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||||
|
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory)
|
||||||
|
)
|
||||||
|
|
||||||
|
let originalState = try await firstService.downloadTrack(track, deviceId: "device-123")
|
||||||
|
let originalFileURL = URL(fileURLWithPath: originalState.localFilePath)
|
||||||
|
let recreatedStoreFileURL = secondAudioDirectory.appendingPathComponent("asset-456.mp3")
|
||||||
|
try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true)
|
||||||
|
try fileManager.moveItem(at: originalFileURL, to: recreatedStoreFileURL)
|
||||||
|
|
||||||
|
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
|
||||||
|
let relaunchedService = RemoteLibrarySyncService(
|
||||||
|
repository: DefaultRemoteLibraryRepository(
|
||||||
|
apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
|
||||||
|
store: InMemoryRemoteLibraryStore()
|
||||||
|
),
|
||||||
|
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||||
|
audioFileStore: relaunchedAudioStore
|
||||||
|
)
|
||||||
|
|
||||||
|
let restoredStates = try await relaunchedService.loadDownloadStates()
|
||||||
|
let restoredState = try XCTUnwrap(restoredStates.first)
|
||||||
|
let restoredBytes = try await relaunchedAudioStore.readAudioFile(at: restoredState.localFilePath)
|
||||||
|
let persistedRestoredState = try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL)
|
||||||
|
.loadDownloadStates()
|
||||||
|
.first
|
||||||
|
|
||||||
|
XCTAssertEqual(restoredState.downloadStatus, .downloaded)
|
||||||
|
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||||
|
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||||
|
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
||||||
|
XCTAssertEqual(restoredBytes, audioData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MockVelodyAPIClient: VelodyAPIClient {
|
private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||||
let remoteLibraryError: VelodyAPIError?
|
let remoteLibraryError: VelodyAPIError?
|
||||||
|
let audioAssetData: Data?
|
||||||
|
let downloadError: VelodyAPIError?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||||
remoteLibraryError: VelodyAPIError? = nil
|
remoteLibraryError: VelodyAPIError? = nil,
|
||||||
|
audioAssetData: Data? = nil,
|
||||||
|
downloadError: VelodyAPIError? = nil
|
||||||
) {
|
) {
|
||||||
self.remoteLibraryResponse = remoteLibraryResponse
|
self.remoteLibraryResponse = remoteLibraryResponse
|
||||||
self.remoteLibraryError = remoteLibraryError
|
self.remoteLibraryError = remoteLibraryError
|
||||||
|
self.audioAssetData = audioAssetData
|
||||||
|
self.downloadError = downloadError
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDevice(
|
func registerDevice(
|
||||||
@ -154,6 +317,20 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadAudioAsset(
|
||||||
|
assetId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data {
|
||||||
|
_ = assetId
|
||||||
|
_ = deviceId
|
||||||
|
|
||||||
|
if let downloadError {
|
||||||
|
throw downloadError
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioAssetData ?? Data()
|
||||||
|
}
|
||||||
|
|
||||||
func prepareUpload(
|
func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
@ -202,6 +379,16 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
private func XCTAssertThrowsErrorAsync(
|
private func XCTAssertThrowsErrorAsync(
|
||||||
_ expression: @escaping () async throws -> Void,
|
_ expression: @escaping () async throws -> Void,
|
||||||
file: StaticString = #filePath,
|
file: StaticString = #filePath,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user