Implement Milestone 7.2 offline audio downloads

This commit is contained in:
diyaa
2026-05-30 07:11:20 +02:00
parent 6e73c1878e
commit 56f030e651
19 changed files with 1822 additions and 31 deletions
+172 -3
View File
@@ -1,11 +1,19 @@
import { randomUUID, createHash } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { Readable } from 'node:stream';
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import {
ForbiddenException,
INestApplication,
NotFoundException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../src/app.module';
import { AssetsController } from '../../src/modules/assets/assets.controller';
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller';
@@ -37,6 +45,18 @@ function createUploadRequest(data: Buffer): any {
return request;
}
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunkValue of stream) {
chunks.push(
Buffer.isBuffer(chunkValue) ? chunkValue : Buffer.from(chunkValue),
);
}
return Buffer.concat(chunks);
}
function createPrismaMock() {
const users = new Map<string, any>();
const devices = new Map<string, any>();
@@ -254,6 +274,7 @@ function createPrismaMock() {
describe('Velody API wiring (e2e)', () => {
let app: INestApplication;
let assetsController: AssetsController;
let healthController: HealthController;
let devicesController: DevicesController;
let libraryController: LibraryController;
@@ -293,6 +314,7 @@ describe('Velody API wiring (e2e)', () => {
);
await app.init();
assetsController = moduleRef.get(AssetsController);
healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController);
libraryController = moduleRef.get(LibraryController);
@@ -344,6 +366,153 @@ describe('Velody API wiring (e2e)', () => {
expect(changesResponse.nextCursor).toBe('0');
});
it('downloads audio asset bytes for the owning device user', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Playback iPhone',
appVersion: '0.1.0',
});
const assetId = randomUUID();
const trackId = randomUUID();
const bytes = sampleMp3Bytes('owner-download');
const storageKey = join(
'users',
prismaState.defaultUser.id,
'audio',
'owner-download.mp3',
);
prismaState.audioAssets.set(assetId, {
id: assetId,
userId: prismaState.defaultUser.id,
trackId,
sha256: sha256Hex(bytes),
storageKey,
originalFilename: 'owner-download.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(bytes.length),
durationMs: 180000,
sourceDeviceId: registerResponse.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
const filePath = join(storageRoot, storageKey);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, bytes);
const headers = new Map<string, string>();
const responseMock = {
setHeader(name: string, value: string) {
headers.set(name.toLowerCase(), String(value));
},
} as any;
const streamable = await assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
responseMock,
);
const downloadedBytes = await streamToBuffer(streamable.getStream());
expect(downloadedBytes.equals(bytes)).toBe(true);
expect(headers.get('content-type')).toBe('audio/mpeg');
expect(headers.get('content-length')).toBe(String(bytes.length));
});
it('rejects unauthorized asset download requests for another user asset', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Playback iPhone',
appVersion: '0.1.0',
});
const assetId = randomUUID();
const otherUserId = randomUUID();
prismaState.audioAssets.set(assetId, {
id: assetId,
userId: otherUserId,
trackId: randomUUID(),
sha256: 'sha-other',
storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
originalFilename: 'other.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(10),
durationMs: 180000,
sourceDeviceId: randomUUID(),
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
await expect(
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('handles missing audio asset files cleanly', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Playback iPhone',
appVersion: '0.1.0',
});
const assetId = randomUUID();
prismaState.audioAssets.set(assetId, {
id: assetId,
userId: prismaState.defaultUser.id,
trackId: randomUUID(),
sha256: 'sha-missing-file',
storageKey: join(
'users',
prismaState.defaultUser.id,
'audio',
'missing-file.mp3',
),
originalFilename: 'missing-file.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(10),
durationMs: 180000,
sourceDeviceId: registerResponse.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
await expect(
assetsController.download(
assetId,
{ deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects an invalid asset download device id query', async () => {
const validationPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
await expect(
validationPipe.transform(
{ deviceId: 'not-a-uuid' },
{
type: 'query',
metatype: AssetDownloadQueryDto,
data: '',
},
),
).rejects.toMatchObject({
response: {
message: expect.arrayContaining(['deviceId must be a UUID']),
},
});
});
it('returns remote library metadata for the requesting device owner', async () => {
const primaryDevice = await devicesController.register({
platform: 'IPHONE',