Add direct upload API for presigning, creating tracks and following processing
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
export interface PresignFile {
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresignRequest {
|
||||||
|
cover: PresignFile | null;
|
||||||
|
files: PresignFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresignTarget {
|
||||||
|
uploadUrl: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresignedAudio extends PresignTarget {
|
||||||
|
intentId: string;
|
||||||
|
pictureIntentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresignResponse {
|
||||||
|
picture: PresignTarget | null;
|
||||||
|
audios: PresignedAudio[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteRequest {
|
||||||
|
title: string;
|
||||||
|
audioIntentId: string;
|
||||||
|
pictureIntentId: string | null;
|
||||||
|
tags: string[];
|
||||||
|
albumId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteResponse {
|
||||||
|
trackId: string;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackProcessing = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||||
|
|
||||||
|
export type StatusResponse = Record<string, TrackProcessing>;
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
beginUpload,
|
||||||
|
chunkStartable,
|
||||||
|
failUpload,
|
||||||
|
finishCreate,
|
||||||
|
finishProcessing,
|
||||||
|
finishUpload,
|
||||||
|
genreConflictMessage,
|
||||||
|
hasActiveUploads,
|
||||||
|
reportProgress,
|
||||||
|
retryUpload,
|
||||||
summarizeQueue,
|
summarizeQueue,
|
||||||
titleFromFileName,
|
titleFromFileName,
|
||||||
toQueuedUpload,
|
toQueuedUpload,
|
||||||
@@ -41,7 +51,14 @@ describe('uploadError', () => {
|
|||||||
describe('summarizeQueue', () => {
|
describe('summarizeQueue', () => {
|
||||||
it('counts ready and failed items', () => {
|
it('counts ready and failed items', () => {
|
||||||
const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload);
|
const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload);
|
||||||
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
|
expect(summarizeQueue(items)).toEqual({
|
||||||
|
ready: 2,
|
||||||
|
failed: 1,
|
||||||
|
uploading: 0,
|
||||||
|
processing: 0,
|
||||||
|
done: 0,
|
||||||
|
errored: 0
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,3 +69,94 @@ describe('uploadLimitsLabel', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('upload state machine', () => {
|
||||||
|
const fresh = () => toQueuedUpload(file('a.wav'));
|
||||||
|
|
||||||
|
it('walks queued to done', () => {
|
||||||
|
let item = fresh();
|
||||||
|
expect(item.phase).toBe('queued');
|
||||||
|
item = beginUpload(item);
|
||||||
|
expect(item.phase).toBe('uploading');
|
||||||
|
item = reportProgress(item, 0.4);
|
||||||
|
expect(item.progress).toBe(0.4);
|
||||||
|
item = finishUpload(item);
|
||||||
|
expect(item).toMatchObject({ phase: 'creating', progress: 1 });
|
||||||
|
item = finishCreate(item, 't1');
|
||||||
|
expect(item).toMatchObject({ phase: 'processing', trackId: 't1' });
|
||||||
|
item = finishProcessing(item);
|
||||||
|
expect(item.phase).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores transitions from the wrong phase', () => {
|
||||||
|
const item = fresh();
|
||||||
|
expect(finishUpload(item)).toBe(item);
|
||||||
|
expect(finishCreate(item, 't1')).toBe(item);
|
||||||
|
expect(finishProcessing(item)).toBe(item);
|
||||||
|
expect(failUpload(item, 'x')).toBe(item);
|
||||||
|
expect(retryUpload(item)).toBe(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never starts an invalid file', () => {
|
||||||
|
const item = toQueuedUpload(file('a.psd'));
|
||||||
|
expect(beginUpload(item)).toBe(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps progress monotonic and capped at 1', () => {
|
||||||
|
let item = beginUpload(fresh());
|
||||||
|
item = reportProgress(item, 0.6);
|
||||||
|
expect(reportProgress(item, 0.3)).toBe(item);
|
||||||
|
expect(reportProgress(item, 4).progress).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails and retries', () => {
|
||||||
|
let item = beginUpload(fresh());
|
||||||
|
item = failUpload(item, 'Cancelada');
|
||||||
|
expect(item).toMatchObject({ phase: 'error', failure: 'Cancelada' });
|
||||||
|
item = retryUpload(item);
|
||||||
|
expect(item).toMatchObject({ phase: 'queued', progress: 0, failure: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a note when the album step fails', () => {
|
||||||
|
const item = finishCreate(finishUpload(beginUpload(fresh())), 't1', 'Sin álbum');
|
||||||
|
expect(item.note).toBe('Sin álbum');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chunkStartable', () => {
|
||||||
|
it('splits startable items into batches and skips the rest', () => {
|
||||||
|
const items = ['a.wav', 'b.wav', 'c.wav', 'd.psd'].map((name) => toQueuedUpload(file(name)));
|
||||||
|
items[1] = beginUpload(items[1]);
|
||||||
|
const chunks = chunkStartable(items, 1);
|
||||||
|
expect(chunks.map((chunk) => chunk.map((item) => item.file.name))).toEqual([
|
||||||
|
['a.wav'],
|
||||||
|
['c.wav']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasActiveUploads', () => {
|
||||||
|
it('is true while uploading, creating or processing', () => {
|
||||||
|
const item = toQueuedUpload(file('a.wav'));
|
||||||
|
expect(hasActiveUploads([item])).toBe(false);
|
||||||
|
expect(hasActiveUploads([beginUpload(item)])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('genreConflictMessage', () => {
|
||||||
|
const options = [
|
||||||
|
{ genre: 'Rock', incompatibleWith: ['Classical'] },
|
||||||
|
{ genre: 'Classical', incompatibleWith: ['Rock'] },
|
||||||
|
{ genre: 'Jazz', incompatibleWith: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('names the conflicting genres', () => {
|
||||||
|
expect(genreConflictMessage(['Rock', 'Classical'], options)).toBe(
|
||||||
|
'Los géneros Rock y Clásica no se pueden combinar.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when compatible', () => {
|
||||||
|
expect(genreConflictMessage(['Rock', 'Jazz'], options)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||||
|
import type { GenreOption } from '$lib/types';
|
||||||
|
|
||||||
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
||||||
|
|
||||||
@@ -9,7 +12,10 @@ export function uploadLimitsLabel(): string {
|
|||||||
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
|
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Visibility = 'private' | 'public';
|
export const UPLOAD_CONCURRENCY = 3;
|
||||||
|
export const UPLOAD_BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
export type UploadPhase = 'queued' | 'uploading' | 'creating' | 'processing' | 'done' | 'error';
|
||||||
|
|
||||||
export interface QueuedUpload {
|
export interface QueuedUpload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +24,11 @@ export interface QueuedUpload {
|
|||||||
format: string;
|
format: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
|
phase: UploadPhase;
|
||||||
|
progress: number;
|
||||||
|
failure: string | null;
|
||||||
|
trackId: string | null;
|
||||||
|
note: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileExtension(name: string): string {
|
export function fileExtension(name: string): string {
|
||||||
@@ -50,13 +61,102 @@ export function toQueuedUpload(file: File): QueuedUpload {
|
|||||||
title: titleFromFileName(file.name),
|
title: titleFromFileName(file.name),
|
||||||
format: fileExtension(file.name).toUpperCase() || '?',
|
format: fileExtension(file.name).toUpperCase() || '?',
|
||||||
error: uploadError(file),
|
error: uploadError(file),
|
||||||
duration: null
|
duration: null,
|
||||||
|
phase: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
failure: null,
|
||||||
|
trackId: null,
|
||||||
|
note: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeQueue(items: QueuedUpload[]): { ready: number; failed: number } {
|
export function isStartable(item: QueuedUpload): boolean {
|
||||||
const failed = items.filter((item) => item.error !== null).length;
|
return item.error === null && item.phase === 'queued';
|
||||||
return { ready: items.length - failed, failed };
|
}
|
||||||
|
|
||||||
|
export function isBusy(item: QueuedUpload): boolean {
|
||||||
|
return item.phase === 'uploading' || item.phase === 'creating';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActive(item: QueuedUpload): boolean {
|
||||||
|
return isBusy(item) || item.phase === 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginUpload(item: QueuedUpload): QueuedUpload {
|
||||||
|
if (!isStartable(item)) return item;
|
||||||
|
return { ...item, phase: 'uploading', progress: 0, failure: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportProgress(item: QueuedUpload, fraction: number): QueuedUpload {
|
||||||
|
if (item.phase !== 'uploading') return item;
|
||||||
|
const progress = Math.min(1, Math.max(item.progress, fraction));
|
||||||
|
return progress === item.progress ? item : { ...item, progress };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishUpload(item: QueuedUpload): QueuedUpload {
|
||||||
|
if (item.phase !== 'uploading') return item;
|
||||||
|
return { ...item, phase: 'creating', progress: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishCreate(
|
||||||
|
item: QueuedUpload,
|
||||||
|
trackId: string,
|
||||||
|
note: string | null = null
|
||||||
|
): QueuedUpload {
|
||||||
|
if (item.phase !== 'creating') return item;
|
||||||
|
return { ...item, phase: 'processing', trackId, note };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishProcessing(item: QueuedUpload): QueuedUpload {
|
||||||
|
if (item.phase !== 'processing') return item;
|
||||||
|
return { ...item, phase: 'done' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failUpload(item: QueuedUpload, reason: string): QueuedUpload {
|
||||||
|
if (!isActive(item)) return item;
|
||||||
|
return { ...item, phase: 'error', failure: reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryUpload(item: QueuedUpload): QueuedUpload {
|
||||||
|
if (item.phase !== 'error') return item;
|
||||||
|
return { ...item, phase: 'queued', progress: 0, failure: null, trackId: null, note: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genreConflictMessage(tags: string[], options: GenreOption[]): string | undefined {
|
||||||
|
const conflict = findConflict(tags, options);
|
||||||
|
if (!conflict) return undefined;
|
||||||
|
return `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkStartable(items: QueuedUpload[], size = UPLOAD_BATCH_SIZE): QueuedUpload[][] {
|
||||||
|
const startable = items.filter(isStartable);
|
||||||
|
const chunks: QueuedUpload[][] = [];
|
||||||
|
for (let i = 0; i < startable.length; i += size) chunks.push(startable.slice(i, i + size));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveUploads(items: QueuedUpload[]): boolean {
|
||||||
|
return items.some(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeQueue(items: QueuedUpload[]): {
|
||||||
|
ready: number;
|
||||||
|
failed: number;
|
||||||
|
uploading: number;
|
||||||
|
processing: number;
|
||||||
|
done: number;
|
||||||
|
errored: number;
|
||||||
|
} {
|
||||||
|
const invalid = items.filter((item) => item.error !== null).length;
|
||||||
|
const valid = items.filter((item) => item.error === null);
|
||||||
|
return {
|
||||||
|
ready: valid.filter((item) => item.phase === 'queued').length,
|
||||||
|
failed: invalid,
|
||||||
|
uploading: valid.filter(isBusy).length,
|
||||||
|
processing: valid.filter((item) => item.phase === 'processing').length,
|
||||||
|
done: valid.filter((item) => item.phase === 'done').length,
|
||||||
|
errored: valid.filter((item) => item.phase === 'error').length
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { apiFor } from '$lib/server/api';
|
||||||
|
import { UploadError, parsePresignRequest, presignTrackUploads } from '$lib/server/trackUploads';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parsePresignRequest(await request.json().catch(() => null));
|
||||||
|
return json(await presignTrackUploads(apiFor({ fetch, locals }), parsed));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { apiFor } from '$lib/server/api';
|
||||||
|
import { UploadError, completeTrackUpload, parseCompleteRequest } from '$lib/server/trackUploads';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseCompleteRequest(await request.json().catch(() => null));
|
||||||
|
return json(await completeTrackUpload(apiFor({ fetch, locals }), parsed));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
|
||||||
|
|
||||||
|
import { POST } from './+server';
|
||||||
|
import { POST as COMPLETE } from './complete/+server';
|
||||||
|
import { GET as STATUS } from './status/+server';
|
||||||
|
|
||||||
|
type PostEvent = Parameters<typeof POST>[0];
|
||||||
|
type CompleteEvent = Parameters<typeof COMPLETE>[0];
|
||||||
|
type StatusEvent = Parameters<typeof STATUS>[0];
|
||||||
|
|
||||||
|
function event(body: unknown, token: string | null = 'token') {
|
||||||
|
return {
|
||||||
|
request: new Request('http://x/api/uploads', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
locals: { accessToken: token, user: null },
|
||||||
|
fetch: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/uploads', () => {
|
||||||
|
it('requires a session', async () => {
|
||||||
|
const res = await POST(event({}, null) as unknown as PostEvent);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid batch', async () => {
|
||||||
|
const res = await POST(event({ files: [] }) as unknown as PostEvent);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies the presign request to the API', async () => {
|
||||||
|
const ev = event({ cover: null, files: [{ fileName: 'a.wav', contentType: '', size: 4 }] });
|
||||||
|
ev.fetch.mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
pictureUploadUrl: null,
|
||||||
|
pictureContentType: null,
|
||||||
|
audios: [{ intentId: 'i', pictureIntentId: null, uploadUrl: 'u', contentType: 'audio/wav' }]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const res = await POST(ev as unknown as PostEvent);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toMatchObject({ picture: null, audios: [{ intentId: 'i' }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/uploads/complete', () => {
|
||||||
|
it('requires a session', async () => {
|
||||||
|
const res = await COMPLETE(event({}, null) as unknown as CompleteEvent);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing title', async () => {
|
||||||
|
const res = await COMPLETE(
|
||||||
|
event({ title: '', audioIntentId: 'a', tags: ['Rock'] }) as unknown as CompleteEvent
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/uploads/status', () => {
|
||||||
|
it('requires a session', async () => {
|
||||||
|
const res = await STATUS({
|
||||||
|
url: new URL('http://x/api/uploads/status?ids=a'),
|
||||||
|
locals: { accessToken: null, user: null },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as unknown as StatusEvent);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing ids', async () => {
|
||||||
|
const res = await STATUS({
|
||||||
|
url: new URL('http://x/api/uploads/status'),
|
||||||
|
locals: { accessToken: 't', user: null },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as unknown as StatusEvent);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { apiFor } from '$lib/server/api';
|
||||||
|
import { UploadError, parseStatusIds, trackProcessingStatus } from '$lib/server/trackUploads';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = parseStatusIds(url.searchParams.get('ids'));
|
||||||
|
return json(await trackProcessingStatus(apiFor({ fetch, locals }), ids));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user