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