Implement artwork download and cache

This commit is contained in:
diyaa 2026-05-30 09:43:14 +02:00
parent 8caf29f186
commit 7b1952794c
23 changed files with 1261 additions and 66 deletions

View File

@ -1,5 +1,8 @@
import SwiftUI
import VelodyDomain
#if canImport(UIKit)
import UIKit
#endif
struct iPhoneLibraryView: View {
@State private var viewModel = iPhoneLibraryViewModel()
@ -37,6 +40,10 @@ struct iPhoneLibraryView: View {
Section("Remote Library: \(viewModel.remoteTracks.count)") {
ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 12) {
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
@ -54,6 +61,8 @@ struct iPhoneLibraryView: View {
.background(statusColor(for: track.status), in: Capsule())
.foregroundStyle(.white)
}
}
}
Text("Duration: \(track.durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
@ -103,6 +112,10 @@ struct iPhoneLibraryView: View {
} else {
ForEach(viewModel.availableOfflineTracks) { track in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 12) {
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
@ -118,6 +131,8 @@ struct iPhoneLibraryView: View {
}
.buttonStyle(.borderedProminent)
}
}
}
Text("Duration: \(track.durationText)")
.font(.subheadline)
@ -219,3 +234,39 @@ struct iPhoneLibraryView: View {
}
}
}
private struct ArtworkThumbnailView: View {
let localFilePath: String?
var body: some View {
Group {
if let artworkImage {
Image(uiImage: artworkImage)
.resizable()
.scaledToFill()
} else {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.gray.opacity(0.14))
Image(systemName: "music.note")
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.12), lineWidth: 1)
)
}
private var artworkImage: UIImage? {
guard let localFilePath, !localFilePath.isEmpty else {
return nil
}
return UIImage(contentsOfFile: localFilePath)
}
}

View File

