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
+2
View File
@@ -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));
}
}
+8
View File
@@ -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');
}
}
}