Add direct upload API for presigning, creating tracks and following processing
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
import {
|
||||
UploadError,
|
||||
completeTrackUpload,
|
||||
parseCompleteRequest,
|
||||
parsePresignRequest,
|
||||
parseStatusIds,
|
||||
presignTrackUploads,
|
||||
trackProcessingStatus
|
||||
} from '$lib/server/trackUploads';
|
||||
|
||||
const file = { fileName: 'a.wav', contentType: '', size: 10 };
|
||||
|
||||
function api(handlers: {
|
||||
get?: (path: string) => unknown;
|
||||
post?: (path: string, init: unknown) => unknown;
|
||||
}) {
|
||||
return {
|
||||
GET: vi.fn(async (path: string) => handlers.get?.(path) ?? {}),
|
||||
POST: vi.fn(async (path: string, init: unknown) => handlers.post?.(path, init) ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
const asApi = (fake: unknown) => fake as ApiClient;
|
||||
|
||||
describe('parsePresignRequest', () => {
|
||||
it('accepts up to the batch size', () => {
|
||||
expect(parsePresignRequest({ cover: null, files: [file] }).files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects empty, oversized and malformed batches', () => {
|
||||
expect(() => parsePresignRequest({ files: [] })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest({ files: Array(21).fill(file) })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest({ files: [{ fileName: 'a' }] })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest({ files: [{ ...file, size: 0 }] })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest(null)).toThrow(UploadError);
|
||||
});
|
||||
|
||||
it('rejects a malformed cover', () => {
|
||||
expect(() => parsePresignRequest({ cover: 3, files: [file] })).toThrow(UploadError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presignTrackUploads', () => {
|
||||
it('sends every audio and the shared cover, and maps the intents', async () => {
|
||||
const fake = api({
|
||||
post: () => ({
|
||||
data: {
|
||||
pictureUploadUrl: 'https://s3/pic',
|
||||
pictureContentType: 'image/png',
|
||||
audios: [
|
||||
{
|
||||
intentId: 'i1',
|
||||
pictureIntentId: 'p1',
|
||||
uploadUrl: 'https://s3/a',
|
||||
contentType: 'audio/wav'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
});
|
||||
const result = await presignTrackUploads(asApi(fake), {
|
||||
cover: { fileName: 'c.png', contentType: 'image/png', size: 5 },
|
||||
files: [file]
|
||||
});
|
||||
expect(result.picture).toEqual({ uploadUrl: 'https://s3/pic', contentType: 'image/png' });
|
||||
expect(result.audios[0]).toMatchObject({ intentId: 'i1', pictureIntentId: 'p1' });
|
||||
const body = fake.POST.mock.calls[0][1] as { body: Record<string, unknown> };
|
||||
expect(body.body).toMatchObject({
|
||||
pictureFileType: 'png',
|
||||
audios: [{ fileType: 'wav', contentType: 'audio/wav', expectedSizeBytes: 10 }]
|
||||
});
|
||||
});
|
||||
|
||||
it('has no picture target without a cover', async () => {
|
||||
const fake = api({
|
||||
post: () => ({
|
||||
data: {
|
||||
pictureUploadUrl: null,
|
||||
pictureContentType: null,
|
||||
audios: [{ intentId: 'i1', pictureIntentId: null, uploadUrl: 'u', contentType: 'a' }]
|
||||
}
|
||||
})
|
||||
});
|
||||
const result = await presignTrackUploads(asApi(fake), { cover: null, files: [file] });
|
||||
expect(result.picture).toBeNull();
|
||||
});
|
||||
|
||||
it('fails when the backend errors', async () => {
|
||||
const fake = api({ post: () => ({ error: { detail: 'nope' } }) });
|
||||
await expect(
|
||||
presignTrackUploads(asApi(fake), { cover: null, files: [file] })
|
||||
).rejects.toMatchObject({ status: 502, message: 'nope' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCompleteRequest', () => {
|
||||
const valid = { title: ' Song ', audioIntentId: 'a', tags: ['Rock', 'Rock'] };
|
||||
|
||||
it('trims and dedupes', () => {
|
||||
expect(parseCompleteRequest(valid)).toEqual({
|
||||
title: 'Song',
|
||||
audioIntentId: 'a',
|
||||
pictureIntentId: null,
|
||||
tags: ['Rock'],
|
||||
albumId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('requires title, intent and genres', () => {
|
||||
expect(() => parseCompleteRequest({ ...valid, title: ' ' })).toThrow(UploadError);
|
||||
expect(() => parseCompleteRequest({ ...valid, audioIntentId: '' })).toThrow(UploadError);
|
||||
expect(() => parseCompleteRequest({ ...valid, tags: [] })).toThrow(UploadError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTrackUpload', () => {
|
||||
const request = {
|
||||
title: 'Song',
|
||||
audioIntentId: 'a',
|
||||
pictureIntentId: null,
|
||||
tags: ['Rock'],
|
||||
albumId: null as string | null
|
||||
};
|
||||
const genres = {
|
||||
data: [
|
||||
{ genre: 'Rock', incompatibleWith: ['Classical'] },
|
||||
{ genre: 'Classical', incompatibleWith: ['Rock'] }
|
||||
]
|
||||
};
|
||||
|
||||
function build(albumResult: unknown = {}) {
|
||||
return api({
|
||||
get: () => genres,
|
||||
post: (path) => (path === '/tracks' ? { data: { id: 't1' } } : albumResult)
|
||||
});
|
||||
}
|
||||
|
||||
it('creates an unpublished-by-default track', async () => {
|
||||
const fake = build();
|
||||
expect(await completeTrackUpload(asApi(fake), request)).toEqual({ trackId: 't1', note: null });
|
||||
expect(fake.POST).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects unknown and conflicting genres before creating', async () => {
|
||||
const fake = build();
|
||||
await expect(
|
||||
completeTrackUpload(asApi(fake), { ...request, tags: ['Nope'] })
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
completeTrackUpload(asApi(fake), { ...request, tags: ['Rock', 'Classical'] })
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(fake.POST).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the track to the album', async () => {
|
||||
const fake = build();
|
||||
await completeTrackUpload(asApi(fake), { ...request, albumId: 'al' });
|
||||
expect(fake.POST).toHaveBeenLastCalledWith('/albums/{id}/tracks', {
|
||||
params: { path: { id: 'al' } },
|
||||
body: { trackIds: ['t1'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the track and reports a note when the album step fails', async () => {
|
||||
const fake = build({ error: { status: 500 } });
|
||||
const result = await completeTrackUpload(asApi(fake), { ...request, albumId: 'al' });
|
||||
expect(result.trackId).toBe('t1');
|
||||
expect(result.note).toContain('álbum');
|
||||
});
|
||||
});
|
||||
|
||||
describe('track processing status', () => {
|
||||
it('parses ids', () => {
|
||||
expect(parseStatusIds('a,b,a')).toEqual(['a', 'b']);
|
||||
expect(() => parseStatusIds(null)).toThrow(UploadError);
|
||||
});
|
||||
|
||||
it('maps the audio status of each track', async () => {
|
||||
const fake = api({
|
||||
get: (path) => (path === '/tracks/{id}' ? { data: { audioStatus: 'Completed' } } : {})
|
||||
});
|
||||
expect(await trackProcessingStatus(asApi(fake), ['a'])).toEqual({ a: 'Completed' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
import { apiErrorDetail } from '$lib/server/api';
|
||||
import { AUDIO_TYPES, IMAGE_TYPES, extOf } from '$lib/server/upload';
|
||||
import { genreConflictMessage, UPLOAD_BATCH_SIZE, UPLOAD_MAX_BYTES } from '$lib/data/uploadQueue';
|
||||
import type {
|
||||
CompleteRequest,
|
||||
CompleteResponse,
|
||||
PresignFile,
|
||||
PresignRequest,
|
||||
PresignResponse,
|
||||
StatusResponse,
|
||||
TrackProcessing
|
||||
} from '$lib/data/uploadProtocol';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import type { components } from '$lib/api/schema';
|
||||
|
||||
type Genre = components['schemas']['Genre'];
|
||||
|
||||
export class UploadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function contentType(file: PresignFile, table: Record<string, string>): string {
|
||||
return file.contentType || table[extOf(file.fileName)] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isFile(value: unknown): value is PresignFile {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const { fileName, contentType: type, size } = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof fileName === 'string' &&
|
||||
fileName !== '' &&
|
||||
typeof type === 'string' &&
|
||||
typeof size === 'number' &&
|
||||
Number.isFinite(size)
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePresignRequest(payload: unknown): PresignRequest {
|
||||
const { cover, files } = (payload ?? {}) as { cover?: unknown; files?: unknown };
|
||||
if (!Array.isArray(files) || files.length === 0 || files.length > UPLOAD_BATCH_SIZE) {
|
||||
throw new UploadError(`Envía entre 1 y ${UPLOAD_BATCH_SIZE} archivos.`, 400);
|
||||
}
|
||||
if (!files.every(isFile)) throw new UploadError('Archivo de audio inválido.', 400);
|
||||
if (files.some((file) => file.size <= 0 || file.size > UPLOAD_MAX_BYTES)) {
|
||||
throw new UploadError('Un archivo está vacío o supera el tamaño máximo.', 400);
|
||||
}
|
||||
if (cover !== null && cover !== undefined && !isFile(cover)) {
|
||||
throw new UploadError('Portada inválida.', 400);
|
||||
}
|
||||
return { cover: cover ?? null, files };
|
||||
}
|
||||
|
||||
export async function presignTrackUploads(
|
||||
api: ApiClient,
|
||||
request: PresignRequest
|
||||
): Promise<PresignResponse> {
|
||||
const { cover, files } = request;
|
||||
const result = await api.POST('/tracks/upload-urls', {
|
||||
body: {
|
||||
pictureFileType: cover ? extOf(cover.fileName) || 'jpg' : null,
|
||||
pictureContentType: cover ? contentType(cover, IMAGE_TYPES) : null,
|
||||
expectedPictureSizeBytes: cover?.size ?? null,
|
||||
audios: files.map((file) => ({
|
||||
fileType: extOf(file.fileName) || 'mp3',
|
||||
contentType: contentType(file, AUDIO_TYPES),
|
||||
expectedSizeBytes: file.size
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
if (result.error || !result.data || result.data.audios.length !== files.length) {
|
||||
throw new UploadError(
|
||||
apiErrorDetail(result.error) ?? 'No se pudieron reservar las URLs de subida.',
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const { pictureUploadUrl, pictureContentType, audios } = result.data;
|
||||
return {
|
||||
picture:
|
||||
pictureUploadUrl && pictureContentType
|
||||
? { uploadUrl: pictureUploadUrl, contentType: pictureContentType }
|
||||
: null,
|
||||
audios: audios.map((audio) => ({
|
||||
intentId: audio.intentId,
|
||||
pictureIntentId: audio.pictureIntentId,
|
||||
uploadUrl: audio.uploadUrl,
|
||||
contentType: audio.contentType
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function idOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
export function parseCompleteRequest(payload: unknown): CompleteRequest {
|
||||
const body = (payload ?? {}) as Record<string, unknown>;
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
if (title === '' || title.length > LIMITS.trackTitle) {
|
||||
throw new UploadError(`El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).`, 400);
|
||||
}
|
||||
const audioIntentId = idOrNull(body.audioIntentId);
|
||||
if (!audioIntentId) throw new UploadError('Subida inválida.', 400);
|
||||
const tags = Array.isArray(body.tags)
|
||||
? [...new Set(body.tags.filter((tag): tag is string => typeof tag === 'string'))]
|
||||
: [];
|
||||
if (tags.length === 0) throw new UploadError('Elige al menos un género.', 400);
|
||||
return {
|
||||
title,
|
||||
audioIntentId,
|
||||
pictureIntentId: idOrNull(body.pictureIntentId),
|
||||
tags,
|
||||
albumId: idOrNull(body.albumId)
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeTrackUpload(
|
||||
api: ApiClient,
|
||||
request: CompleteRequest
|
||||
): Promise<CompleteResponse> {
|
||||
const { data: available } = await api.GET('/genres/available');
|
||||
const options = available ?? [];
|
||||
const known = new Set<string>(options.map((option) => option.genre));
|
||||
if (!request.tags.every((tag) => known.has(tag))) {
|
||||
throw new UploadError('Alguno de los géneros no es válido.', 400);
|
||||
}
|
||||
const conflict = genreConflictMessage(request.tags, options);
|
||||
if (conflict) throw new UploadError(conflict, 400);
|
||||
|
||||
const created = await api.POST('/tracks', {
|
||||
body: {
|
||||
title: request.title,
|
||||
pictureIntentId: request.pictureIntentId,
|
||||
audioIntentId: request.audioIntentId,
|
||||
tags: request.tags as Genre[],
|
||||
isExplicit: false
|
||||
}
|
||||
});
|
||||
if (created.error || !created.data) {
|
||||
throw new UploadError(
|
||||
apiErrorDetail(created.error) ?? 'No se pudo crear la canción tras la subida.',
|
||||
502
|
||||
);
|
||||
}
|
||||
const trackId = created.data.id;
|
||||
|
||||
if (!request.albumId) return { trackId, note: null };
|
||||
const added = await api.POST('/albums/{id}/tracks', {
|
||||
params: { path: { id: request.albumId } },
|
||||
body: { trackIds: [trackId] }
|
||||
});
|
||||
return {
|
||||
trackId,
|
||||
note: added.error ? 'Se creó, pero no se pudo añadir al álbum.' : null
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStatusIds(raw: string | null): string[] {
|
||||
const ids = [...new Set((raw ?? '').split(',').filter(Boolean))];
|
||||
if (ids.length === 0 || ids.length > UPLOAD_BATCH_SIZE) {
|
||||
throw new UploadError('Canciones inválidas.', 400);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function trackProcessingStatus(
|
||||
api: ApiClient,
|
||||
ids: string[]
|
||||
): Promise<StatusResponse> {
|
||||
const entries = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const result = await api.GET('/tracks/{id}', { params: { path: { id } } });
|
||||
return [id, result.data?.audioStatus] as const;
|
||||
})
|
||||
);
|
||||
const known = entries.filter(
|
||||
(entry): entry is readonly [string, TrackProcessing] => entry[1] !== undefined
|
||||
);
|
||||
return Object.fromEntries(known);
|
||||
}
|
||||
Reference in New Issue
Block a user