@ -211,6 +211,7 @@ final class iPhoneLibraryViewModel {
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
@ -218,7 +219,8 @@ final class iPhoneLibraryViewModel {
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.environment = environment
@ -228,7 +230,8 @@ final class iPhoneLibraryViewModel {
self.syncService = syncService
self.offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.player.onStateChange = { [weak self] state in
self?.handleNowPlayingStateChange(state)
@ -402,6 +405,14 @@ final class iPhoneLibraryViewModel {
return InMemoryOfflineAudioFileStore()
}
private static func makeArtworkStore() -> any ArtworkStore {
if let store = try? FileArtworkStore() {
return store
}
return InMemoryArtworkStore()
}
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
let snapshot = try await offlineLibraryService.loadSnapshot()
@ -484,6 +495,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let canPlay: Bool
let playButtonTitle: String
let lastDownloadError: String?
let artworkLocalFilePath: String?
init(
track: OfflineLibraryRemoteTrack,
@ -504,6 +516,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
? "Pause"
: "Play"
lastDownloadError = track.lastDownloadError
artworkLocalFilePath = track.localArtworkFilePath
}
static func formatDuration(seconds: Int) -> String {
@ -549,6 +562,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
let remoteTrackID: String
let assetID: String
let playButtonTitle: String
let artworkLocalFilePath: String?
init(
track: OfflineLibraryTrack,
@ -563,5 +577,6 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
? "Pause"
: "Play"
artworkLocalFilePath = track.localArtworkFilePath
}
}

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from './modules/assets/assets.module';
import { ArtworkModule } from './modules/artwork/artwork.module';
import { AppConfigModule } from './modules/config/config.module';
import { DevicesModule } from './modules/devices/devices.module';
import { HealthModule } from './modules/health/health.module';
@ -11,6 +12,7 @@ import { UploadsModule } from './modules/uploads/uploads.module';
imports: [
AppConfigModule,
AssetsModule,
ArtworkModule,
HealthModule,
DevicesModule,
UploadsModule,

View File

@ -0,0 +1,46 @@
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/assets.dto';
import { ArtworkService } from './artwork.service';
@ApiTags('artwork')
@Controller({
path: 'artwork',
version: '1',
})
export class ArtworkController {
constructor(private readonly artworkService: ArtworkService) {}
@Get(':artworkId/download')
@ApiProduces('image/*')
@ApiOkResponse({
schema: {
type: 'string',
format: 'binary',
},
})
async download(
@Param('artworkId') artworkId: string,
@Query() query: AssetDownloadQueryDto,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
const download = await this.artworkService.getOwnedArtworkDownload(
artworkId,
query.deviceId,
);
response.setHeader('Content-Type', download.mimeType);
response.setHeader('Content-Length', String(download.contentLength));
return new StreamableFile(createReadStream(download.filePath));
}
}

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 { ArtworkController } from './artwork.controller';
import { ArtworkService } from './artwork.service';
@Module({
imports: [PrismaModule, StorageModule],
controllers: [ArtworkController],
providers: [ArtworkService],
})
export class ArtworkModule {}

View File

@ -0,0 +1,141 @@
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 { ArtworkService } from './artwork.service';
type MockState = ReturnType<typeof createPrismaMock>['state'];
function createPrismaMock() {
const devices = new Map<string, any>();
const artworkAssets = new Map<string, any>();
return {
prismaMock: {
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return devices.get(where.id) ?? null;
}),
},
artworkAsset: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return artworkAssets.get(where.id) ?? null;
}),
},
} as unknown as PrismaService,
state: {
devices,
artworkAssets,
},
};
}
function createAppConfig(storageRoot: string): AppConfigService {
return {
maxUploadSizeBytes: 10 * 1024 * 1024,
storageRoot,
} as AppConfigService;
}
describe('ArtworkService', () => {
let service: ArtworkService;
let state: MockState;
let storageRoot: string;
let storageService: LocalFilesystemStorageService;
beforeEach(async () => {
const mock = createPrismaMock();
state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
service = new ArtworkService(mock.prismaMock, storageService);
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
it('returns a local file path, content length, and mime type for the owning device user', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const artworkId = randomUUID();
const storageKey = join('library', 'artwork', `${artworkId}.png`);
const bytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
'base64',
);
state.devices.set(deviceId, { id: deviceId, userId });
state.artworkAssets.set(artworkId, {
storageKey,
mimeType: 'image/png',
track: {
userId,
},
});
const filePath = storageService.resolve(storageKey);
await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, bytes);
const download = await service.getOwnedArtworkDownload(artworkId, deviceId);
expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(bytes.length);
expect(download.mimeType).toBe('image/png');
});
it('rejects download attempts from a different user device', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID();
const ownerDeviceId = randomUUID();
const artworkId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
state.artworkAssets.set(artworkId, {
storageKey: join('library', 'artwork', `${artworkId}.jpg`),
mimeType: 'image/jpeg',
track: {
userId: ownerId,
},
});
await expect(
service.getOwnedArtworkDownload(artworkId, ownerDeviceId),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('returns not found when the artwork file is missing from storage', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const artworkId = randomUUID();
state.devices.set(deviceId, { id: deviceId, userId });
state.artworkAssets.set(artworkId, {
storageKey: join('library', 'artwork', `${artworkId}.png`),
mimeType: 'image/png',
track: {
userId,
},
});
await expect(
service.getOwnedArtworkDownload(artworkId, deviceId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the artwork asset does not exist', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
state.devices.set(deviceId, { id: deviceId, userId });
await expect(
service.getOwnedArtworkDownload(randomUUID(), deviceId),
).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@ -0,0 +1,81 @@
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 ArtworkDownload {
filePath: string;
contentLength: number;
mimeType: string;
}
@Injectable()
export class ArtworkService {
constructor(
private readonly prismaService: PrismaService,
private readonly storageService: LocalFilesystemStorageService,
) {}
async getOwnedArtworkDownload(
artworkId: string,
deviceId: string,
): Promise<ArtworkDownload> {
const device = await this.prismaService.device.findUnique({
where: { id: deviceId },
select: {
userId: true,
},
});
if (!device) {
throw new NotFoundException('Device not found');
}
const artwork = await this.prismaService.artworkAsset.findUnique({
where: { id: artworkId },
select: {
storageKey: true,
mimeType: true,
track: {
select: {
userId: true,
},
},
},
});
if (!artwork || !artwork.track) {
throw new NotFoundException('Artwork not found');
}
if (artwork.track.userId !== device.userId) {
throw new ForbiddenException('Artwork does not belong to this device user.');
}
const filePath = this.storageService.resolve(artwork.storageKey);
try {
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
throw new NotFoundException('Artwork file not found');
}
return {
filePath,
contentLength: fileStats.size,
mimeType: artwork.mimeType,
};
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
throw new NotFoundException('Artwork file not found');
}
}
}

View File

@ -7,6 +7,26 @@ export class LibraryTracksQueryDto {
deviceId!: string;
}
export class RemoteArtworkDto {
@ApiProperty({ format: 'uuid' })
artworkId!: string;
@ApiProperty({
example:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
})
sha256!: string;
@ApiProperty({ example: 'image/jpeg' })
mimeType!: string;
@ApiProperty({ example: 512, required: false, nullable: true })
width!: number | null;
@ApiProperty({ example: 512, required: false, nullable: true })
height!: number | null;
}
export class RemoteLibraryTrackDto {
@ApiProperty({ format: 'uuid' })
trackId!: string;
@ -34,6 +54,13 @@ export class RemoteLibraryTrackDto {
@ApiProperty({ example: '2026-05-29T08:05:00.000Z' })
updatedAt!: string;
@ApiProperty({
type: RemoteArtworkDto,
required: false,
nullable: true,
})
artwork!: RemoteArtworkDto | null;
}
export class LibraryTracksResponseDto {

View File

@ -9,6 +9,7 @@ function createPrismaMock() {
const devices = new Map<string, any>();
const tracks = new Map<string, any>();
const audioAssets = new Map<string, any>();
const artworkAssets = new Map<string, any>();
return {
prismaMock: {
@ -35,6 +36,9 @@ function createPrismaMock() {
primaryAudioAsset: track.primaryAudioAssetId
? audioAssets.get(track.primaryAudioAssetId) ?? null
: null,
artworkAsset: track.artworkAssetId
? artworkAssets.get(track.artworkAssetId) ?? null
: null,
}));
}),
},
@ -43,6 +47,7 @@ function createPrismaMock() {
devices,
tracks,
audioAssets,
artworkAssets,
},
};
}
@ -78,6 +83,7 @@ describe('LibraryService', () => {
const ownerDeviceId = randomUUID();
const ownerTrackId = randomUUID();
const ownerAssetId = randomUUID();
const ownerArtworkId = randomUUID();
const secondOwnerTrackId = randomUUID();
const secondOwnerAssetId = randomUUID();
const otherTrackId = randomUUID();
@ -90,6 +96,13 @@ describe('LibraryService', () => {
sha256: 'sha-owner-a',
durationMs: 181000,
});
state.artworkAssets.set(ownerArtworkId, {
id: ownerArtworkId,
sha256: 'artwork-sha-owner-a',
mimeType: 'image/png',
width: 600,
height: 600,
});
state.audioAssets.set(secondOwnerAssetId, {
id: secondOwnerAssetId,
sha256: 'sha-owner-b',
@ -120,6 +133,7 @@ describe('LibraryService', () => {
durationMs: 181000,
status: 'ACTIVE',
primaryAudioAssetId: ownerAssetId,
artworkAssetId: ownerArtworkId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
@ -147,6 +161,13 @@ describe('LibraryService', () => {
assetId: ownerAssetId,
createdAt: '2026-05-29T08:00:00.000Z',
updatedAt: '2026-05-29T08:01:00.000Z',
artwork: {
artworkId: ownerArtworkId,
sha256: 'artwork-sha-owner-a',
mimeType: 'image/png',
width: 600,
height: 600,
},
},
{
trackId: secondOwnerTrackId,
@ -157,6 +178,7 @@ describe('LibraryService', () => {
assetId: secondOwnerAssetId,
createdAt: '2026-05-29T08:05:00.000Z',
updatedAt: '2026-05-29T08:06:00.000Z',
artwork: null,
},
]);
});

View File

@ -74,6 +74,15 @@ export class LibraryService {
durationMs: true,
},
},
artworkAsset: {
select: {
id: true,
sha256: true,
mimeType: true,
width: true,
height: true,
},
},
},
});
@ -95,6 +104,15 @@ export class LibraryService {
assetId: track.primaryAudioAsset.id,
createdAt: track.createdAt.toISOString(),
updatedAt: track.updatedAt.toISOString(),
artwork: track.artworkAsset
? {
artworkId: track.artworkAsset.id,
sha256: track.artworkAsset.sha256,
mimeType: track.artworkAsset.mimeType,
width: track.artworkAsset.width,
height: track.artworkAsset.height,
}
: null,
},
];
});

View File

@ -14,6 +14,7 @@ import { Test } from '@nestjs/testing';
import { AppModule } from '../../src/app.module';
import { AssetsController } from '../../src/modules/assets/assets.controller';
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller';
@ -62,6 +63,7 @@ function createPrismaMock() {
const devices = new Map<string, any>();
const tracks = new Map<string, any>();
const audioAssets = new Map<string, any>();
const artworkAssets = new Map<string, any>();
const uploadSessions = new Map<string, any>();
const libraryEvents = new Map<bigint, any>();
let nextLibraryEventId = 1n;
@ -133,6 +135,9 @@ function createPrismaMock() {
primaryAudioAsset: track.primaryAudioAssetId
? audioAssets.get(track.primaryAudioAssetId) ?? null
: null,
artworkAsset: track.artworkAssetId
? artworkAssets.get(track.artworkAssetId) ?? null
: null,
}));
}),
findUnique: jest.fn().mockImplementation(async ({ where }) => {
@ -209,6 +214,11 @@ function createPrismaMock() {
return updated;
}),
},
artworkAsset: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return artworkAssets.get(where.id) ?? null;
}),
},
uploadSession: {
create: jest.fn().mockImplementation(async ({ data }) => {
const now = new Date();
@ -266,6 +276,7 @@ function createPrismaMock() {
devices,
tracks,
audioAssets,
artworkAssets,
uploadSessions,
libraryEvents,
},
@ -275,6 +286,7 @@ function createPrismaMock() {
describe('Velody API wiring (e2e)', () => {
let app: INestApplication;
let assetsController: AssetsController;
let artworkController: ArtworkController;
let healthController: HealthController;
let devicesController: DevicesController;
let libraryController: LibraryController;
@ -315,6 +327,7 @@ describe('Velody API wiring (e2e)', () => {
await app.init();
assetsController = moduleRef.get(AssetsController);
artworkController = moduleRef.get(ArtworkController);
healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController);
libraryController = moduleRef.get(LibraryController);
@ -490,6 +503,107 @@ describe('Velody API wiring (e2e)', () => {
).rejects.toBeInstanceOf(NotFoundException);
});
it('downloads artwork bytes for the owning device user with the stored image mime type', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Artwork iPhone',
appVersion: '0.1.0',
});
const artworkId = randomUUID();
const trackId = randomUUID();
const bytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
'base64',
);
const storageKey = join('library', 'artwork', `${artworkId}.png`);
prismaState.artworkAssets.set(artworkId, {
id: artworkId,
sha256: sha256Hex(bytes),
storageKey,
mimeType: 'image/png',
width: 1,
height: 1,
fileSizeBytes: BigInt(bytes.length),
createdAt: new Date('2026-05-29T08:00:00.000Z'),
track: {
userId: prismaState.defaultUser.id,
},
});
prismaState.tracks.set(trackId, {
id: trackId,
userId: prismaState.defaultUser.id,
primaryAudioAssetId: null,
artworkAssetId: artworkId,
title: 'Artwork Track',
artist: 'Velody',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: null,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:02: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 artworkController.download(
artworkId,
{ deviceId: registerResponse.deviceId },
responseMock,
);
const downloadedBytes = await streamToBuffer(streamable.getStream());
expect(downloadedBytes.equals(bytes)).toBe(true);
expect(headers.get('content-type')).toBe('image/png');
expect(headers.get('content-length')).toBe(String(bytes.length));
});
it('returns not found when the requested artwork file is missing', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Artwork iPhone',
appVersion: '0.1.0',
});
const artworkId = randomUUID();
prismaState.artworkAssets.set(artworkId, {
id: artworkId,
sha256: 'sha-missing-artwork',
storageKey: join('library', 'artwork', `${artworkId}.png`),
mimeType: 'image/png',
width: 1,
height: 1,
fileSizeBytes: BigInt(10),
createdAt: new Date('2026-05-29T08:00:00.000Z'),
track: {
userId: prismaState.defaultUser.id,
},
});
await expect(
artworkController.download(
artworkId,
{ 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,
@ -523,6 +637,7 @@ describe('Velody API wiring (e2e)', () => {
const secondaryDeviceId = randomUUID();
const primaryTrackId = randomUUID();
const primaryAssetId = randomUUID();
const primaryArtworkId = randomUUID();
const secondaryTrackId = randomUUID();
const secondaryAssetId = randomUUID();
@ -552,6 +667,16 @@ describe('Velody API wiring (e2e)', () => {
sourceDeviceId: primaryDevice.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
prismaState.artworkAssets.set(primaryArtworkId, {
id: primaryArtworkId,
sha256: 'artwork-sha-default',
storageKey: `library/artwork/${primaryArtworkId}.png`,
mimeType: 'image/png',
width: 512,
height: 512,
fileSizeBytes: BigInt(128),
createdAt: new Date('2026-05-29T08:00:30.000Z'),
});
prismaState.audioAssets.set(secondaryAssetId, {
id: secondaryAssetId,
userId: secondUserId,
@ -571,7 +696,7 @@ describe('Velody API wiring (e2e)', () => {
id: primaryTrackId,
userId: prismaState.defaultUser.id,
primaryAudioAssetId: primaryAssetId,
artworkAssetId: null,
artworkAssetId: primaryArtworkId,
title: 'Default User Track',
artist: 'Velody',
album: null,
@ -621,6 +746,13 @@ describe('Velody API wiring (e2e)', () => {
assetId: primaryAssetId,
createdAt: '2026-05-29T08:00:00.000Z',
updatedAt: '2026-05-29T08:02:00.000Z',
artwork: {
artworkId: primaryArtworkId,
sha256: 'artwork-sha-default',
mimeType: 'image/png',
width: 512,
height: 512,
},
},
],
});

View File

@ -12,6 +12,7 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
public var localFilePath: String
public var downloadedAt: Date?
public var isFileAvailable: Bool
public var localArtworkFilePath: String?
public init(
remoteTrackId: String,
@ -22,7 +23,8 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
sha256: String,
localFilePath: String,
downloadedAt: Date?,
isFileAvailable: Bool
isFileAvailable: Bool,
localArtworkFilePath: String? = nil
) {
self.remoteTrackId = remoteTrackId
self.assetId = assetId
@ -33,6 +35,7 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
self.localFilePath = localFilePath
self.downloadedAt = downloadedAt
self.isFileAvailable = isFileAvailable
self.localArtworkFilePath = localArtworkFilePath
}
}
@ -53,6 +56,7 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
public var isFileAvailable: Bool
public var status: OfflineLibraryRemoteTrackStatus
public var lastDownloadError: String?
public var localArtworkFilePath: String?
public init(
remoteTrack: RemoteTrack,
@ -60,7 +64,8 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
downloadedAt: Date?,
isFileAvailable: Bool,
status: OfflineLibraryRemoteTrackStatus,
lastDownloadError: String?
lastDownloadError: String?,
localArtworkFilePath: String? = nil
) {
self.remoteTrack = remoteTrack
self.localFilePath = localFilePath
@ -68,6 +73,7 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
self.isFileAvailable = isFileAvailable
self.status = status
self.lastDownloadError = lastDownloadError
self.localArtworkFilePath = localArtworkFilePath
}
}

View File

@ -1,5 +1,27 @@
import Foundation
public struct RemoteArtwork: Codable, Hashable, Sendable {
public var artworkId: String
public var sha256: String
public var mimeType: String
public var width: Int?
public var height: Int?
public init(
artworkId: String,
sha256: String,
mimeType: String,
width: Int? = nil,
height: Int? = nil
) {
self.artworkId = artworkId
self.sha256 = sha256
self.mimeType = mimeType
self.width = width
self.height = height
}
}
public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
public var id: String { trackId }
@ -11,6 +33,7 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
public var assetId: String
public var createdAt: String
public var updatedAt: String
public var artwork: RemoteArtwork?
public init(
trackId: String,
@ -20,7 +43,8 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
sha256: String,
assetId: String,
createdAt: String,
updatedAt: String
updatedAt: String,
artwork: RemoteArtwork? = nil
) {
self.trackId = trackId
self.title = title
@ -30,5 +54,6 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
self.assetId = assetId
self.createdAt = createdAt
self.updatedAt = updatedAt
self.artwork = artwork
}
}

View File

@ -1,6 +1,38 @@
import Foundation
import VelodyDomain
public struct RemoteArtworkDTO: Codable, Hashable, Sendable {
public var artworkId: String
public var sha256: String
public var mimeType: String
public var width: Int?
public var height: Int?
public init(
artworkId: String,
sha256: String,
mimeType: String,
width: Int? = nil,
height: Int? = nil
) {
self.artworkId = artworkId
self.sha256 = sha256
self.mimeType = mimeType
self.width = width
self.height = height
}
public var remoteArtwork: RemoteArtwork {
RemoteArtwork(
artworkId: artworkId,
sha256: sha256,
mimeType: mimeType,
width: width,
height: height
)
}
}
public struct RemoteTrackDTO: Codable, Hashable, Sendable {
public var trackId: String
public var title: String
@ -10,6 +42,7 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
public var assetId: String
public var createdAt: String
public var updatedAt: String
public var artwork: RemoteArtworkDTO?
public init(
trackId: String,
@ -19,7 +52,8 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
sha256: String,
assetId: String,
createdAt: String,
updatedAt: String
updatedAt: String,
artwork: RemoteArtworkDTO? = nil
) {
self.trackId = trackId
self.title = title
@ -29,6 +63,7 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
self.assetId = assetId
self.createdAt = createdAt
self.updatedAt = updatedAt
self.artwork = artwork
}
public var remoteTrack: RemoteTrack {
@ -40,7 +75,8 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
sha256: sha256,
assetId: assetId,
createdAt: createdAt,
updatedAt: updatedAt
updatedAt: updatedAt,
artwork: artwork?.remoteArtwork
)
}
}

View File

@ -48,6 +48,11 @@ public protocol VelodyAPIClient: Sendable {
deviceId: String
) async throws -> Data
func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse
@ -145,6 +150,23 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
return try await executeData(request)
}
public func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
method: "GET",
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
acceptType: "image/*"
)
return try await executeData(request)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@ -450,6 +472,18 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
])
}
public func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
_ = artworkId
_ = deviceId
return Data([
0x89, 0x50, 0x4E, 0x47,
])
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {

View File

@ -1,5 +1,6 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyNetworking
final class RemoteLibraryDTOTests: XCTestCase {
@ -12,7 +13,14 @@ final class RemoteLibraryDTOTests: XCTestCase {
sha256: String(repeating: "a", count: 64),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
updatedAt: "2026-05-29T08:05:00.000Z",
artwork: RemoteArtworkDTO(
artworkId: "artwork-789",
sha256: String(repeating: "b", count: 64),
mimeType: "image/png",
width: 512,
height: 512
)
)
let track = dto.remoteTrack
@ -23,6 +31,16 @@ final class RemoteLibraryDTOTests: XCTestCase {
XCTAssertEqual(track.durationSeconds, 245)
XCTAssertEqual(track.sha256, String(repeating: "a", count: 64))
XCTAssertEqual(track.assetId, "asset-456")
XCTAssertEqual(
track.artwork,
RemoteArtwork(
artworkId: "artwork-789",
sha256: String(repeating: "b", count: 64),
mimeType: "image/png",
width: 512,
height: 512
)
)
}
func testRemoteLibraryResponseDTODecodesFromAPIResponse() throws {
@ -38,7 +56,14 @@ final class RemoteLibraryDTOTests: XCTestCase {
"sha256": "\(String(repeating: "a", count: 64))",
"assetId": "asset-456",
"createdAt": "2026-05-29T08:00:00.000Z",
"updatedAt": "2026-05-29T08:05:00.000Z"
"updatedAt": "2026-05-29T08:05:00.000Z",
"artwork": {
"artworkId": "artwork-789",
"sha256": "\(String(repeating: "b", count: 64))",
"mimeType": "image/png",
"width": 512,
"height": 512
}
}
]
}
@ -50,5 +75,6 @@ final class RemoteLibraryDTOTests: XCTestCase {
XCTAssertEqual(decoded.tracks.count, 1)
XCTAssertEqual(decoded.tracks.first?.trackId, "track-123")
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789")
}
}

View File

@ -0,0 +1,200 @@
import Foundation
import VelodyDomain
public enum ArtworkStoreError: LocalizedError, Equatable, Sendable {
case emptyArtworkData
case missingLocalFile(path: String)
public var errorDescription: String? {
switch self {
case .emptyArtworkData:
return "The downloaded artwork file was empty."
case let .missingLocalFile(path):
return "The local artwork file is missing: \(path)"
}
}
}
public protocol ArtworkStore: Actor {
func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String
func readArtwork(at localFilePath: String) async throws -> Data
func fileExists(at localFilePath: String) async -> Bool
func cachedFilePath(for artwork: RemoteArtwork) async -> String?
}
public actor FileArtworkStore: ArtworkStore {
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 saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String {
guard !data.isEmpty else {
throw ArtworkStoreError.emptyArtworkData
}
try fileManager.createDirectory(
at: baseDirectoryURL,
withIntermediateDirectories: true
)
let fileURL = localFileURL(for: artwork)
try data.write(to: fileURL, options: .atomic)
let storedData = try Data(contentsOf: fileURL)
guard !storedData.isEmpty else {
try? fileManager.removeItem(at: fileURL)
throw ArtworkStoreError.emptyArtworkData
}
return fileURL.standardizedFileURL.path
}
public func readArtwork(at localFilePath: String) async throws -> Data {
let standardizedPath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
guard fileManager.fileExists(atPath: standardizedPath) else {
throw ArtworkStoreError.missingLocalFile(path: localFilePath)
}
return try Data(contentsOf: URL(fileURLWithPath: standardizedPath))
}
public func fileExists(at localFilePath: String) async -> Bool {
let standardizedPath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
return fileManager.fileExists(atPath: standardizedPath)
}
public func cachedFilePath(for artwork: RemoteArtwork) async -> String? {
let expectedFileURL = localFileURL(for: artwork).standardizedFileURL
if fileManager.fileExists(atPath: expectedFileURL.path) {
return expectedFileURL.path
}
guard let contents = try? fileManager.contentsOfDirectory(
at: baseDirectoryURL,
includingPropertiesForKeys: nil
) else {
return nil
}
let fallbackPrefix = "\(artwork.artworkId)."
return contents
.first(where: {
$0.lastPathComponent.hasPrefix(fallbackPrefix) &&
fileManager.fileExists(atPath: $0.path)
})?
.standardizedFileURL
.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("artwork", isDirectory: true)
}
private func localFileURL(for artwork: RemoteArtwork) -> URL {
baseDirectoryURL.appendingPathComponent(
"\(artwork.artworkId).\(Self.fileExtension(for: artwork.mimeType))"
)
}
private static func fileExtension(for mimeType: String) -> String {
switch mimeType.lowercased() {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/heic":
return "heic"
case "image/heif":
return "heif"
case "image/gif":
return "gif"
default:
return "img"
}
}
}
public actor InMemoryArtworkStore: ArtworkStore {
private var files: [String: Data]
public init(files: [String: Data] = [:]) {
self.files = files
}
public func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String {
guard !data.isEmpty else {
throw ArtworkStoreError.emptyArtworkData
}
let localFilePath = Self.localFilePath(for: artwork)
files[localFilePath] = data
return localFilePath
}
public func readArtwork(at localFilePath: String) async throws -> Data {
guard let data = files[localFilePath] else {
throw ArtworkStoreError.missingLocalFile(path: localFilePath)
}
return data
}
public func fileExists(at localFilePath: String) async -> Bool {
files[localFilePath] != nil
}
public func cachedFilePath(for artwork: RemoteArtwork) async -> String? {
let expectedFilePath = Self.localFilePath(for: artwork)
if files[expectedFilePath] != nil {
return expectedFilePath
}
let fallbackPrefix = "/in-memory/\(artwork.artworkId)."
return files.keys.first(where: { $0.hasPrefix(fallbackPrefix) })
}
private static func localFilePath(for artwork: RemoteArtwork) -> String {
let fileExtension: String
switch artwork.mimeType.lowercased() {
case "image/jpeg", "image/jpg":
fileExtension = "jpg"
case "image/png":
fileExtension = "png"
case "image/webp":
fileExtension = "webp"
case "image/heic":
fileExtension = "heic"
case "image/heif":
fileExtension = "heif"
case "image/gif":
fileExtension = "gif"
default:
fileExtension = "img"
}
return "/in-memory/\(artwork.artworkId).\(fileExtension)"
}
}

View File

@ -0,0 +1,108 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyPersistence
final class ArtworkStoreTests: XCTestCase {
func testFileArtworkStoreWritesAndReadsArtworkData() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let store = try FileArtworkStore(baseDirectoryURL: tempDirectory)
let artwork = RemoteArtwork(
artworkId: "artwork-123",
sha256: String(repeating: "a", count: 64),
mimeType: "image/png",
width: 1,
height: 1
)
let bytes = sampleArtworkData()
let localFilePath = try await store.saveArtwork(bytes, artwork: artwork)
let storedBytes = try await store.readArtwork(at: localFilePath)
let fileExists = await store.fileExists(at: localFilePath)
let cachedFilePath = await store.cachedFilePath(for: artwork)
XCTAssertEqual(storedBytes, bytes)
XCTAssertTrue(fileExists)
XCTAssertEqual(cachedFilePath, localFilePath)
}
func testFileArtworkStoreRejectsEmptyArtworkData() async throws {
let store = try FileArtworkStore(
baseDirectoryURL: FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
)
await XCTAssertThrowsErrorAsync {
_ = try await store.saveArtwork(
Data(),
artwork: RemoteArtwork(
artworkId: "artwork-123",
sha256: String(repeating: "a", count: 64),
mimeType: "image/png"
)
)
} assertion: { error in
XCTAssertEqual(error as? ArtworkStoreError, .emptyArtworkData)
}
}
func testFileArtworkStorePersistsArtworkAcrossInstances() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let artwork = RemoteArtwork(
artworkId: "artwork-123",
sha256: String(repeating: "a", count: 64),
mimeType: "image/png",
width: 1,
height: 1
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstStore = try FileArtworkStore(baseDirectoryURL: tempDirectory)
let firstLocalFilePath = try await firstStore.saveArtwork(
sampleArtworkData(),
artwork: artwork
)
let secondStore = try FileArtworkStore(baseDirectoryURL: tempDirectory)
let restoredLocalFilePath = await secondStore.cachedFilePath(for: artwork)
XCTAssertEqual(restoredLocalFilePath, firstLocalFilePath)
}
}
private func sampleArtworkData() -> Data {
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
)!
}
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

@ -5,13 +5,16 @@ import VelodyPersistence
public actor OfflineLibraryService {
private let syncService: RemoteLibrarySyncService
private let audioFileStore: any OfflineAudioFileStore
private let artworkStore: any ArtworkStore
public init(
syncService: RemoteLibrarySyncService,
audioFileStore: any OfflineAudioFileStore
audioFileStore: any OfflineAudioFileStore,
artworkStore: any ArtworkStore
) {
self.syncService = syncService
self.audioFileStore = audioFileStore
self.artworkStore = artworkStore
}
public func loadSnapshot() async throws -> OfflineLibrarySnapshot {
@ -37,6 +40,10 @@ public actor OfflineLibraryService {
for: downloadState,
status: status
)
let localArtworkFilePath = await Self.localArtworkFilePath(
for: remoteTrack.artwork,
artworkStore: artworkStore
)
remoteLibraryTracks.append(
OfflineLibraryRemoteTrack(
@ -45,7 +52,8 @@ public actor OfflineLibraryService {
downloadedAt: downloadedAt,
isFileAvailable: isFileAvailable,
status: status,
lastDownloadError: lastDownloadError
lastDownloadError: lastDownloadError,
localArtworkFilePath: localArtworkFilePath
)
)
@ -63,7 +71,8 @@ public actor OfflineLibraryService {
sha256: remoteTrack.sha256,
localFilePath: localFilePath,
downloadedAt: downloadedAt,
isFileAvailable: true
isFileAvailable: true,
localArtworkFilePath: localArtworkFilePath
)
)
}
@ -118,4 +127,15 @@ public actor OfflineLibraryService {
return downloadState?.lastDownloadError
}
private static func localArtworkFilePath(
for artwork: RemoteArtwork?,
artworkStore: any ArtworkStore
) async -> String? {
guard let artwork else {
return nil
}
return await artworkStore.cachedFilePath(for: artwork)
}
}

View File

@ -7,6 +7,7 @@ public protocol RemoteLibraryRepository: Actor {
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data
}
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
@ -38,4 +39,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
) async throws -> Data {
try await apiClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId)
}
public func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
try await apiClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId)
}
}

