Implement artwork download and cache
This commit is contained in:
parent
8caf29f186
commit
7b1952794c
@ -1,5 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct iPhoneLibraryView: View {
|
struct iPhoneLibraryView: View {
|
||||||
@State private var viewModel = iPhoneLibraryViewModel()
|
@State private var viewModel = iPhoneLibraryViewModel()
|
||||||
@ -37,22 +40,28 @@ struct iPhoneLibraryView: View {
|
|||||||
Section("Remote Library: \(viewModel.remoteTracks.count)") {
|
Section("Remote Library: \(viewModel.remoteTracks.count)") {
|
||||||
ForEach(viewModel.remoteTracks) { track in
|
ForEach(viewModel.remoteTracks) { track in
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(track.title)
|
HStack(alignment: .top) {
|
||||||
.font(.headline)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(track.artist)
|
Text(track.title)
|
||||||
.foregroundStyle(.secondary)
|
.font(.headline)
|
||||||
|
Text(track.artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(track.statusText)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(statusColor(for: track.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)")
|
Text("Duration: \(track.durationText)")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@ -103,20 +112,26 @@ struct iPhoneLibraryView: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.availableOfflineTracks) { track in
|
ForEach(viewModel.availableOfflineTracks) { track in
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(track.title)
|
HStack(alignment: .top) {
|
||||||
.font(.headline)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(track.artist)
|
Text(track.title)
|
||||||
.foregroundStyle(.secondary)
|
.font(.headline)
|
||||||
}
|
Text(track.artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(track.playButtonTitle) {
|
Button(track.playButtonTitle) {
|
||||||
viewModel.togglePlayback(trackID: track.id)
|
viewModel.togglePlayback(trackID: track.id)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Duration: \(track.durationText)")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -211,6 +211,7 @@ final class iPhoneLibraryViewModel {
|
|||||||
let store = Self.makeRemoteLibraryStore()
|
let store = Self.makeRemoteLibraryStore()
|
||||||
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||||
let audioFileStore = Self.makeOfflineAudioFileStore()
|
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||||
|
let artworkStore = Self.makeArtworkStore()
|
||||||
let repository = DefaultRemoteLibraryRepository(
|
let repository = DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: store
|
store: store
|
||||||
@ -218,7 +219,8 @@ final class iPhoneLibraryViewModel {
|
|||||||
let syncService = RemoteLibrarySyncService(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
|
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@ -228,7 +230,8 @@ final class iPhoneLibraryViewModel {
|
|||||||
self.syncService = syncService
|
self.syncService = syncService
|
||||||
self.offlineLibraryService = OfflineLibraryService(
|
self.offlineLibraryService = OfflineLibraryService(
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
self.player.onStateChange = { [weak self] state in
|
self.player.onStateChange = { [weak self] state in
|
||||||
self?.handleNowPlayingStateChange(state)
|
self?.handleNowPlayingStateChange(state)
|
||||||
@ -402,6 +405,14 @@ final class iPhoneLibraryViewModel {
|
|||||||
return InMemoryOfflineAudioFileStore()
|
return InMemoryOfflineAudioFileStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeArtworkStore() -> any ArtworkStore {
|
||||||
|
if let store = try? FileArtworkStore() {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
return InMemoryArtworkStore()
|
||||||
|
}
|
||||||
|
|
||||||
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
|
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
|
||||||
let snapshot = try await offlineLibraryService.loadSnapshot()
|
let snapshot = try await offlineLibraryService.loadSnapshot()
|
||||||
|
|
||||||
@ -484,6 +495,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|||||||
let canPlay: Bool
|
let canPlay: Bool
|
||||||
let playButtonTitle: String
|
let playButtonTitle: String
|
||||||
let lastDownloadError: String?
|
let lastDownloadError: String?
|
||||||
|
let artworkLocalFilePath: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
track: OfflineLibraryRemoteTrack,
|
track: OfflineLibraryRemoteTrack,
|
||||||
@ -504,6 +516,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|||||||
? "Pause"
|
? "Pause"
|
||||||
: "Play"
|
: "Play"
|
||||||
lastDownloadError = track.lastDownloadError
|
lastDownloadError = track.lastDownloadError
|
||||||
|
artworkLocalFilePath = track.localArtworkFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
static func formatDuration(seconds: Int) -> String {
|
static func formatDuration(seconds: Int) -> String {
|
||||||
@ -549,6 +562,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
|
|||||||
let remoteTrackID: String
|
let remoteTrackID: String
|
||||||
let assetID: String
|
let assetID: String
|
||||||
let playButtonTitle: String
|
let playButtonTitle: String
|
||||||
|
let artworkLocalFilePath: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
track: OfflineLibraryTrack,
|
track: OfflineLibraryTrack,
|
||||||
@ -563,5 +577,6 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
|
|||||||
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
|
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
|
||||||
? "Pause"
|
? "Pause"
|
||||||
: "Play"
|
: "Play"
|
||||||
|
artworkLocalFilePath = track.localArtworkFilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AssetsModule } from './modules/assets/assets.module';
|
import { AssetsModule } from './modules/assets/assets.module';
|
||||||
|
import { ArtworkModule } from './modules/artwork/artwork.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DevicesModule } from './modules/devices/devices.module';
|
import { DevicesModule } from './modules/devices/devices.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
@ -11,6 +12,7 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
|||||||
imports: [
|
imports: [
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
AssetsModule,
|
AssetsModule,
|
||||||
|
ArtworkModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
DevicesModule,
|
DevicesModule,
|
||||||
UploadsModule,
|
UploadsModule,
|
||||||
|
|||||||
46
backend/src/modules/artwork/artwork.controller.ts
Normal file
46
backend/src/modules/artwork/artwork.controller.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/modules/artwork/artwork.module.ts
Normal file
12
backend/src/modules/artwork/artwork.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { ArtworkController } from './artwork.controller';
|
||||||
|
import { ArtworkService } from './artwork.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, StorageModule],
|
||||||
|
controllers: [ArtworkController],
|
||||||
|
providers: [ArtworkService],
|
||||||
|
})
|
||||||
|
export class ArtworkModule {}
|
||||||
141
backend/src/modules/artwork/artwork.service.spec.ts
Normal file
141
backend/src/modules/artwork/artwork.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
backend/src/modules/artwork/artwork.service.ts
Normal file
81
backend/src/modules/artwork/artwork.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,26 @@ export class LibraryTracksQueryDto {
|
|||||||
deviceId!: string;
|
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 {
|
export class RemoteLibraryTrackDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
trackId!: string;
|
trackId!: string;
|
||||||
@ -34,6 +54,13 @@ export class RemoteLibraryTrackDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: '2026-05-29T08:05:00.000Z' })
|
@ApiProperty({ example: '2026-05-29T08:05:00.000Z' })
|
||||||
updatedAt!: string;
|
updatedAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: RemoteArtworkDto,
|
||||||
|
required: false,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
artwork!: RemoteArtworkDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LibraryTracksResponseDto {
|
export class LibraryTracksResponseDto {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ function createPrismaMock() {
|
|||||||
const devices = new Map<string, any>();
|
const devices = new Map<string, any>();
|
||||||
const tracks = new Map<string, any>();
|
const tracks = new Map<string, any>();
|
||||||
const audioAssets = new Map<string, any>();
|
const audioAssets = new Map<string, any>();
|
||||||
|
const artworkAssets = new Map<string, any>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prismaMock: {
|
prismaMock: {
|
||||||
@ -35,6 +36,9 @@ function createPrismaMock() {
|
|||||||
primaryAudioAsset: track.primaryAudioAssetId
|
primaryAudioAsset: track.primaryAudioAssetId
|
||||||
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
||||||
: null,
|
: null,
|
||||||
|
artworkAsset: track.artworkAssetId
|
||||||
|
? artworkAssets.get(track.artworkAssetId) ?? null
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -43,6 +47,7 @@ function createPrismaMock() {
|
|||||||
devices,
|
devices,
|
||||||
tracks,
|
tracks,
|
||||||
audioAssets,
|
audioAssets,
|
||||||
|
artworkAssets,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -78,6 +83,7 @@ describe('LibraryService', () => {
|
|||||||
const ownerDeviceId = randomUUID();
|
const ownerDeviceId = randomUUID();
|
||||||
const ownerTrackId = randomUUID();
|
const ownerTrackId = randomUUID();
|
||||||
const ownerAssetId = randomUUID();
|
const ownerAssetId = randomUUID();
|
||||||
|
const ownerArtworkId = randomUUID();
|
||||||
const secondOwnerTrackId = randomUUID();
|
const secondOwnerTrackId = randomUUID();
|
||||||
const secondOwnerAssetId = randomUUID();
|
const secondOwnerAssetId = randomUUID();
|
||||||
const otherTrackId = randomUUID();
|
const otherTrackId = randomUUID();
|
||||||
@ -90,6 +96,13 @@ describe('LibraryService', () => {
|
|||||||
sha256: 'sha-owner-a',
|
sha256: 'sha-owner-a',
|
||||||
durationMs: 181000,
|
durationMs: 181000,
|
||||||
});
|
});
|
||||||
|
state.artworkAssets.set(ownerArtworkId, {
|
||||||
|
id: ownerArtworkId,
|
||||||
|
sha256: 'artwork-sha-owner-a',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
width: 600,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
state.audioAssets.set(secondOwnerAssetId, {
|
state.audioAssets.set(secondOwnerAssetId, {
|
||||||
id: secondOwnerAssetId,
|
id: secondOwnerAssetId,
|
||||||
sha256: 'sha-owner-b',
|
sha256: 'sha-owner-b',
|
||||||
@ -120,6 +133,7 @@ describe('LibraryService', () => {
|
|||||||
durationMs: 181000,
|
durationMs: 181000,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
primaryAudioAssetId: ownerAssetId,
|
primaryAudioAssetId: ownerAssetId,
|
||||||
|
artworkAssetId: ownerArtworkId,
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
});
|
});
|
||||||
@ -147,6 +161,13 @@ describe('LibraryService', () => {
|
|||||||
assetId: ownerAssetId,
|
assetId: ownerAssetId,
|
||||||
createdAt: '2026-05-29T08:00:00.000Z',
|
createdAt: '2026-05-29T08:00:00.000Z',
|
||||||
updatedAt: '2026-05-29T08:01: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,
|
trackId: secondOwnerTrackId,
|
||||||
@ -157,6 +178,7 @@ describe('LibraryService', () => {
|
|||||||
assetId: secondOwnerAssetId,
|
assetId: secondOwnerAssetId,
|
||||||
createdAt: '2026-05-29T08:05:00.000Z',
|
createdAt: '2026-05-29T08:05:00.000Z',
|
||||||
updatedAt: '2026-05-29T08:06:00.000Z',
|
updatedAt: '2026-05-29T08:06:00.000Z',
|
||||||
|
artwork: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -74,6 +74,15 @@ export class LibraryService {
|
|||||||
durationMs: true,
|
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,
|
assetId: track.primaryAudioAsset.id,
|
||||||
createdAt: track.createdAt.toISOString(),
|
createdAt: track.createdAt.toISOString(),
|
||||||
updatedAt: track.updatedAt.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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { Test } from '@nestjs/testing';
|
|||||||
import { AppModule } from '../../src/app.module';
|
import { AppModule } from '../../src/app.module';
|
||||||
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||||
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
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 { AppConfigService } from '../../src/modules/config/config.service';
|
||||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||||
import { HealthController } from '../../src/modules/health/health.controller';
|
import { HealthController } from '../../src/modules/health/health.controller';
|
||||||
@ -62,6 +63,7 @@ function createPrismaMock() {
|
|||||||
const devices = new Map<string, any>();
|
const devices = new Map<string, any>();
|
||||||
const tracks = new Map<string, any>();
|
const tracks = new Map<string, any>();
|
||||||
const audioAssets = new Map<string, any>();
|
const audioAssets = new Map<string, any>();
|
||||||
|
const artworkAssets = new Map<string, any>();
|
||||||
const uploadSessions = new Map<string, any>();
|
const uploadSessions = new Map<string, any>();
|
||||||
const libraryEvents = new Map<bigint, any>();
|
const libraryEvents = new Map<bigint, any>();
|
||||||
let nextLibraryEventId = 1n;
|
let nextLibraryEventId = 1n;
|
||||||
@ -133,6 +135,9 @@ function createPrismaMock() {
|
|||||||
primaryAudioAsset: track.primaryAudioAssetId
|
primaryAudioAsset: track.primaryAudioAssetId
|
||||||
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
||||||
: null,
|
: null,
|
||||||
|
artworkAsset: track.artworkAssetId
|
||||||
|
? artworkAssets.get(track.artworkAssetId) ?? null
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
@ -209,6 +214,11 @@ function createPrismaMock() {
|
|||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
artworkAsset: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return artworkAssets.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
uploadSession: {
|
uploadSession: {
|
||||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -266,6 +276,7 @@ function createPrismaMock() {
|
|||||||
devices,
|
devices,
|
||||||
tracks,
|
tracks,
|
||||||
audioAssets,
|
audioAssets,
|
||||||
|
artworkAssets,
|
||||||
uploadSessions,
|
uploadSessions,
|
||||||
libraryEvents,
|
libraryEvents,
|
||||||
},
|
},
|
||||||
@ -275,6 +286,7 @@ function createPrismaMock() {
|
|||||||
describe('Velody API wiring (e2e)', () => {
|
describe('Velody API wiring (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let assetsController: AssetsController;
|
let assetsController: AssetsController;
|
||||||
|
let artworkController: ArtworkController;
|
||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
let devicesController: DevicesController;
|
let devicesController: DevicesController;
|
||||||
let libraryController: LibraryController;
|
let libraryController: LibraryController;
|
||||||
@ -315,6 +327,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
assetsController = moduleRef.get(AssetsController);
|
assetsController = moduleRef.get(AssetsController);
|
||||||
|
artworkController = moduleRef.get(ArtworkController);
|
||||||
healthController = moduleRef.get(HealthController);
|
healthController = moduleRef.get(HealthController);
|
||||||
devicesController = moduleRef.get(DevicesController);
|
devicesController = moduleRef.get(DevicesController);
|
||||||
libraryController = moduleRef.get(LibraryController);
|
libraryController = moduleRef.get(LibraryController);
|
||||||
@ -490,6 +503,107 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).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 () => {
|
it('rejects an invalid asset download device id query', async () => {
|
||||||
const validationPipe = new ValidationPipe({
|
const validationPipe = new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
@ -523,6 +637,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
const secondaryDeviceId = randomUUID();
|
const secondaryDeviceId = randomUUID();
|
||||||
const primaryTrackId = randomUUID();
|
const primaryTrackId = randomUUID();
|
||||||
const primaryAssetId = randomUUID();
|
const primaryAssetId = randomUUID();
|
||||||
|
const primaryArtworkId = randomUUID();
|
||||||
const secondaryTrackId = randomUUID();
|
const secondaryTrackId = randomUUID();
|
||||||
const secondaryAssetId = randomUUID();
|
const secondaryAssetId = randomUUID();
|
||||||
|
|
||||||
@ -552,6 +667,16 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
sourceDeviceId: primaryDevice.deviceId,
|
sourceDeviceId: primaryDevice.deviceId,
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
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, {
|
prismaState.audioAssets.set(secondaryAssetId, {
|
||||||
id: secondaryAssetId,
|
id: secondaryAssetId,
|
||||||
userId: secondUserId,
|
userId: secondUserId,
|
||||||
@ -571,7 +696,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
id: primaryTrackId,
|
id: primaryTrackId,
|
||||||
userId: prismaState.defaultUser.id,
|
userId: prismaState.defaultUser.id,
|
||||||
primaryAudioAssetId: primaryAssetId,
|
primaryAudioAssetId: primaryAssetId,
|
||||||
artworkAssetId: null,
|
artworkAssetId: primaryArtworkId,
|
||||||
title: 'Default User Track',
|
title: 'Default User Track',
|
||||||
artist: 'Velody',
|
artist: 'Velody',
|
||||||
album: null,
|
album: null,
|
||||||
@ -621,6 +746,13 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
assetId: primaryAssetId,
|
assetId: primaryAssetId,
|
||||||
createdAt: '2026-05-29T08:00:00.000Z',
|
createdAt: '2026-05-29T08:00:00.000Z',
|
||||||
updatedAt: '2026-05-29T08:02:00.000Z',
|
updatedAt: '2026-05-29T08:02:00.000Z',
|
||||||
|
artwork: {
|
||||||
|
artworkId: primaryArtworkId,
|
||||||
|
sha256: 'artwork-sha-default',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
|
|||||||
public var localFilePath: String
|
public var localFilePath: String
|
||||||
public var downloadedAt: Date?
|
public var downloadedAt: Date?
|
||||||
public var isFileAvailable: Bool
|
public var isFileAvailable: Bool
|
||||||
|
public var localArtworkFilePath: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
remoteTrackId: String,
|
remoteTrackId: String,
|
||||||
@ -22,7 +23,8 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
|
|||||||
sha256: String,
|
sha256: String,
|
||||||
localFilePath: String,
|
localFilePath: String,
|
||||||
downloadedAt: Date?,
|
downloadedAt: Date?,
|
||||||
isFileAvailable: Bool
|
isFileAvailable: Bool,
|
||||||
|
localArtworkFilePath: String? = nil
|
||||||
) {
|
) {
|
||||||
self.remoteTrackId = remoteTrackId
|
self.remoteTrackId = remoteTrackId
|
||||||
self.assetId = assetId
|
self.assetId = assetId
|
||||||
@ -33,6 +35,7 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
|
|||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
self.downloadedAt = downloadedAt
|
self.downloadedAt = downloadedAt
|
||||||
self.isFileAvailable = isFileAvailable
|
self.isFileAvailable = isFileAvailable
|
||||||
|
self.localArtworkFilePath = localArtworkFilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +56,7 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
|
|||||||
public var isFileAvailable: Bool
|
public var isFileAvailable: Bool
|
||||||
public var status: OfflineLibraryRemoteTrackStatus
|
public var status: OfflineLibraryRemoteTrackStatus
|
||||||
public var lastDownloadError: String?
|
public var lastDownloadError: String?
|
||||||
|
public var localArtworkFilePath: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
remoteTrack: RemoteTrack,
|
remoteTrack: RemoteTrack,
|
||||||
@ -60,7 +64,8 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
|
|||||||
downloadedAt: Date?,
|
downloadedAt: Date?,
|
||||||
isFileAvailable: Bool,
|
isFileAvailable: Bool,
|
||||||
status: OfflineLibraryRemoteTrackStatus,
|
status: OfflineLibraryRemoteTrackStatus,
|
||||||
lastDownloadError: String?
|
lastDownloadError: String?,
|
||||||
|
localArtworkFilePath: String? = nil
|
||||||
) {
|
) {
|
||||||
self.remoteTrack = remoteTrack
|
self.remoteTrack = remoteTrack
|
||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
@ -68,6 +73,7 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
|
|||||||
self.isFileAvailable = isFileAvailable
|
self.isFileAvailable = isFileAvailable
|
||||||
self.status = status
|
self.status = status
|
||||||
self.lastDownloadError = lastDownloadError
|
self.lastDownloadError = lastDownloadError
|
||||||
|
self.localArtworkFilePath = localArtworkFilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,27 @@
|
|||||||
import Foundation
|
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 struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
||||||
public var id: String { trackId }
|
public var id: String { trackId }
|
||||||
|
|
||||||
@ -11,6 +33,7 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
public var assetId: String
|
public var assetId: String
|
||||||
public var createdAt: String
|
public var createdAt: String
|
||||||
public var updatedAt: String
|
public var updatedAt: String
|
||||||
|
public var artwork: RemoteArtwork?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
@ -20,7 +43,8 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
sha256: String,
|
sha256: String,
|
||||||
assetId: String,
|
assetId: String,
|
||||||
createdAt: String,
|
createdAt: String,
|
||||||
updatedAt: String
|
updatedAt: String,
|
||||||
|
artwork: RemoteArtwork? = nil
|
||||||
) {
|
) {
|
||||||
self.trackId = trackId
|
self.trackId = trackId
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -30,5 +54,6 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
self.assetId = assetId
|
self.assetId = assetId
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
|
self.artwork = artwork
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,38 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import VelodyDomain
|
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 struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
||||||
public var trackId: String
|
public var trackId: String
|
||||||
public var title: String
|
public var title: String
|
||||||
@ -10,6 +42,7 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
|||||||
public var assetId: String
|
public var assetId: String
|
||||||
public var createdAt: String
|
public var createdAt: String
|
||||||
public var updatedAt: String
|
public var updatedAt: String
|
||||||
|
public var artwork: RemoteArtworkDTO?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
@ -19,7 +52,8 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
|||||||
sha256: String,
|
sha256: String,
|
||||||
assetId: String,
|
assetId: String,
|
||||||
createdAt: String,
|
createdAt: String,
|
||||||
updatedAt: String
|
updatedAt: String,
|
||||||
|
artwork: RemoteArtworkDTO? = nil
|
||||||
) {
|
) {
|
||||||
self.trackId = trackId
|
self.trackId = trackId
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -29,6 +63,7 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
|||||||
self.assetId = assetId
|
self.assetId = assetId
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
|
self.artwork = artwork
|
||||||
}
|
}
|
||||||
|
|
||||||
public var remoteTrack: RemoteTrack {
|
public var remoteTrack: RemoteTrack {
|
||||||
@ -40,7 +75,8 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
|||||||
sha256: sha256,
|
sha256: sha256,
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt
|
updatedAt: updatedAt,
|
||||||
|
artwork: artwork?.remoteArtwork
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,11 @@ public protocol VelodyAPIClient: Sendable {
|
|||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> Data
|
) async throws -> Data
|
||||||
|
|
||||||
|
func downloadArtwork(
|
||||||
|
artworkId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data
|
||||||
|
|
||||||
func prepareUpload(
|
func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse
|
) async throws -> UploadPrepareResponse
|
||||||
@ -145,6 +150,23 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
return try await executeData(request)
|
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(
|
public func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) 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(
|
public func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import VelodyDomain
|
||||||
@testable import VelodyNetworking
|
@testable import VelodyNetworking
|
||||||
|
|
||||||
final class RemoteLibraryDTOTests: XCTestCase {
|
final class RemoteLibraryDTOTests: XCTestCase {
|
||||||
@ -12,7 +13,14 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
|||||||
sha256: String(repeating: "a", count: 64),
|
sha256: String(repeating: "a", count: 64),
|
||||||
assetId: "asset-456",
|
assetId: "asset-456",
|
||||||
createdAt: "2026-05-29T08:00:00.000Z",
|
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
|
let track = dto.remoteTrack
|
||||||
@ -23,6 +31,16 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
|||||||
XCTAssertEqual(track.durationSeconds, 245)
|
XCTAssertEqual(track.durationSeconds, 245)
|
||||||
XCTAssertEqual(track.sha256, String(repeating: "a", count: 64))
|
XCTAssertEqual(track.sha256, String(repeating: "a", count: 64))
|
||||||
XCTAssertEqual(track.assetId, "asset-456")
|
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 {
|
func testRemoteLibraryResponseDTODecodesFromAPIResponse() throws {
|
||||||
@ -38,7 +56,14 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
|||||||
"sha256": "\(String(repeating: "a", count: 64))",
|
"sha256": "\(String(repeating: "a", count: 64))",
|
||||||
"assetId": "asset-456",
|
"assetId": "asset-456",
|
||||||
"createdAt": "2026-05-29T08:00:00.000Z",
|
"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.count, 1)
|
||||||
XCTAssertEqual(decoded.tracks.first?.trackId, "track-123")
|
XCTAssertEqual(decoded.tracks.first?.trackId, "track-123")
|
||||||
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
|
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
|
||||||
|
XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,13 +5,16 @@ import VelodyPersistence
|
|||||||
public actor OfflineLibraryService {
|
public actor OfflineLibraryService {
|
||||||
private let syncService: RemoteLibrarySyncService
|
private let syncService: RemoteLibrarySyncService
|
||||||
private let audioFileStore: any OfflineAudioFileStore
|
private let audioFileStore: any OfflineAudioFileStore
|
||||||
|
private let artworkStore: any ArtworkStore
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
syncService: RemoteLibrarySyncService,
|
syncService: RemoteLibrarySyncService,
|
||||||
audioFileStore: any OfflineAudioFileStore
|
audioFileStore: any OfflineAudioFileStore,
|
||||||
|
artworkStore: any ArtworkStore
|
||||||
) {
|
) {
|
||||||
self.syncService = syncService
|
self.syncService = syncService
|
||||||
self.audioFileStore = audioFileStore
|
self.audioFileStore = audioFileStore
|
||||||
|
self.artworkStore = artworkStore
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadSnapshot() async throws -> OfflineLibrarySnapshot {
|
public func loadSnapshot() async throws -> OfflineLibrarySnapshot {
|
||||||
@ -37,6 +40,10 @@ public actor OfflineLibraryService {
|
|||||||
for: downloadState,
|
for: downloadState,
|
||||||
status: status
|
status: status
|
||||||
)
|
)
|
||||||
|
let localArtworkFilePath = await Self.localArtworkFilePath(
|
||||||
|
for: remoteTrack.artwork,
|
||||||
|
artworkStore: artworkStore
|
||||||
|
)
|
||||||
|
|
||||||
remoteLibraryTracks.append(
|
remoteLibraryTracks.append(
|
||||||
OfflineLibraryRemoteTrack(
|
OfflineLibraryRemoteTrack(
|
||||||
@ -45,7 +52,8 @@ public actor OfflineLibraryService {
|
|||||||
downloadedAt: downloadedAt,
|
downloadedAt: downloadedAt,
|
||||||
isFileAvailable: isFileAvailable,
|
isFileAvailable: isFileAvailable,
|
||||||
status: status,
|
status: status,
|
||||||
lastDownloadError: lastDownloadError
|
lastDownloadError: lastDownloadError,
|
||||||
|
localArtworkFilePath: localArtworkFilePath
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,7 +71,8 @@ public actor OfflineLibraryService {
|
|||||||
sha256: remoteTrack.sha256,
|
sha256: remoteTrack.sha256,
|
||||||
localFilePath: localFilePath,
|
localFilePath: localFilePath,
|
||||||
downloadedAt: downloadedAt,
|
downloadedAt: downloadedAt,
|
||||||
isFileAvailable: true
|
isFileAvailable: true,
|
||||||
|
localArtworkFilePath: localArtworkFilePath
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -118,4 +127,15 @@ public actor OfflineLibraryService {
|
|||||||
|
|
||||||
return downloadState?.lastDownloadError
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ public protocol RemoteLibraryRepository: Actor {
|
|||||||
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
|
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
|
||||||
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
|
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
|
||||||
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data
|
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data
|
||||||
|
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||||
@ -38,4 +39,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
|||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
try await apiClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,18 @@ public actor RemoteLibrarySyncService {
|
|||||||
private let repository: any RemoteLibraryRepository
|
private let repository: any RemoteLibraryRepository
|
||||||
private let downloadStateStore: any RemoteTrackDownloadStateStore
|
private let downloadStateStore: any RemoteTrackDownloadStateStore
|
||||||
private let audioFileStore: any OfflineAudioFileStore
|
private let audioFileStore: any OfflineAudioFileStore
|
||||||
|
private let artworkStore: any ArtworkStore
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
repository: any RemoteLibraryRepository,
|
repository: any RemoteLibraryRepository,
|
||||||
downloadStateStore: any RemoteTrackDownloadStateStore,
|
downloadStateStore: any RemoteTrackDownloadStateStore,
|
||||||
audioFileStore: any OfflineAudioFileStore
|
audioFileStore: any OfflineAudioFileStore,
|
||||||
|
artworkStore: any ArtworkStore
|
||||||
) {
|
) {
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.downloadStateStore = downloadStateStore
|
self.downloadStateStore = downloadStateStore
|
||||||
self.audioFileStore = audioFileStore
|
self.audioFileStore = audioFileStore
|
||||||
|
self.artworkStore = artworkStore
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||||
@ -29,6 +32,7 @@ public actor RemoteLibrarySyncService {
|
|||||||
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
|
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
|
||||||
let tracks = try await repository.syncRemoteTracks(deviceId: deviceId)
|
let tracks = try await repository.syncRemoteTracks(deviceId: deviceId)
|
||||||
try await ensureDownloadStates(for: tracks)
|
try await ensureDownloadStates(for: tracks)
|
||||||
|
await cacheArtwork(for: tracks, deviceId: deviceId)
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,4 +185,29 @@ public actor RemoteLibrarySyncService {
|
|||||||
|
|
||||||
return reconciledStates
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,11 +141,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let syncService = RemoteLibrarySyncService(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: relaunchedAudioStore
|
audioFileStore: relaunchedAudioStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
let offlineLibraryService = OfflineLibraryService(
|
let offlineLibraryService = OfflineLibraryService(
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
audioFileStore: relaunchedAudioStore
|
audioFileStore: relaunchedAudioStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
let snapshot = try await offlineLibraryService.loadSnapshot()
|
let snapshot = try await offlineLibraryService.loadSnapshot()
|
||||||
@ -177,11 +179,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
store: remoteLibraryStore
|
store: remoteLibraryStore
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
let offlineLibraryService = OfflineLibraryService(
|
let offlineLibraryService = OfflineLibraryService(
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
@ -212,10 +216,12 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
isDirectory: true
|
isDirectory: true
|
||||||
)
|
)
|
||||||
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
||||||
|
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
|
||||||
let track = makeRemoteTrack(
|
let track = makeRemoteTrack(
|
||||||
trackId: "track-sync",
|
trackId: "track-sync",
|
||||||
assetId: "asset-sync",
|
assetId: "asset-sync",
|
||||||
title: "Sync Safe"
|
title: "Sync Safe",
|
||||||
|
artworkId: "artwork-sync"
|
||||||
)
|
)
|
||||||
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
||||||
let audioData = sampleMp3Data(seed: track.assetId)
|
let audioData = sampleMp3Data(seed: track.assetId)
|
||||||
@ -223,21 +229,27 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||||
tracks: [makeRemoteTrackDTO(from: track)]
|
tracks: [makeRemoteTrackDTO(from: track)]
|
||||||
),
|
),
|
||||||
audioAssetData: audioData
|
audioAssetData: audioData,
|
||||||
|
artworkDataByArtworkID: [
|
||||||
|
"artwork-sync": sampleArtworkData(),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||||
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||||
|
let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||||
let syncService = RemoteLibrarySyncService(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: remoteLibraryStore
|
store: remoteLibraryStore
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
let offlineLibraryService = OfflineLibraryService(
|
let offlineLibraryService = OfflineLibraryService(
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
@ -254,6 +266,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId])
|
XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId])
|
||||||
XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId])
|
XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId])
|
||||||
XCTAssertEqual(afterResync.remoteTracks.first?.status, .downloaded)
|
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 {
|
func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws {
|
||||||
@ -266,8 +280,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||||
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
||||||
let tracks = [
|
let tracks = [
|
||||||
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1"),
|
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1", artworkId: "artwork-1"),
|
||||||
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2"),
|
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
|
||||||
]
|
]
|
||||||
let apiClient = OfflineLibraryMockAPIClient(
|
let apiClient = OfflineLibraryMockAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||||
@ -276,8 +290,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
audioAssetDataByAssetID: [
|
audioAssetDataByAssetID: [
|
||||||
"asset-1": sampleMp3Data(seed: "asset-1"),
|
"asset-1": sampleMp3Data(seed: "asset-1"),
|
||||||
"asset-2": sampleMp3Data(seed: "asset-2"),
|
"asset-2": sampleMp3Data(seed: "asset-2"),
|
||||||
|
],
|
||||||
|
artworkDataByArtworkID: [
|
||||||
|
"artwork-1": sampleArtworkData(),
|
||||||
|
"artwork-2": sampleArtworkData(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
try? fileManager.removeItem(at: tempDirectory)
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
@ -292,11 +311,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let firstSyncService = RemoteLibrarySyncService(
|
let firstSyncService = RemoteLibrarySyncService(
|
||||||
repository: firstRepository,
|
repository: firstRepository,
|
||||||
downloadStateStore: firstDownloadStateStore,
|
downloadStateStore: firstDownloadStateStore,
|
||||||
audioFileStore: firstAudioStore
|
audioFileStore: firstAudioStore,
|
||||||
|
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||||
)
|
)
|
||||||
let firstOfflineLibraryService = OfflineLibraryService(
|
let firstOfflineLibraryService = OfflineLibraryService(
|
||||||
syncService: firstSyncService,
|
syncService: firstSyncService,
|
||||||
audioFileStore: firstAudioStore
|
audioFileStore: firstAudioStore,
|
||||||
|
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123")
|
_ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123")
|
||||||
@ -315,11 +336,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let relaunchedSyncService = RemoteLibrarySyncService(
|
let relaunchedSyncService = RemoteLibrarySyncService(
|
||||||
repository: relaunchedRepository,
|
repository: relaunchedRepository,
|
||||||
downloadStateStore: relaunchedDownloadStateStore,
|
downloadStateStore: relaunchedDownloadStateStore,
|
||||||
audioFileStore: relaunchedAudioStore
|
audioFileStore: relaunchedAudioStore,
|
||||||
|
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||||
)
|
)
|
||||||
let relaunchedOfflineLibraryService = OfflineLibraryService(
|
let relaunchedOfflineLibraryService = OfflineLibraryService(
|
||||||
syncService: relaunchedSyncService,
|
syncService: relaunchedSyncService,
|
||||||
audioFileStore: relaunchedAudioStore
|
audioFileStore: relaunchedAudioStore,
|
||||||
|
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||||
)
|
)
|
||||||
|
|
||||||
let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot()
|
let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot()
|
||||||
@ -327,6 +350,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
|
XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
|
||||||
XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
|
XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
|
||||||
XCTAssertEqual(afterRelaunch.remoteTracks.map(\.status), [.downloaded, .downloaded])
|
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(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks),
|
repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks),
|
||||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates),
|
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates),
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
return OfflineLibraryService(
|
return OfflineLibraryService(
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,21 +395,30 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
|
|||||||
_ = deviceId
|
_ = deviceId
|
||||||
return Data()
|
return Data()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
|
||||||
|
_ = artworkId
|
||||||
|
_ = deviceId
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||||
let audioAssetData: Data?
|
let audioAssetData: Data?
|
||||||
let audioAssetDataByAssetID: [String: Data]
|
let audioAssetDataByAssetID: [String: Data]
|
||||||
|
let artworkDataByArtworkID: [String: Data]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||||
audioAssetData: Data? = nil,
|
audioAssetData: Data? = nil,
|
||||||
audioAssetDataByAssetID: [String: Data] = [:]
|
audioAssetDataByAssetID: [String: Data] = [:],
|
||||||
|
artworkDataByArtworkID: [String: Data] = [:]
|
||||||
) {
|
) {
|
||||||
self.remoteLibraryResponse = remoteLibraryResponse
|
self.remoteLibraryResponse = remoteLibraryResponse
|
||||||
self.audioAssetData = audioAssetData
|
self.audioAssetData = audioAssetData
|
||||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||||
|
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDevice(
|
func registerDevice(
|
||||||
@ -431,6 +467,14 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data()
|
return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadArtwork(
|
||||||
|
artworkId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data {
|
||||||
|
_ = deviceId
|
||||||
|
return artworkDataByArtworkID[artworkId] ?? Data()
|
||||||
|
}
|
||||||
|
|
||||||
func prepareUpload(
|
func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
@ -483,7 +527,8 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
private func makeRemoteTrack(
|
private func makeRemoteTrack(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
assetId: String,
|
assetId: String,
|
||||||
title: String
|
title: String,
|
||||||
|
artworkId: String? = nil
|
||||||
) -> RemoteTrack {
|
) -> RemoteTrack {
|
||||||
let bytes = sampleMp3Data(seed: assetId)
|
let bytes = sampleMp3Data(seed: assetId)
|
||||||
|
|
||||||
@ -495,7 +540,16 @@ private func makeRemoteTrack(
|
|||||||
sha256: sha256Hex(bytes),
|
sha256: sha256Hex(bytes),
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
createdAt: "2026-05-30T08:00:00.000Z",
|
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,
|
sha256: track.sha256,
|
||||||
assetId: track.assetId,
|
assetId: track.assetId,
|
||||||
createdAt: track.createdAt,
|
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))
|
] + Array(seed.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sampleArtworkData() -> Data {
|
||||||
|
Data(
|
||||||
|
base64Encoded:
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
private func sha256Hex(_ data: Data) -> String {
|
||||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: store
|
store: store
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||||
@ -76,7 +77,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: store
|
store: store
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||||
@ -109,7 +111,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: store
|
store: store
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
await XCTAssertThrowsErrorAsync {
|
await XCTAssertThrowsErrorAsync {
|
||||||
@ -132,7 +135,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
let track = RemoteTrack(
|
let track = RemoteTrack(
|
||||||
trackId: "track-123",
|
trackId: "track-123",
|
||||||
@ -166,7 +170,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
let track = RemoteTrack(
|
let track = RemoteTrack(
|
||||||
trackId: "track-123",
|
trackId: "track-123",
|
||||||
@ -222,7 +227,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
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")
|
let originalState = try await firstService.downloadTrack(track, deviceId: "device-123")
|
||||||
@ -238,7 +244,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||||
audioFileStore: relaunchedAudioStore
|
audioFileStore: relaunchedAudioStore,
|
||||||
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
|
|
||||||
let restoredStates = try await relaunchedService.loadDownloadStates()
|
let restoredStates = try await relaunchedService.loadDownloadStates()
|
||||||
@ -254,6 +261,58 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
||||||
XCTAssertEqual(restoredBytes, audioData)
|
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 {
|
private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||||
@ -261,17 +320,23 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
let remoteLibraryError: VelodyAPIError?
|
let remoteLibraryError: VelodyAPIError?
|
||||||
let audioAssetData: Data?
|
let audioAssetData: Data?
|
||||||
let downloadError: VelodyAPIError?
|
let downloadError: VelodyAPIError?
|
||||||
|
let artworkData: Data?
|
||||||
|
let artworkDownloadError: VelodyAPIError?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||||
remoteLibraryError: VelodyAPIError? = nil,
|
remoteLibraryError: VelodyAPIError? = nil,
|
||||||
audioAssetData: Data? = nil,
|
audioAssetData: Data? = nil,
|
||||||
downloadError: VelodyAPIError? = nil
|
downloadError: VelodyAPIError? = nil,
|
||||||
|
artworkData: Data? = nil,
|
||||||
|
artworkDownloadError: VelodyAPIError? = nil
|
||||||
) {
|
) {
|
||||||
self.remoteLibraryResponse = remoteLibraryResponse
|
self.remoteLibraryResponse = remoteLibraryResponse
|
||||||
self.remoteLibraryError = remoteLibraryError
|
self.remoteLibraryError = remoteLibraryError
|
||||||
self.audioAssetData = audioAssetData
|
self.audioAssetData = audioAssetData
|
||||||
self.downloadError = downloadError
|
self.downloadError = downloadError
|
||||||
|
self.artworkData = artworkData
|
||||||
|
self.artworkDownloadError = artworkDownloadError
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDevice(
|
func registerDevice(
|
||||||
@ -331,6 +396,20 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
return audioAssetData ?? Data()
|
return audioAssetData ?? Data()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadArtwork(
|
||||||
|
artworkId: String,
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> Data {
|
||||||
|
_ = artworkId
|
||||||
|
_ = deviceId
|
||||||
|
|
||||||
|
if let artworkDownloadError {
|
||||||
|
throw artworkDownloadError
|
||||||
|
}
|
||||||
|
|
||||||
|
return artworkData ?? Data()
|
||||||
|
}
|
||||||
|
|
||||||
func prepareUpload(
|
func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
@ -385,6 +464,13 @@ private func sampleMp3Data(seed: String) -> Data {
|
|||||||
] + Array(seed.utf8))
|
] + Array(seed.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sampleArtworkData() -> Data {
|
||||||
|
Data(
|
||||||
|
base64Encoded:
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
private func sha256Hex(_ data: Data) -> String {
|
||||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user