Implement Milestone 7.2 offline audio downloads
This commit is contained in:
@@ -1,6 +1,46 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/api/v1/assets/{assetId}/download": {
|
||||
"get": {
|
||||
"operationId": "AssetsController_download_v1",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "assetId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deviceId",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"audio/mpeg": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/health": {
|
||||
"get": {
|
||||
"operationId": "HealthController_getHealth_v1",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsModule } from './modules/assets/assets.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DevicesModule } from './modules/devices/devices.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
@@ -9,6 +10,7 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
AssetsModule,
|
||||
HealthModule,
|
||||
DevicesModule,
|
||||
UploadsModule,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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.dto';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@ApiTags('assets')
|
||||
@Controller({
|
||||
path: 'assets',
|
||||
version: '1',
|
||||
})
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Get(':assetId/download')
|
||||
@ApiProduces('audio/mpeg')
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
},
|
||||
})
|
||||
async download(
|
||||
@Param('assetId') assetId: string,
|
||||
@Query() query: AssetDownloadQueryDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const download = await this.assetsService.getOwnedAudioAssetDownload(
|
||||
assetId,
|
||||
query.deviceId,
|
||||
);
|
||||
|
||||
response.setHeader('Content-Type', 'audio/mpeg');
|
||||
response.setHeader('Content-Length', String(download.contentLength));
|
||||
|
||||
return new StreamableFile(createReadStream(download.filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AssetDownloadQueryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule],
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 { AssetsService } from './assets.service';
|
||||
|
||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||
|
||||
function createPrismaMock() {
|
||||
const devices = new Map<string, any>();
|
||||
const audioAssets = new Map<string, any>();
|
||||
|
||||
return {
|
||||
prismaMock: {
|
||||
device: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return devices.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
audioAsset: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return audioAssets.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaService,
|
||||
state: {
|
||||
devices,
|
||||
audioAssets,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAppConfig(storageRoot: string): AppConfigService {
|
||||
return {
|
||||
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||
storageRoot,
|
||||
} as AppConfigService;
|
||||
}
|
||||
|
||||
describe('AssetsService', () => {
|
||||
let service: AssetsService;
|
||||
let state: MockState;
|
||||
let storageRoot: string;
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mock = createPrismaMock();
|
||||
state = mock.state;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
service = new AssetsService(mock.prismaMock, storageService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a local file path and content length for the owning device user', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
||||
const assetBytes = Buffer.from('ID3-owner-track', 'utf8');
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId,
|
||||
storageKey,
|
||||
});
|
||||
|
||||
const filePath = storageService.resolve(storageKey);
|
||||
await storageService.ensureParentDirectory(filePath);
|
||||
await writeFile(filePath, assetBytes);
|
||||
|
||||
const download = await service.getOwnedAudioAssetDownload(assetId, deviceId);
|
||||
|
||||
expect(download.filePath).toBe(filePath);
|
||||
expect(download.contentLength).toBe(assetBytes.length);
|
||||
});
|
||||
|
||||
it('rejects download attempts from a different user device', async () => {
|
||||
const ownerId = randomUUID();
|
||||
const otherUserId = randomUUID();
|
||||
const ownerDeviceId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
||||
state.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId: ownerId,
|
||||
storageKey: join('users', ownerId, 'audio', 'owner.mp3'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(assetId, ownerDeviceId),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('returns not found when the asset file is missing from storage', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId,
|
||||
storageKey: join('users', userId, 'audio', 'missing.mp3'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(assetId, deviceId),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the device does not exist', async () => {
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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 AudioAssetDownload {
|
||||
filePath: string;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly storageService: LocalFilesystemStorageService,
|
||||
) {}
|
||||
|
||||
async getOwnedAudioAssetDownload(
|
||||
assetId: string,
|
||||
deviceId: string,
|
||||
): Promise<AudioAssetDownload> {
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
const asset = await this.prismaService.audioAsset.findUnique({
|
||||
where: { id: assetId },
|
||||
select: {
|
||||
userId: true,
|
||||
storageKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Audio asset not found');
|
||||
}
|
||||
|
||||
if (asset.userId !== device.userId) {
|
||||
throw new ForbiddenException('Audio asset does not belong to this device user.');
|
||||
}
|
||||
|
||||
const filePath = this.storageService.resolve(asset.storageKey);
|
||||
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
|
||||
if (!fileStats.isFile()) {
|
||||
throw new NotFoundException('Audio asset file not found');
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
contentLength: fileStats.size,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NotFoundException('Audio asset file not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user