View File

@ -6,15 +6,18 @@ public actor RemoteLibrarySyncService {
private let repository: any RemoteLibraryRepository
private let downloadStateStore: any RemoteTrackDownloadStateStore
private let audioFileStore: any OfflineAudioFileStore
private let artworkStore: any ArtworkStore
public init(
repository: any RemoteLibraryRepository,
downloadStateStore: any RemoteTrackDownloadStateStore,
audioFileStore: any OfflineAudioFileStore
audioFileStore: any OfflineAudioFileStore,
artworkStore: any ArtworkStore
) {
self.repository = repository
self.downloadStateStore = downloadStateStore
self.audioFileStore = audioFileStore
self.artworkStore = artworkStore
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
@ -29,6 +32,7 @@ public actor RemoteLibrarySyncService {
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
let tracks = try await repository.syncRemoteTracks(deviceId: deviceId)
try await ensureDownloadStates(for: tracks)
await cacheArtwork(for: tracks, deviceId: deviceId)
return tracks
}
@ -181,4 +185,29 @@ public actor RemoteLibrarySyncService {
return reconciledStates
}
private func cacheArtwork(
for tracks: [RemoteTrack],
deviceId: String
) async {
for track in tracks {
guard let artwork = track.artwork else {
continue
}
if await artworkStore.cachedFilePath(for: artwork) != nil {
continue
}
do {
let artworkData = try await repository.downloadArtwork(
artworkId: artwork.artworkId,
deviceId: deviceId
)
_ = try await artworkStore.saveArtwork(artworkData, artwork: artwork)
} catch {
continue
}
}
}
}

View File

@ -141,11 +141,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: relaunchedAudioStore
audioFileStore: relaunchedAudioStore,
artworkStore: InMemoryArtworkStore()
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: relaunchedAudioStore
audioFileStore: relaunchedAudioStore,
artworkStore: InMemoryArtworkStore()
)
let snapshot = try await offlineLibraryService.loadSnapshot()
@ -177,11 +179,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
store: remoteLibraryStore
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
defer {
@ -212,10 +216,12 @@ final class OfflineLibraryServiceTests: XCTestCase {
isDirectory: true
)
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
let track = makeRemoteTrack(
trackId: "track-sync",
assetId: "asset-sync",
title: "Sync Safe"
title: "Sync Safe",
artworkId: "artwork-sync"
)
let remoteLibraryStore = InMemoryRemoteLibraryStore()
let audioData = sampleMp3Data(seed: track.assetId)
@ -223,21 +229,27 @@ final class OfflineLibraryServiceTests: XCTestCase {
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [makeRemoteTrackDTO(from: track)]
),
audioAssetData: audioData
audioAssetData: audioData,
artworkDataByArtworkID: [
"artwork-sync": sampleArtworkData(),
]
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory)
let syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: remoteLibraryStore
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
defer {
@ -254,6 +266,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId])
XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId])
XCTAssertEqual(afterResync.remoteTracks.first?.status, .downloaded)
XCTAssertEqual(beforeResync.remoteTracks.first?.localArtworkFilePath, afterResync.remoteTracks.first?.localArtworkFilePath)
XCTAssertEqual(beforeResync.availableTracks.first?.localArtworkFilePath, afterResync.availableTracks.first?.localArtworkFilePath)
}
func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws {
@ -266,8 +280,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
let tracks = [
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1"),
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2"),
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1", artworkId: "artwork-1"),
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
]
let apiClient = OfflineLibraryMockAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
@ -276,8 +290,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
audioAssetDataByAssetID: [
"asset-1": sampleMp3Data(seed: "asset-1"),
"asset-2": sampleMp3Data(seed: "asset-2"),
],
artworkDataByArtworkID: [
"artwork-1": sampleArtworkData(),
"artwork-2": sampleArtworkData(),
]
)
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
defer {
try? fileManager.removeItem(at: tempDirectory)
@ -292,11 +311,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
let firstSyncService = RemoteLibrarySyncService(
repository: firstRepository,
downloadStateStore: firstDownloadStateStore,
audioFileStore: firstAudioStore
audioFileStore: firstAudioStore,
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
)
let firstOfflineLibraryService = OfflineLibraryService(
syncService: firstSyncService,
audioFileStore: firstAudioStore
audioFileStore: firstAudioStore,
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
)
_ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123")
@ -315,11 +336,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
let relaunchedSyncService = RemoteLibrarySyncService(
repository: relaunchedRepository,
downloadStateStore: relaunchedDownloadStateStore,
audioFileStore: relaunchedAudioStore
audioFileStore: relaunchedAudioStore,
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
)
let relaunchedOfflineLibraryService = OfflineLibraryService(
syncService: relaunchedSyncService,
audioFileStore: relaunchedAudioStore
audioFileStore: relaunchedAudioStore,
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
)
let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot()
@ -327,6 +350,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
XCTAssertEqual(afterRelaunch.remoteTracks.map(\.status), [.downloaded, .downloaded])
XCTAssertEqual(afterRelaunch.remoteTracks.compactMap(\.localArtworkFilePath).count, 2)
XCTAssertEqual(afterRelaunch.availableTracks.compactMap(\.localArtworkFilePath).count, 2)
}
}
@ -338,12 +363,14 @@ private func makeOfflineLibraryService(
let syncService = RemoteLibrarySyncService(
repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates),
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
return OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
}
@ -368,21 +395,30 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
_ = deviceId
return Data()
}
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
_ = artworkId
_ = deviceId
return Data()
}
}
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
let remoteLibraryResponse: RemoteLibraryResponseDTO?
let audioAssetData: Data?
let audioAssetDataByAssetID: [String: Data]
let artworkDataByArtworkID: [String: Data]
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
audioAssetData: Data? = nil,
audioAssetDataByAssetID: [String: Data] = [:]
audioAssetDataByAssetID: [String: Data] = [:],
artworkDataByArtworkID: [String: Data] = [:]
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.audioAssetData = audioAssetData
self.audioAssetDataByAssetID = audioAssetDataByAssetID
self.artworkDataByArtworkID = artworkDataByArtworkID
}
func registerDevice(
@ -431,6 +467,14 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data()
}
func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
_ = deviceId
return artworkDataByArtworkID[artworkId] ?? Data()
}
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@ -483,7 +527,8 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
private func makeRemoteTrack(
trackId: String,
assetId: String,
title: String
title: String,
artworkId: String? = nil
) -> RemoteTrack {
let bytes = sampleMp3Data(seed: assetId)
@ -495,7 +540,16 @@ private func makeRemoteTrack(
sha256: sha256Hex(bytes),
assetId: assetId,
createdAt: "2026-05-30T08:00:00.000Z",
updatedAt: "2026-05-30T08:05:00.000Z"
updatedAt: "2026-05-30T08:05:00.000Z",
artwork: artworkId.map {
RemoteArtwork(
artworkId: $0,
sha256: String(repeating: "c", count: 64),
mimeType: "image/png",
width: 1,
height: 1
)
}
)
}
@ -508,7 +562,16 @@ private func makeRemoteTrackDTO(from track: RemoteTrack) -> RemoteTrackDTO {
sha256: track.sha256,
assetId: track.assetId,
createdAt: track.createdAt,
updatedAt: track.updatedAt
updatedAt: track.updatedAt,
artwork: track.artwork.map {
RemoteArtworkDTO(
artworkId: $0.artworkId,
sha256: $0.sha256,
mimeType: $0.mimeType,
width: $0.width,
height: $0.height
)
}
)
}
@ -518,6 +581,13 @@ private func sampleMp3Data(seed: String) -> Data {
] + Array(seed.utf8))
}
private func sampleArtworkData() -> Data {
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
)!
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}

