From 493cc74cfb689c6c2826c935191702858d9b8676 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 25 Sep 2026 01:51:19 +0200 Subject: [PATCH] Add direct upload API for presigning, creating tracks and following processing --- web-player/src/lib/data/uploadProtocol.ts | 42 ++++ web-player/src/lib/data/uploadQueue.test.ts | 110 ++++++++++- web-player/src/lib/data/uploadQueue.ts | 110 ++++++++++- .../src/lib/server/trackUploads.test.ts | 186 +++++++++++++++++ web-player/src/lib/server/trackUploads.ts | 187 ++++++++++++++++++ web-player/src/routes/api/uploads/+server.ts | 16 ++ .../routes/api/uploads/complete/+server.ts | 16 ++ .../src/routes/api/uploads/server.test.ts | 79 ++++++++ .../src/routes/api/uploads/status/+server.ts | 16 ++ 9 files changed, 756 insertions(+), 6 deletions(-) create mode 100644 web-player/src/lib/data/uploadProtocol.ts create mode 100644 web-player/src/lib/server/trackUploads.test.ts create mode 100644 web-player/src/lib/server/trackUploads.ts create mode 100644 web-player/src/routes/api/uploads/+server.ts create mode 100644 web-player/src/routes/api/uploads/complete/+server.ts create mode 100644 web-player/src/routes/api/uploads/server.test.ts create mode 100644 web-player/src/routes/api/uploads/status/+server.ts diff --git a/web-player/src/lib/data/uploadProtocol.ts b/web-player/src/lib/data/uploadProtocol.ts new file mode 100644 index 0000000..41e2a4c --- /dev/null +++ b/web-player/src/lib/data/uploadProtocol.ts @@ -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; diff --git a/web-player/src/lib/data/uploadQueue.test.ts b/web-player/src/lib/data/uploadQueue.test.ts index a0f3f36..571a0cb 100644 --- a/web-player/src/lib/data/uploadQueue.test.ts +++ b/web-player/src/lib/data/uploadQueue.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; import { + beginUpload, + chunkStartable, + failUpload, + finishCreate, + finishProcessing, + finishUpload, + genreConflictMessage, + hasActiveUploads, + reportProgress, + retryUpload, summarizeQueue, titleFromFileName, toQueuedUpload, @@ -41,7 +51,14 @@ describe('uploadError', () => { describe('summarizeQueue', () => { it('counts ready and failed items', () => { 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(); + }); +}); diff --git a/web-player/src/lib/data/uploadQueue.ts b/web-player/src/lib/data/uploadQueue.ts index ff6fe87..6844243 100644 --- a/web-player/src/lib/data/uploadQueue.ts +++ b/web-player/src/lib/data/uploadQueue.ts @@ -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_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`; } -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 { id: string; @@ -18,6 +24,11 @@ export interface QueuedUpload { format: string; error: string | null; duration: number | null; + phase: UploadPhase; + progress: number; + failure: string | null; + trackId: string | null; + note: string | null; } export function fileExtension(name: string): string { @@ -50,13 +61,102 @@ export function toQueuedUpload(file: File): QueuedUpload { title: titleFromFileName(file.name), format: fileExtension(file.name).toUpperCase() || '?', 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 } { - const failed = items.filter((item) => item.error !== null).length; - return { ready: items.length - failed, failed }; +export function isStartable(item: QueuedUpload): boolean { + return item.error === null && item.phase === 'queued'; +} + +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 { diff --git a/web-player/src/lib/server/trackUploads.test.ts b/web-player/src/lib/server/trackUploads.test.ts new file mode 100644 index 0000000..38f9730 --- /dev/null +++ b/web-player/src/lib/server/trackUploads.test.ts @@ -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 }; + 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' }); + }); +}); diff --git a/web-player/src/lib/server/trackUploads.ts b/web-player/src/lib/server/trackUploads.ts new file mode 100644 index 0000000..24aa6a9 --- /dev/null +++ b/web-player/src/lib/server/trackUploads.ts @@ -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 { + 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; + 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 { + 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; + 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 { + const { data: available } = await api.GET('/genres/available'); + const options = available ?? []; + const known = new Set(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 { + 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); +} diff --git a/web-player/src/routes/api/uploads/+server.ts b/web-player/src/routes/api/uploads/+server.ts new file mode 100644 index 0000000..fe415f2 --- /dev/null +++ b/web-player/src/routes/api/uploads/+server.ts @@ -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; + } +}; diff --git a/web-player/src/routes/api/uploads/complete/+server.ts b/web-player/src/routes/api/uploads/complete/+server.ts new file mode 100644 index 0000000..4522ad2 --- /dev/null +++ b/web-player/src/routes/api/uploads/complete/+server.ts @@ -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; + } +}; diff --git a/web-player/src/routes/api/uploads/server.test.ts b/web-player/src/routes/api/uploads/server.test.ts new file mode 100644 index 0000000..91819a6 --- /dev/null +++ b/web-player/src/routes/api/uploads/server.test.ts @@ -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[0]; +type CompleteEvent = Parameters[0]; +type StatusEvent = Parameters[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); + }); +}); diff --git a/web-player/src/routes/api/uploads/status/+server.ts b/web-player/src/routes/api/uploads/status/+server.ts new file mode 100644 index 0000000..50e0cb4 --- /dev/null +++ b/web-player/src/routes/api/uploads/status/+server.ts @@ -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; + } +};