Implement Milestone 7.2 offline audio downloads

This commit is contained in:
diyaa 2026-05-30 07:11:20 +02:00
parent 6e73c1878e
commit 56f030e651
19 changed files with 1822 additions and 31 deletions

View File

@ -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) {
Text(track.title) HStack(alignment: .top) {
.font(.headline) VStack(alignment: .leading, spacing: 4) {
Text(track.artist) Text(track.title)
.foregroundStyle(.secondary) .font(.headline)
Text(track.artist)
.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,12 +105,20 @@ struct iPhoneLibraryView: View {
} }
} }
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
Text(viewModel.syncStatus) VStack(alignment: .leading, spacing: 4) {
.font(.footnote) if let playbackError = viewModel.nowPlaying.errorMessage {
.foregroundStyle(.secondary) Text(playbackError)
.padding() .font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(.red)
.background(.ultraThinMaterial) }
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
} }
} }
.task { .task {
@ -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
}
}
} }

View File

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

View File

@ -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",

View File

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

View 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));
}
}

View File

@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AssetDownloadQueryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
deviceId!: string;
}

View 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 {}

View 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);
});
});

View 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');
}
}
}

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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