diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index fb48491..169c6c7 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -1,5 +1,8 @@ import SwiftUI import VelodyDomain +#if canImport(UIKit) +import UIKit +#endif struct iPhoneLibraryView: View { @State private var viewModel = iPhoneLibraryViewModel() @@ -37,22 +40,28 @@ struct iPhoneLibraryView: View { Section("Remote Library: \(viewModel.remoteTracks.count)") { ForEach(viewModel.remoteTracks) { track in VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top) { + HStack(alignment: .top, spacing: 12) { + ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) + VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .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.status), in: Capsule()) + .foregroundStyle(.white) + } } - - Spacer() - - Text(track.statusText) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(statusColor(for: track.status), in: Capsule()) - .foregroundStyle(.white) } Text("Duration: \(track.durationText)") .font(.subheadline) @@ -103,20 +112,26 @@ struct iPhoneLibraryView: View { } else { ForEach(viewModel.availableOfflineTracks) { track in VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top) { + HStack(alignment: .top, spacing: 12) { + ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath) + VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) - } + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.headline) + Text(track.artist) + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - Button(track.playButtonTitle) { - viewModel.togglePlayback(trackID: track.id) + Button(track.playButtonTitle) { + viewModel.togglePlayback(trackID: track.id) + } + .buttonStyle(.borderedProminent) + } } - .buttonStyle(.borderedProminent) } Text("Duration: \(track.durationText)") @@ -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) + } +} diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 81dcde3..097d85a 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -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 } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 11fce91..058aee4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/modules/artwork/artwork.controller.ts b/backend/src/modules/artwork/artwork.controller.ts new file mode 100644 index 0000000..eae708c --- /dev/null +++ b/backend/src/modules/artwork/artwork.controller.ts @@ -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 { + 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)); + } +} diff --git a/backend/src/modules/artwork/artwork.module.ts b/backend/src/modules/artwork/artwork.module.ts new file mode 100644 index 0000000..21aa927 --- /dev/null +++ b/backend/src/modules/artwork/artwork.module.ts @@ -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 {} diff --git a/backend/src/modules/artwork/artwork.service.spec.ts b/backend/src/modules/artwork/artwork.service.spec.ts new file mode 100644 index 0000000..217add7 --- /dev/null +++ b/backend/src/modules/artwork/artwork.service.spec.ts @@ -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['state']; + +function createPrismaMock() { + const devices = new Map(); + const artworkAssets = new Map(); + + 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); + }); +}); diff --git a/backend/src/modules/artwork/artwork.service.ts b/backend/src/modules/artwork/artwork.service.ts new file mode 100644 index 0000000..2bcdba3 --- /dev/null +++ b/backend/src/modules/artwork/artwork.service.ts @@ -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 { + 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'); + } + } +} diff --git a/backend/src/modules/library/library.dto.ts b/backend/src/modules/library/library.dto.ts index fb1d8a4..c0b9ec7 100644 --- a/backend/src/modules/library/library.dto.ts +++ b/backend/src/modules/library/library.dto.ts @@ -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 { diff --git a/backend/src/modules/library/library.service.spec.ts b/backend/src/modules/library/library.service.spec.ts index db90d15..76ed2e8 100644 --- a/backend/src/modules/library/library.service.spec.ts +++ b/backend/src/modules/library/library.service.spec.ts @@ -9,6 +9,7 @@ function createPrismaMock() { const devices = new Map(); const tracks = new Map(); const audioAssets = new Map(); + const artworkAssets = new Map(); 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, }, ]); }); diff --git a/backend/src/modules/library/library.service.ts b/backend/src/modules/library/library.service.ts index f47cea8..841d405 100644 --- a/backend/src/modules/library/library.service.ts +++ b/backend/src/modules/library/library.service.ts @@ -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, }, ]; }); diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index eff535f..de1c9f0 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -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(); const tracks = new Map(); const audioAssets = new Map(); + const artworkAssets = new Map(); const uploadSessions = new Map(); const libraryEvents = new Map(); 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(); + 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, + }, }, ], }); diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift index e3c5703..dfa5979 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/OfflineLibraryModels.swift @@ -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 } } diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift index 69acb81..2a55487 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift @@ -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 } } diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift index 95b86ba..4a52f50 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift @@ -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 ) } } diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift index 9b25cdf..4d6b85f 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift @@ -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 { diff --git a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift index f657c9a..d8c6f61 100644 --- a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift +++ b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift @@ -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") } } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/ArtworkStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/ArtworkStore.swift new file mode 100644 index 0000000..15e7706 --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/ArtworkStore.swift @@ -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)" + } +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/ArtworkStoreTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/ArtworkStoreTests.swift new file mode 100644 index 0000000..cec9557 --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/ArtworkStoreTests.swift @@ -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) + } +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift b/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift index 84941d1..2987023 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/OfflineLibraryService.swift @@ -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) + } } diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift index 7a6c925..ee976bd 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift @@ -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) + } } diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift index 4b7b9cc..4576cba 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift @@ -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 + } + } + } } diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift index 877dab7..912aa5f 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift @@ -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() } diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift index a1e2162..feee178 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift @@ -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() }