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/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/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/utils/gridColumns.ts b/web-player/src/lib/utils/gridColumns.ts index 1345789..6845e6a 100644 --- a/web-player/src/lib/utils/gridColumns.ts +++ b/web-player/src/lib/utils/gridColumns.ts @@ -29,7 +29,8 @@ export const COL = { status: '7rem', 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/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/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' }); + } + }); +});