View File

@ -31,7 +31,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
@ -76,7 +77,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
@ -109,7 +111,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
await XCTAssertThrowsErrorAsync {
@ -132,7 +135,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
let track = RemoteTrack(
trackId: "track-123",
@ -166,7 +170,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let track = RemoteTrack(
trackId: "track-123",
@ -222,7 +227,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory)
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
artworkStore: InMemoryArtworkStore()
)
let originalState = try await firstService.downloadTrack(track, deviceId: "device-123")
@ -238,7 +244,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: relaunchedAudioStore
audioFileStore: relaunchedAudioStore,
artworkStore: InMemoryArtworkStore()
)
let restoredStates = try await relaunchedService.loadDownloadStates()
@ -254,6 +261,58 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
XCTAssertEqual(restoredBytes, audioData)
}
func testSyncCachesArtworkIndependentlyFromAudioDownloads() async throws {
let artwork = RemoteArtwork(
artworkId: "artwork-123",
sha256: String(repeating: "d", count: 64),
mimeType: "image/png",
width: 1,
height: 1
)
let artworkStore = InMemoryArtworkStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [
RemoteTrackDTO(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z",
artwork: RemoteArtworkDTO(
artworkId: artwork.artworkId,
sha256: artwork.sha256,
mimeType: artwork.mimeType,
width: artwork.width,
height: artwork.height
)
),
]
),
artworkData: sampleArtworkData()
),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: artworkStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedArtworkPath = await artworkStore.cachedFilePath(for: artwork)
let cachedArtworkBytes = try await artworkStore.readArtwork(
at: try XCTUnwrap(cachedArtworkPath)
)
XCTAssertEqual(tracks.first?.artwork, artwork)
XCTAssertEqual(cachedArtworkBytes, sampleArtworkData())
}
}
private struct MockVelodyAPIClient: VelodyAPIClient {
@ -261,17 +320,23 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
let remoteLibraryError: VelodyAPIError?
let audioAssetData: Data?
let downloadError: VelodyAPIError?
let artworkData: Data?
let artworkDownloadError: VelodyAPIError?
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
remoteLibraryError: VelodyAPIError? = nil,
audioAssetData: Data? = nil,
downloadError: VelodyAPIError? = nil
downloadError: VelodyAPIError? = nil,
artworkData: Data? = nil,
artworkDownloadError: VelodyAPIError? = nil
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.remoteLibraryError = remoteLibraryError
self.audioAssetData = audioAssetData
self.downloadError = downloadError
self.artworkData = artworkData
self.artworkDownloadError = artworkDownloadError
}
func registerDevice(
@ -331,6 +396,20 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
return audioAssetData ?? Data()
}
func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
_ = artworkId
_ = deviceId
if let artworkDownloadError {
throw artworkDownloadError
}
return artworkData ?? Data()
}
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@ -385,6 +464,13 @@ private func sampleMp3Data(seed: String) -> Data {
] + Array(seed.utf8))
}
private func sampleArtworkData() -> Data {
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
)!
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}