diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index 7651a13..50fc2bf 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -108,6 +108,6 @@ Server helpers: - `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it. - `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client. - `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions. -- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload. +- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload. - `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting. - Page sizes come from `$lib/config`; do not hardcode them. diff --git a/web-player/docs/paginas.md b/web-player/docs/paginas.md index 737aeae..5d2b00f 100644 --- a/web-player/docs/paginas.md +++ b/web-player/docs/paginas.md @@ -17,7 +17,6 @@ src/routes/ new/ /library/albums/new Create an album [id]/ /library/albums/:id Edit an album uploads/ /library/uploads Upload queue - upload/ /upload Upload a song albums/ /albums Your albums [id]/ /albums/:id Album detail playlists/ /playlists Your playlists @@ -36,8 +35,7 @@ Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar - **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`. - **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist. - **Liked `/liked`**: the songs the user liked, newest first, with unlike per row. -- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue. -- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend. +- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here. - **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library. - **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view. - **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu. diff --git a/web-player/scripts/check-tokens.mjs b/web-player/scripts/check-tokens.mjs index 928ab06..b9fcd93 100644 --- a/web-player/scripts/check-tokens.mjs +++ b/web-player/scripts/check-tokens.mjs @@ -16,6 +16,7 @@ const ARBITRARY_WHITELIST = [ /^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados" /^transition-\[filter\]$/, // Explorar: genre-tile hover /^transition-\[transform,width\]$/, // Tabs: indicador deslizante + /^transition-\[width\]$/, // cola de subida: barra de progreso /^bg-\[image:var\(--mf-[\w-]+\)\]$/ ]; diff --git a/web-player/src/lib/components/music/library/LibraryUploadsSection.svelte b/web-player/src/lib/components/music/library/LibraryUploadsSection.svelte index b0ad180..4721699 100644 --- a/web-player/src/lib/components/music/library/LibraryUploadsSection.svelte +++ b/web-player/src/lib/components/music/library/LibraryUploadsSection.svelte @@ -1,68 +1,42 @@
@@ -72,17 +46,18 @@ title="Arrastra tus archivos aquí" hint={uploadLimitsLabel()} inputLabel="Elegir archivos de audio" - onfiles={enqueue} + onfiles={(files) => queue.enqueue(files)} readDrop={filesFromDrop} /> single?.title ?? '', (value) => single && setTitle(single.id, value)} - titleDisabled={single === null} + {albums} + bind:selectedGenres={queue.selectedGenres} + bind:albumId={queue.albumId} + hasCover={queue.cover !== null} + oncover={(file) => (queue.cover = file)} + disabled={queue.running} />
@@ -91,24 +66,40 @@

Cola de subida

- {queue.length > 0 ? queueMeta : 'Todavía no hay archivos'} + {queue.items.length > 0 ? queueMeta : 'Todavía no hay archivos'}

- {#if queue.length > 0} - + {#if queue.items.length > 0} + {/if} - +
- {#if queue.length > 0} + +

+ Se añadirá sin publicar: publícala desde Canciones cuando termine de procesarse. +

+ + {#if queue.notice} + {queue.notice} + {/if} + + {#if queue.items.length > 0} queue.remove(id)} + onCancel={(id) => queue.cancel(id)} + onRetry={(id) => queue.retry(id)} + onTitle={(id, title) => queue.setTitle(id, title)} /> {:else}

diff --git a/web-player/src/lib/components/music/library/UploadQueueList.svelte b/web-player/src/lib/components/music/library/UploadQueueList.svelte index 1f5f08c..72b67e7 100644 --- a/web-player/src/lib/components/music/library/UploadQueueList.svelte +++ b/web-player/src/lib/components/music/library/UploadQueueList.svelte @@ -12,7 +12,6 @@ mobileLabel: true }, { id: 'genres', label: 'Géneros', width: COL.genresWide, class: TEXT }, - { id: 'access', label: 'Acceso', width: COL.access, class: TEXT }, { id: 'size', label: 'Tamaño', width: COL.size, class: `${TEXT} tabular-nums` }, { id: 'duration', @@ -28,14 +27,16 @@ mobile: COL.statusMobile, mobileLabel: true }, - { id: 'remove', width: COL.action, mobile: COL.actionMobile } + { id: 'actions', width: COL.actions, mobile: COL.actionsMobile } ]; -

diff --git a/web-player/src/lib/components/shell/navLinks.ts b/web-player/src/lib/components/shell/navLinks.ts index 1e7ba2f..d9e9a12 100644 --- a/web-player/src/lib/components/shell/navLinks.ts +++ b/web-player/src/lib/components/shell/navLinks.ts @@ -63,7 +63,7 @@ export function appNavSections( label: 'Gestionar música', icon: Music, inMobileTabs: false, - activeFor: ['/albums', '/upload'] + activeFor: ['/albums'] } ] } 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/forms/trackUploadForm.test.ts b/web-player/src/lib/server/forms/trackUploadForm.test.ts deleted file mode 100644 index a8fc0cd..0000000 --- a/web-player/src/lib/server/forms/trackUploadForm.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { LIMITS } from '$lib/validation'; -import { parseTrackUploadForm } from './trackUploadForm'; - -function form(fields: Record): FormData { - const data = new FormData(); - for (const [key, value] of Object.entries(fields)) { - if (Array.isArray(value)) value.forEach((item) => data.append(key, item)); - else data.set(key, value); - } - return data; -} - -const audio = new File(['a'], 'a.mp3'); -const cover = new File(['c'], 'c.png'); - -describe('parseTrackUploadForm', () => { - it('parses a valid form and deduplicates tags', () => { - const parsed = parseTrackUploadForm( - form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' }) - ); - expect(parsed).toMatchObject({ - body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true } - }); - }); - it('validates each field', () => { - const ok = { title: 'Song', audio, cover, tags: ['rock'] }; - expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage'); - expect( - parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) })) - ).toHaveProperty('failMessage'); - expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage'); - expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage'); - expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage'); - }); -}); diff --git a/web-player/src/lib/server/forms/trackUploadForm.ts b/web-player/src/lib/server/forms/trackUploadForm.ts deleted file mode 100644 index d64a3bb..0000000 --- a/web-player/src/lib/server/forms/trackUploadForm.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { formFile, formString } from '$lib/server/forms/fields'; -import { LIMITS } from '$lib/validation'; - -interface TrackUploadBody { - title: string; - audio: File; - cover: File; - tags: string[]; - isExplicit: boolean; -} - -export function parseTrackUploadForm( - form: FormData -): { body: TrackUploadBody } | { failMessage: string } { - const title = formString(form, 'title').trim(); - if (title === '' || title.length > LIMITS.trackTitle) { - return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` }; - } - - const audio = formFile(form, 'audio'); - if (!audio) return { failMessage: 'Selecciona un archivo de audio.' }; - - const cover = formFile(form, 'cover'); - if (!cover) return { failMessage: 'Selecciona una portada.' }; - - const tags = [...new Set(form.getAll('tags').map(String))]; - if (tags.length === 0) return { failMessage: 'Elige al menos un género.' }; - - return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } }; -} 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/lib/state/history.svelte.ts b/web-player/src/lib/state/history.svelte.ts index d0db6e2..95e126e 100644 --- a/web-player/src/lib/state/history.svelte.ts +++ b/web-player/src/lib/state/history.svelte.ts @@ -12,7 +12,6 @@ const LABELS: [RegExp, string][] = [ [/^\/library\/albums\/?$/, 'Volver a tus álbumes'], [/^\/library\/uploads\/?$/, 'Volver a subir música'], [/^\/library\/?$/, 'Volver a tus canciones'], - [/^\/upload\/?$/, 'Volver a subir música'], [/^\/mixes\/[^/]+/, 'Volver a la mezcla'] ]; diff --git a/web-player/src/lib/state/uploadQueue.svelte.ts b/web-player/src/lib/state/uploadQueue.svelte.ts new file mode 100644 index 0000000..bf9d478 --- /dev/null +++ b/web-player/src/lib/state/uploadQueue.svelte.ts @@ -0,0 +1,265 @@ +import { + UPLOAD_CONCURRENCY, + beginUpload, + chunkStartable, + failUpload, + finishCreate, + finishProcessing, + finishUpload, + genreConflictMessage, + hasActiveUploads, + isStartable, + readDuration, + reportProgress, + retryUpload, + summarizeQueue, + toQueuedUpload, + type QueuedUpload +} from '$lib/data/uploadQueue'; +import type { PresignedAudio } from '$lib/data/uploadProtocol'; +import type { GenreOption } from '$lib/types'; +import { httpUploadTransport, type UploadTransport } from '$lib/utils/uploadTransport'; + +const STATUS_BATCH = 20; + +interface QueueDeps { + transport: UploadTransport; + pollDelay: (attempt: number) => Promise; + readDuration: (file: File) => Promise; +} + +interface BatchSettings { + tags: string[]; + albumId: string | null; +} + +function reasonOf(err: unknown): string { + if (err instanceof DOMException && err.name === 'AbortError') return 'Cancelada'; + return err instanceof Error && err.message !== '' ? err.message : 'La subida falló.'; +} + +function backoff(attempt: number): Promise { + const delay = Math.min(3000 * 1.5 ** attempt, 15000); + return new Promise((resolve) => setTimeout(resolve, delay)); +} + +export class UploadQueue { + items = $state([]); + selectedGenres = $state([]); + cover = $state(null); + albumId = $state(''); + notice = $state(null); + running = $state(false); + + summary = $derived(summarizeQueue(this.items)); + active = $derived(hasActiveUploads(this.items)); + + private deps: QueueDeps; + private controllers = new Map(); + private presignController: AbortController | null = null; + private polling = false; + + constructor(deps: Partial = {}) { + this.deps = { + transport: deps.transport ?? httpUploadTransport, + pollDelay: deps.pollDelay ?? backoff, + readDuration: deps.readDuration ?? readDuration + }; + } + + enqueue(files: File[]) { + const added = files.map(toQueuedUpload); + this.items = [...this.items, ...added]; + for (const item of added) { + if (item.error) continue; + this.deps.readDuration(item.file).then((duration) => { + this.update(item.id, (current) => ({ ...current, duration })); + }); + } + } + + setTitle(id: string, title: string) { + this.update(id, (item) => ({ ...item, title })); + } + + remove(id: string) { + this.controllers.get(id)?.abort(); + this.items = this.items.filter((item) => item.id !== id); + } + + cancel(id: string) { + this.controllers.get(id)?.abort(); + } + + clear() { + this.presignController?.abort(); + for (const controller of this.controllers.values()) controller.abort(); + this.items = []; + } + + retry(id: string) { + this.update(id, retryUpload); + } + + async start(genreOptions: GenreOption[]) { + if (this.running) return; + this.notice = null; + if (this.selectedGenres.length === 0) { + this.notice = 'Elige al menos un género para toda la cola.'; + return; + } + const conflict = genreConflictMessage(this.selectedGenres, genreOptions); + if (conflict) { + this.notice = conflict; + return; + } + + const chunks = chunkStartable(this.items); + if (chunks.length === 0) return; + + const settings: BatchSettings = { + tags: [...this.selectedGenres], + albumId: this.albumId === '' ? null : this.albumId + }; + const cover = this.cover; + this.running = true; + try { + for (const chunk of chunks) await this.runChunk(chunk, cover, settings); + } finally { + this.running = false; + } + } + + private update(id: string, change: (item: QueuedUpload) => QueuedUpload) { + this.items = this.items.map((item) => (item.id === id ? change(item) : item)); + } + + private find(id: string): QueuedUpload | undefined { + return this.items.find((item) => item.id === id); + } + + private async runChunk(chunk: QueuedUpload[], cover: File | null, settings: BatchSettings) { + const current = chunk.filter((item) => { + const live = this.find(item.id); + return live !== undefined && isStartable(live); + }); + if (current.length === 0) return; + + const controller = new AbortController(); + this.presignController = controller; + + let audios: PresignedAudio[]; + try { + const presigned = await this.deps.transport.presign( + { + cover: cover ? { fileName: cover.name, contentType: cover.type, size: cover.size } : null, + files: current.map(({ file }) => ({ + fileName: file.name, + contentType: file.type, + size: file.size + })) + }, + controller.signal + ); + if (presigned.picture && cover) { + await this.deps.transport.put(presigned.picture, cover, () => {}, controller.signal); + } + audios = presigned.audios; + } catch (err) { + const reason = reasonOf(err); + for (const { id } of current) + this.update(id, (item) => failUpload(beginUpload(item), reason)); + return; + } finally { + this.presignController = null; + } + + const jobs = current.map((item, index) => ({ id: item.id, audio: audios[index] })); + const worker = async () => { + for (let job = jobs.shift(); job; job = jobs.shift()) { + await this.processOne(job.id, job.audio, settings); + } + }; + await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, jobs.length) }, worker)); + } + + private async processOne(id: string, audio: PresignedAudio, settings: BatchSettings) { + const item = this.find(id); + if (!item || !isStartable(item)) return; + + const controller = new AbortController(); + this.controllers.set(id, controller); + this.update(id, beginUpload); + try { + await this.deps.transport.put( + audio, + item.file, + (fraction) => this.update(id, (current) => reportProgress(current, fraction)), + controller.signal + ); + this.update(id, finishUpload); + const title = this.find(id)?.title.trim() || item.title; + const created = await this.deps.transport.complete( + { + title, + audioIntentId: audio.intentId, + pictureIntentId: audio.pictureIntentId, + tags: settings.tags, + albumId: settings.albumId + }, + controller.signal + ); + this.update(id, (current) => finishCreate(current, created.trackId, created.note)); + void this.watchProcessing(); + } catch (err) { + const reason = reasonOf(err); + this.update(id, (current) => failUpload(current, reason)); + } finally { + this.controllers.delete(id); + } + } + + private async watchProcessing() { + if (this.polling) return; + this.polling = true; + try { + for (let attempt = 0; ; attempt++) { + const trackIds = this.items + .filter((item) => item.phase === 'processing' && item.trackId !== null) + .map((item) => item.trackId as string); + if (trackIds.length === 0) return; + await this.deps.pollDelay(attempt); + await this.refreshStatus(trackIds); + } + } finally { + this.polling = false; + } + } + + private async refreshStatus(trackIds: string[]) { + const batches: string[][] = []; + for (let i = 0; i < trackIds.length; i += STATUS_BATCH) { + batches.push(trackIds.slice(i, i + STATUS_BATCH)); + } + await Promise.all( + batches.map(async (ids) => { + try { + const statuses = await this.deps.transport.status(ids); + for (const item of this.items) { + const status = item.trackId ? statuses[item.trackId] : undefined; + if (status === 'Completed') this.update(item.id, finishProcessing); + if (status === 'Failed') { + this.update(item.id, (current) => + failUpload(current, 'No se pudo procesar el audio.') + ); + } + } + } catch { + return; + } + }) + ); + } +} + +export const uploadQueue = new UploadQueue(); diff --git a/web-player/src/lib/state/uploadQueue.test.ts b/web-player/src/lib/state/uploadQueue.test.ts new file mode 100644 index 0000000..0b9c761 --- /dev/null +++ b/web-player/src/lib/state/uploadQueue.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UploadQueue } from '$lib/state/uploadQueue.svelte'; +import type { UploadTransport } from '$lib/utils/uploadTransport'; +import { putHeaders } from '$lib/utils/uploadTransport'; +import type { GenreOption } from '$lib/types'; + +const GENRES: GenreOption[] = [ + { genre: 'Rock', incompatibleWith: ['Classical'] }, + { genre: 'Classical', incompatibleWith: ['Rock'] }, + { genre: 'Jazz', incompatibleWith: [] } +]; + +function file(name: string) { + return new File([new Uint8Array(8)], name); +} + +function fakeTransport(overrides: Partial = {}) { + let counter = 0; + const transport: UploadTransport = { + presign: vi.fn(async (request) => ({ + picture: request.cover ? { uploadUrl: 'https://s3/pic', contentType: 'image/png' } : null, + audios: request.files.map(() => ({ + intentId: `intent-${counter++}`, + pictureIntentId: request.cover ? 'pic' : null, + uploadUrl: 'https://s3/audio', + contentType: 'audio/wav' + })) + })), + put: vi.fn(async (_target, _file, onProgress) => onProgress(0.5)), + complete: vi.fn(async (request) => ({ trackId: `track-${request.audioIntentId}`, note: null })), + status: vi.fn(async (ids) => + Object.fromEntries(ids.map((id: string) => [id, 'Completed' as const])) + ), + ...overrides + }; + return transport; +} + +function queue(transport: UploadTransport) { + const q = new UploadQueue({ + transport, + pollDelay: async () => {}, + readDuration: async () => 60 + }); + q.selectedGenres = ['Jazz']; + return q; +} + +describe('UploadQueue', () => { + it('uploads every valid file, creates the tracks and finishes them', async () => { + const transport = fakeTransport(); + const q = queue(transport); + q.enqueue([file('a.wav'), file('b.mp3'), file('c.psd')]); + await q.start(GENRES); + await vi.waitFor(() => expect(q.summary.done).toBe(2)); + expect(q.summary.failed).toBe(1); + expect(transport.put).toHaveBeenCalledTimes(2); + expect(transport.complete).toHaveBeenCalledTimes(2); + expect(transport.presign).toHaveBeenCalledTimes(1); + }); + + it('uploads the shared cover once and passes the album and genres', async () => { + const transport = fakeTransport(); + const q = queue(transport); + q.cover = new File([new Uint8Array(4)], 'c.png', { type: 'image/png' }); + q.albumId = 'album-1'; + q.enqueue([file('a.wav'), file('b.wav')]); + await q.start(GENRES); + const puts = vi.mocked(transport.put).mock.calls.map((call) => call[0].uploadUrl); + expect(puts.filter((url) => url === 'https://s3/pic')).toHaveLength(1); + expect(transport.complete).toHaveBeenCalledWith( + expect.objectContaining({ albumId: 'album-1', tags: ['Jazz'], pictureIntentId: 'pic' }), + expect.anything() + ); + }); + + it('requires genres and rejects conflicting ones before sending anything', async () => { + const transport = fakeTransport(); + const q = queue(transport); + q.enqueue([file('a.wav')]); + q.selectedGenres = []; + await q.start(GENRES); + expect(q.notice).toContain('género'); + q.selectedGenres = ['Rock', 'Classical']; + await q.start(GENRES); + expect(q.notice).toContain('no se pueden combinar'); + expect(transport.presign).not.toHaveBeenCalled(); + }); + + it('never runs more than the concurrency limit at once', async () => { + let running = 0; + let peak = 0; + const transport = fakeTransport({ + put: async () => { + running++; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running--; + } + }); + const q = queue(transport); + q.enqueue(Array.from({ length: 8 }, (_, i) => file(`${i}.wav`))); + await q.start(GENRES); + expect(peak).toBe(3); + }); + + it('marks a file as failed and lets it retry', async () => { + let fail = true; + const transport = fakeTransport({ + put: async () => { + if (fail) throw new Error('boom'); + } + }); + const q = queue(transport); + q.enqueue([file('a.wav')]); + await q.start(GENRES); + expect(q.items[0]).toMatchObject({ phase: 'error', failure: 'boom' }); + fail = false; + q.retry(q.items[0].id); + await q.start(GENRES); + await vi.waitFor(() => expect(q.items[0].phase).toBe('done')); + }); + + it('fails the whole batch when the presign request fails', async () => { + const transport = fakeTransport({ + presign: async () => { + throw new Error('sin URLs'); + } + }); + const q = queue(transport); + q.enqueue([file('a.wav'), file('b.wav')]); + await q.start(GENRES); + expect(q.items.map((item) => item.failure)).toEqual(['sin URLs', 'sin URLs']); + }); + + it('cancels an upload in flight', async () => { + const transport = fakeTransport({ + put: (_target, _file, _progress, signal) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new DOMException('x', 'AbortError'))) + ) + }); + const q = queue(transport); + q.enqueue([file('a.wav')]); + const started = q.start(GENRES); + await vi.waitFor(() => expect(q.items[0].phase).toBe('uploading')); + q.cancel(q.items[0].id); + await started; + expect(q.items[0]).toMatchObject({ phase: 'error', failure: 'Cancelada' }); + expect(transport.complete).not.toHaveBeenCalled(); + }); + + it('keeps the album note and turns a failed processing into an error', async () => { + const transport = fakeTransport({ + complete: async () => ({ trackId: 't1', note: 'Sin álbum' }), + status: async () => ({ t1: 'Failed' }) + }); + const q = queue(transport); + q.enqueue([file('a.wav')]); + await q.start(GENRES); + await vi.waitFor(() => expect(q.items[0].phase).toBe('error')); + expect(q.items[0].note).toBe('Sin álbum'); + }); + + it('reports active uploads and clears the queue', async () => { + const transport = fakeTransport({ + put: (_target, _file, _progress, signal) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new DOMException('x', 'AbortError'))) + ) + }); + const q = queue(transport); + q.enqueue([file('a.wav')]); + const started = q.start(GENRES); + await vi.waitFor(() => expect(q.active).toBe(true)); + q.clear(); + await started; + expect(q.items).toEqual([]); + expect(q.active).toBe(false); + }); +}); + +describe('putHeaders', () => { + it('sends only the signed headers', () => { + const target = { uploadUrl: 'https://s3/a?X-Amz-SignedHeaders=host', contentType: 'audio/wav' }; + expect(putHeaders(target)).toEqual({}); + expect( + putHeaders({ + ...target, + uploadUrl: 'https://s3/a?X-Amz-SignedHeaders=content-type%3Bhost%3Bif-none-match' + }) + ).toEqual({ 'Content-Type': 'audio/wav', 'If-None-Match': '*' }); + }); +}); diff --git a/web-player/src/lib/utils/gridColumns.ts b/web-player/src/lib/utils/gridColumns.ts index bda48ce..d4e97c2 100644 --- a/web-player/src/lib/utils/gridColumns.ts +++ b/web-player/src/lib/utils/gridColumns.ts @@ -30,7 +30,8 @@ export const COL = { publish: '6.5rem', statusQueue: '8.75rem', statusMobile: '6rem', - access: '6rem', + actions: '4.75rem', + actionsMobile: '4rem', size: '5.5rem', durationQueue: '4.5rem' } as const; diff --git a/web-player/src/lib/utils/uploadTransport.ts b/web-player/src/lib/utils/uploadTransport.ts new file mode 100644 index 0000000..97a97ea --- /dev/null +++ b/web-player/src/lib/utils/uploadTransport.ts @@ -0,0 +1,91 @@ +import type { + CompleteRequest, + CompleteResponse, + PresignRequest, + PresignResponse, + PresignTarget, + StatusResponse +} from '$lib/data/uploadProtocol'; + +export interface UploadTransport { + presign(request: PresignRequest, signal: AbortSignal): Promise; + put( + target: PresignTarget, + file: Blob, + onProgress: (fraction: number) => void, + signal: AbortSignal + ): Promise; + complete(request: CompleteRequest, signal: AbortSignal): Promise; + status(ids: string[]): Promise; +} + +export class TransportError extends Error {} + +export function signedHeaders(uploadUrl: string): Set { + const raw = new URL(uploadUrl).searchParams.get('X-Amz-SignedHeaders') ?? 'host'; + return new Set(raw.toLowerCase().split(';')); +} + +export function putHeaders(target: PresignTarget): Record { + const signed = signedHeaders(target.uploadUrl); + const headers: Record = {}; + if (signed.has('content-type')) headers['Content-Type'] = target.contentType; + if (signed.has('if-none-match')) headers['If-None-Match'] = '*'; + return headers; +} + +async function postJson(url: string, body: unknown, signal: AbortSignal): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new TransportError(data?.message ?? 'La solicitud falló.'); + } + return data as T; +} + +function putWithProgress( + target: PresignTarget, + file: Blob, + onProgress: (fraction: number) => void, + signal: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const xhr = new XMLHttpRequest(); + xhr.open('PUT', target.uploadUrl); + for (const [name, value] of Object.entries(putHeaders(target))) { + xhr.setRequestHeader(name, value); + } + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) onProgress(event.loaded / event.total); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new TransportError(`El almacenamiento rechazó el archivo (${xhr.status}).`)); + }; + xhr.onerror = () => reject(new TransportError('No se pudo conectar con el almacenamiento.')); + xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', () => xhr.abort(), { once: true }); + xhr.send(file); + }); +} + +export const httpUploadTransport: UploadTransport = { + presign: (request, signal) => postJson('/api/uploads', request, signal), + put: putWithProgress, + complete: (request, signal) => + postJson('/api/uploads/complete', request, signal), + async status(ids) { + const response = await fetch(`/api/uploads/status?ids=${encodeURIComponent(ids.join(','))}`); + if (!response.ok) throw new TransportError('No se pudo consultar el estado.'); + return (await response.json()) as StatusResponse; + } +}; diff --git a/web-player/src/routes/(app)/+layout.svelte b/web-player/src/routes/(app)/+layout.svelte index 428e0ce..58ecc24 100644 --- a/web-player/src/routes/(app)/+layout.svelte +++ b/web-player/src/routes/(app)/+layout.svelte @@ -17,6 +17,8 @@ import { trackNavigation } from '$lib/state/history.svelte'; import { restoreScroll } from '$lib/state/scroll'; import { page } from '$app/state'; + import { beforeNavigate } from '$app/navigation'; + import { uploadQueue } from '$lib/state/uploadQueue.svelte'; interface Props { children: Snippet; @@ -34,6 +36,9 @@ ); trackNavigation(); + beforeNavigate((navigation) => { + if (navigation.willUnload && uploadQueue.active) navigation.cancel(); + }); restoreScroll(() => scroller); $effect(() => { diff --git a/web-player/src/routes/(app)/library/uploads/+page.server.ts b/web-player/src/routes/(app)/library/uploads/+page.server.ts index 72c80ab..b29eea4 100644 --- a/web-player/src/routes/(app)/library/uploads/+page.server.ts +++ b/web-player/src/routes/(app)/library/uploads/+page.server.ts @@ -1,10 +1,17 @@ import type { PageServerLoad } from './$types'; import { apiFor } from '$lib/server/api'; +import { loadUserAlbums } from '$lib/server/albums'; import { requireUser } from '$lib/server/guards'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { - requireUser(locals, url); + const user = requireUser(locals, url); const api = apiFor({ fetch, locals }); - const genres = await api.GET('/genres/available'); - return { genres: genres.data ?? [] }; + const [genres, albums] = await Promise.all([ + api.GET('/genres/available'), + loadUserAlbums(api, user.sub) + ]); + return { + genres: genres.data ?? [], + albums: albums.items.map(({ id, title }) => ({ id, title })) + }; }; diff --git a/web-player/src/routes/(app)/library/uploads/+page.svelte b/web-player/src/routes/(app)/library/uploads/+page.svelte index 738fc66..3d5ca69 100644 --- a/web-player/src/routes/(app)/library/uploads/+page.svelte +++ b/web-player/src/routes/(app)/library/uploads/+page.svelte @@ -4,4 +4,4 @@ let { data } = $props(); - + diff --git a/web-player/src/routes/(app)/upload/+page.server.ts b/web-player/src/routes/(app)/upload/+page.server.ts deleted file mode 100644 index 7de31e3..0000000 --- a/web-player/src/routes/(app)/upload/+page.server.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Actions, PageServerLoad } from './$types'; -import { fail } from '@sveltejs/kit'; -import { apiFor, requireData } from '$lib/server/api'; -import { requireUser } from '$lib/server/guards'; -import { authedAction } from '$lib/server/actions/authedAction'; -import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm'; -import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; -import { findConflict, genreInfo } from '$lib/data/genres'; -import type { components } from '$lib/api/schema'; - -type Genre = components['schemas']['Genre']; - -export const load: PageServerLoad = async ({ locals, url, fetch }) => { - requireUser(locals, url); - const api = apiFor({ fetch, locals }); - const { data } = await api.GET('/genres/available'); - return { genres: data ?? [] }; -}; - -export const actions: Actions = { - default: authedAction(async ({ api, form }) => { - const parsed = parseTrackUploadForm(form); - if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); - const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body; - - const { data: available } = await api.GET('/genres/available'); - const options = available ?? []; - const known = new Set(options.map((option) => option.genre)); - if (!rawTags.every((tag) => known.has(tag))) { - return fail(400, { message: 'Alguno de los géneros no es válido.' }); - } - const tags = rawTags as Genre[]; - const conflict = findConflict(tags, options); - if (conflict) { - return fail(400, { - message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.` - }); - } - - const audioContentType = contentTypeOf(audio, AUDIO_TYPES); - const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); - - const urlsResult = await api.POST('/tracks/upload-urls', { - body: { - pictureFileType: extOf(cover.name) || 'jpg', - pictureContentType, - audioFileType: extOf(audio.name) || 'mp3', - audioContentType, - expectedPictureSizeBytes: cover.size, - expectedAudioSizeBytes: audio.size - } - }); - - const { data: urls, failure: urlsFailure } = requireData( - urlsResult, - 'No se pudieron reservar las URLs de subida.' - ); - if (urlsFailure) return urlsFailure; - - try { - const [audioBuffer, coverBuffer] = await Promise.all([ - audio.arrayBuffer(), - cover.arrayBuffer() - ]); - await Promise.all([ - putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType), - urls.pictureUploadUrl && urls.pictureContentType - ? putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType) - : Promise.resolve() - ]); - } catch (err) { - return fail(502, { - message: 'Falló la subida del archivo al almacenamiento.', - detail: err instanceof Error ? err.message : undefined - }); - } - - const trackResult = await api.POST('/tracks', { - body: { - title, - pictureIntentId: urls.pictureIntentId, - audioIntentId: urls.audioIntentId, - tags, - isExplicit - } - }); - - const { data: track, failure: trackFailure } = requireData( - trackResult, - 'No se pudo crear la pista tras la subida.' - ); - if (trackFailure) return trackFailure; - - return { success: true, trackId: track.id, title: track.title }; - }, 'Inicia sesión para subir música.') -}; diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte deleted file mode 100644 index e5b2084..0000000 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ /dev/null @@ -1,299 +0,0 @@ - - - - Subir música - - - - - - -
    - {#each steps as step, i (step.key)} -
  1. - -
    - - {#if stepStates[i] === 'done'} - - {:else} - {i + 1} - {/if} - - {step.label} -
    -

    {step.hint}

    -
    -
  2. - {/each} -
- - {#if status === 'done'} - - - - -

¡Subida!

-

- «{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en - unos momentos. -

-
- - -
-
- {:else} -
{ - status = 'uploading'; - errorMsg = ''; - errorDetail = ''; - return async ({ result }) => { - if (result.type === 'success') { - publishedTitle = title; - status = 'done'; - } else if (result.type === 'failure') { - status = 'error'; - errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.'; - errorDetail = (result.data?.detail as string | undefined) ?? ''; - } else { - status = 'error'; - errorMsg = 'Error inesperado durante la subida.'; - } - }; - }} - class="mt-10 space-y-8" - > -
{ - e.preventDefault(); - dragging = true; - }} - ondragleave={() => (dragging = false)} - ondrop={onDrop} - class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging - ? 'bg-surface-2 ring-2 ring-accent/50' - : 'bg-surface'}" - > - - {#if audioName === ''} - - {:else} - - {/if} - {#if audioName === ''} -

- Arrastra tu audio aquí o haz clic para elegir -

-

MP3, FLAC, WAV…

- {:else} -

- {audioName} -

-

{formatSize(audioSize)} · Listo para subir

- {/if} -
- - -
- - {#key coverResetToken} - (hasCover = true)} - /> - {/key} - {#snippet hint()} -

{hasCover ? 'Portada lista' : 'Haz clic para elegir'}

- {/snippet} -
- -
- - - {#snippet hint()} -

Se usa como nombre público de la pista en el catálogo.

- {title.length}/{LIMITS.trackTitle} - {/snippet} -
- - - - - - Contenido explícito - - - -
-
-
- - - Acepto los términos y condiciones al subir {title.trim() !== '' - ? `«${title}»` - : 'esta canción'}. - - - {#if status === 'uploading'} - -
- Subiendo y creando la pista… - un momento -
-
-
-
-
- {/if} - - {#if status === 'error'} - - {errorMsg} - {#if errorDetail}{errorDetail}{/if} - - {/if} - -
- - -
-
- {/if} -
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; + } +}; diff --git a/web-player/src/routes/upload/+server.ts b/web-player/src/routes/upload/+server.ts new file mode 100644 index 0000000..19205f7 --- /dev/null +++ b/web-player/src/routes/upload/+server.ts @@ -0,0 +1,4 @@ +import type { RequestHandler } from './$types'; +import { redirect } from '@sveltejs/kit'; + +export const GET: RequestHandler = () => redirect(308, '/library/uploads'); diff --git a/web-player/src/routes/upload/server.test.ts b/web-player/src/routes/upload/server.test.ts new file mode 100644 index 0000000..4c0efe3 --- /dev/null +++ b/web-player/src/routes/upload/server.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { GET } from './+server'; + +describe('GET /upload', () => { + it('redirects permanently to the uploads section', () => { + try { + GET({} as Parameters[0]); + expect.unreachable(); + } catch (err) { + expect(err).toMatchObject({ status: 308, location: '/library/uploads' }); + } + }); +});