Author SHA1 Message Date
Admin 8e85af2b3b Render unknown URLs inside the app shell and move ListeningStats to the domain types
CI/CD / BackEnd (push) Successful in 1m19s
CI/CD / WebPlayer (push) Successful in 46s
CI/CD / Deploy (push) Skipped
Add catch-all routes so unknown pages 404 inside the (app) layout and unknown
/api URLs return a JSON 404 instead of redirecting to the login page. Restore
the full root error page.
2026-09-24 14:29:45 +02:00
Admin 2d23eb87c6 Update frontend docs for the new structure and fix session test typing 2026-09-24 14:06:46 +02:00
Admin e5ce63a192 Add tests for API endpoints, liked store, listen tracker and paged list 2026-09-24 14:04:43 +02:00
Admin b5c9658d65 Add tests for sessions, guards, API error details and the app layout load 2026-09-24 14:03:35 +02:00
Admin ba0a2373b5 Normalize API counters in the server mappers instead of route loaders 2026-09-24 14:02:23 +02:00
Admin b0163f6a4d Split the explore page into genre grid, top result and search results components 2026-09-24 14:01:33 +02:00
Admin 0a192645d6 Drive the theme accent from the app layout instead of the player 2026-09-24 13:59:54 +02:00
Admin 351b333529 Split the server API module into client, guards, form fields and action helpers 2026-09-24 13:59:21 +02:00
Admin 155f0e2b6a Move liked tracks state to state/ and recently played merge to player/ 2026-09-24 13:58:43 +02:00
Admin a54d1ef077 Move store-connected components out of ui/ into components/music and lint the ui boundary 2026-09-24 13:58:13 +02:00
Admin 2361b88e0a Rename the app shell components folder and move AccountMenu next to its consumers 2026-09-24 13:57:35 +02:00
Admin e7c2fd5571 Move the app shell and its data loading into the (app) route group layout 2026-09-24 13:57:02 +02:00
Admin 3126cb6dd6 web-player: regenerate package-lock with npm 10 so npm ci works on Node 22
CI/CD / BackEnd (push) Successful in 1m19s
CI/CD / WebPlayer (push) Successful in 45s
CI/CD / Deploy (push) Skipped
2026-09-24 13:16:43 +02:00
Admin 03afcc8a90 web-player: sync package-lock with missing emnapi optional dependencies
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Failing after 8s
CI/CD / Deploy (push) Skipped
2026-09-24 13:02:38 +02:00
73 changed files with 1916 additions and 539 deletions
+23 -19
View File
@@ -1,6 +1,6 @@
# Musify: web-player
Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill.
Musify's public web app (`web-player/` folder). Design and UI recipes live in this file and in `src/routes/layout.css`.
## Stack
@@ -15,7 +15,7 @@ npm run dev # dev server (port 5173)
npm run build
npm run check # svelte-check, must exit 0 errors before closing a change
npm run format # prettier --write .
npm run test # vitest run (pure functions, mappers, form parsers)
npm run test # vitest run (tests live next to the file they test)
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
```
@@ -33,23 +33,25 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
## Folder structure
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs.
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `liked.svelte.ts`, and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, and `scroll.ts`. Nothing in `state/` imports from `components/`.
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`.
- `src/lib/data/` holds client side domain helpers: albums, genres, recentlyPlayed, search.
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `ListeningStats`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs.
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `recentlyPlayed.ts` (merges `player.recentlyPlayed` with the server history), and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, `liked.svelte.ts` (the user's liked tracks), `toggle.svelte.ts` (`createToggle`), and `scroll.ts` (`restoreScroll`). Nothing in `state/` imports from `components/`.
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`, and `color.ts` (`ACCENT_HUE`). The player never imports from `theme/`: `(app)/+layout.svelte` follows `player.currentId` and calls `accent.follow`.
- `src/lib/data/` holds client side domain helpers: albums, genres, search. It never imports from `player/`.
- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`.
- `src/lib/actions/` holds Svelte actions: `clickOutside`, `onEscape`, `pressable`.
- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers.
- `src/lib/server/` is server only code (see below).
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader, and `navLinks.ts`.
- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`.
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
- `src/lib/components/ui/` holds the design system components, split into five categories:
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu.
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories:
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, TrackContextMenu, AlbumContextMenu, AccountMenu, and so on).
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, PlayAllButton, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
- `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
- Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`). Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
## Naming conventions and glossary
@@ -73,7 +75,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
## App shell
The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/scroll.ts`; don't use `window.scrollTo`/`scrollY`.
## Motion
@@ -81,7 +83,7 @@ Every animation follows one scale of three durations and one easing curve, all d
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in the root `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
## Auth
@@ -91,7 +93,7 @@ Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_RE
## Backend API
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the helpers in `$lib/server/api.ts`:
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the client in `$lib/server/api.ts`:
```ts
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session
@@ -102,8 +104,10 @@ It returns `{ data, error }` (ErrorOr style) and never throws except on network
Server helpers:
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, ...) turn DTOs into the domain types. Every load and `/api` endpoint that returns tracks, albums or playlists goes through them, so `Number()` conversions never reach the client.
- `authedAction(handler)` wraps a form action: it returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler. Use `formString`/`formFile` to read fields, `failOnError(result, message)` to turn an API error into a `fail(...)`, and `requireData(result, message)` when you also need the response data narrowed.
- `forms/` holds the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
- `api.ts` holds the client and error handling: `apiFor`, `unwrapOrError`, `failOnError` (turns an API error into a `fail(...)`), `requireData` (also narrows the response data) and `apiErrorDetail`.
- `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.
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
- Page sizes come from `$lib/config`; do not hardcode them.
+1 -2
View File
@@ -31,5 +31,4 @@ Built and run as a container by the production stack — see
[../deploy/README.md](../deploy/README.md). There the same variables come from
`deploy/.env.prod` through compose instead of from `.env`.
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md) and
in the `musify-web` skill.
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md).
+315
View File
@@ -0,0 +1,315 @@
# Plan de refactor tras la auditoría del frontend
> **Cerrado.** Todas las partes están hechas; el plan activo es `frontend-structure-plan.md`.
Plan de trabajo derivado de la auditoría del `web-player` (2026-09-24). Cada parte es un PR independiente, revisable por separado, que deja `npm run check` y `npm run lint` en verde. Antes de empezar una parte, mira el **Registro** del final.
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más). Las partes marcadas con 🔗 dependen de cambios en `Musify.Api`.
## Orden y dependencias
```
P0 Red de seguridad
└─ P1 Seguridad ─ P2 Reproductor ─ P3 Bugs de páginas ─ P4 Código muerto
└─ P5 Modelo Track único
├─ P6 Capa de servidor ─ P7 Formularios y validación
│ └─ P8 Rendimiento 🔗
└─ P9 Responsabilidades ─ P10 Componentes y páginas
└─ P11 Consistencia ─ P12 Guardarraíles y docs
```
P1 a P4 son arreglos acotados y se pueden mergear en cualquier orden. De P5 en adelante el orden importa: cada parte simplifica la siguiente.
---
## P0 · Red de seguridad (M)
Objetivo: tener tests de las funciones puras antes de tocar lógica.
- Añadir Vitest (`vitest`, script `npm run test`, incluido en `lint` o en CI).
- Tests para lo que ya es puro: `fmtTime`, `fmtDurationLong`, `plural`, `albumMeta` (`utils/format.ts`), `findTopResult` (`data/search.ts`), `findConflict`, `genreHue` (`data/genres.ts`), `pickGreeting` (`server/greeting.ts`), `parseAlbumForm` (`server/albumForm.ts`), `appendUnique`, `shuffle`.
- Extraer las operaciones de cola de `PlayerState` a funciones puras en `player/queue.ts` (`insertNext`, `append`, `move`, `nextIndex`, `previousIndex`) y testearlas. `PlayerState` pasa a llamarlas. Esto prepara P2.
**Cierre:** `npm run test` en verde y cubre las funciones anteriores.
---
## P1 · Seguridad (S)
1. **Redirección abierta con `returnTo`.**
- Nueva función `safeReturnTo(value: string | null | undefined): string` en `$lib/server/auth.ts`: acepta solo rutas que empiecen por `/` y no por `//` ni `/\`; si no, devuelve `/`.
- Aplicarla en `routes/auth/+page.server.ts` (ambos usos), en `routes/auth/login/+server.ts` (antes de guardar la cookie) y en `routes/auth/callback/+server.ts` (al leer la cookie).
2. **`SESSION_SECRET` obligatorio.** En `$lib/server/config.ts`, `sessionSecret` pasa por `required()` y exige una longitud mínima de 32 caracteres. Actualizar el comentario de `.env.example`.
3. **Validar las entradas de los endpoints `/api`.**
- `api/likes/+server.ts`: no reenviar el body tal cual; leer `trackId`, comprobar que es un string no vacío y devolver 400 si no.
- `api/listens/[id]/progress/+server.ts`: envolver `request.json()` para devolver 400 con JSON inválido en vez de 500.
- `server/image.ts`: `encodeURIComponent(id)` al construir la URL de upstream.
- `api/tracks/+server.ts`: acotar `pageSize` con un máximo (`MAX_PAGE_SIZE` en `config.ts`).
**Cierre:** `/auth?returnTo=https://example.com` acaba en `/`; arrancar sin `SESSION_SECRET` falla con un mensaje claro; tests de `safeReturnTo`.
---
## P2 · Reproductor y cola (M)
1. **Duplicados en la cola** (`player.svelte.ts` `playNext`/`appendToQueue`, `Queue.svelte`).
- `playNext` mueve la pista si ya estaba en la cola, en vez de duplicarla.
- `appendToQueue` omite las pistas que ya están pendientes.
- Con esto las claves `(track.id)` vuelven a ser únicas y `#index()` es fiable.
2. **Fin de cola.** Con repetir desactivado, al terminar la última pista el reproductor se detiene en vez de volver al principio. Si más adelante se quiere "repetir todo", `repeat` pasará a `'off' | 'all' | 'one'` (queda fuera de este plan).
3. **La cola se abre sola** (`+layout.svelte:44`). Abrirla solo la primera vez que empieza a sonar algo en la sesión (`currentId` pasa de `null` a un valor) y no volver a abrirla si el usuario la cierra.
4. **Rollback de "Me gusta"** (`liked.svelte.ts:69`). En caso de error, revertir solo la entrada de ese id, no sustituir el mapa entero.
5. **localStorage seguro.**
- Nuevo `$lib/utils/storage.ts` con `readStorage(key, fallback)` y `writeStorage(key, value)` envueltos en try/catch.
- Volumen: validar y acotar a 0–100 (hoy puede quedar `NaN`).
- Unificar claves: `musify.player.volume`, `musify.player.muted`, `musify.theme`.
- Persistir el volumen siempre, no solo cuando existe el elemento `<audio>`.
**Cierre:** añadir la misma pista dos veces a la cola no rompe nada; la cola termina; tests de `player/queue.ts` actualizados; prueba manual en el Browser pane.
---
## P3 · Bugs de páginas y datos (M)
1. **Permisos en la playlist** (`playlists/[id]`).
- El `load` devuelve `isOwner`, comparando el propietario de la playlist con `locals.user.sub`. Si la API no expone el propietario, añadirlo al DTO 🔗.
- Mostrar Editar, Eliminar y "Quitar" solo al propietario. Añadir `BackLink` como en el álbum y la mezcla, y `section: '/playlists'` cuando sea propia.
2. **Una sola fuente para las playlists del usuario.**
- `+layout.server.ts` carga las playlists del usuario con `PLAYLIST_PICKER_PAGE_SIZE` y devuelve también `totalItemCount`.
- La barra lateral muestra las 8 primeras y usa el total real en el contador.
- Los menús contextuales (inicio, explorar, mezcla) usan esa lista desde `page.data` y así explorar y mezcla dejan de pedirla aparte.
3. **Playlist destacada de inicio.**
- Nuevo endpoint `api/playlists/[id]/tracks/+server.ts`, análogo al de álbumes. "Reproducir" y "Aleatorio" cargan la lista completa en ese momento.
- La meta muestra el total real. La duración se quita hasta que la API la devuelva (P8).
- La promesa se resuelve con `{#await}` en vez de con un `$effect` que copia a estado.
4. **Borrar canción de la biblioteca con confirmación.** `ConfirmDialog` acepta `fields` (inputs ocultos) y `library` lo usa, igual que álbumes y playlists.
5. **Explorar.**
- Usar `plural()` para el número de canciones de un álbum.
- Contadores de chips y "N coincidencias" a partir de `totalItemCount` de cada respuesta, no de lo cargado.
6. **Enlace `/terms`** en la subida: quitar el enlace hasta que exista una página de términos (o crearla, si se decide).
7. **`playbackConfig.ts`:** no guardar en caché cuando la respuesta trae `error`.
8. **`ImageDropzone`:** liberar la URL anterior con `URL.revokeObjectURL` al cambiar de archivo y al desmontar.
9. **Textos.**
- "Populares esta semana" duplica "Continuar escuchando": quitar la sección hasta que haya un endpoint real de más escuchadas (o renombrarla a "Escuchado recientemente").
- `+error.svelte`: quitar la promesa de modo sin conexión.
- "Retomalo" → "Retómalo"; "Modo blanco" → "Modo claro".
**Cierre:** un no propietario no ve acciones de edición; la barra lateral cuenta bien; la destacada reproduce la lista completa.
---
## P4 · Código muerto (S)
Borrar sin sustituto:
- `player.svelte.ts`: `playPlaylist`, `playlistTracks`, `playlists`, interfaz `Playlist`, getter `isPlaying`, `playNextItem` (usar `playNext([item])`).
- `theme/palette.ts`: `Accent.gradient` (y con ello el tipo `Accent`, que queda en `string`).
- `IconButton`: el mapa `TEXT` y la prop `tone` pasan a `plain` o por defecto; `muted` y `subtle` son idénticos.
- `Artwork`: prop `fallback` y sus ramas `gradient` y `none`.
- `MediaIdentity.badge`, `FollowButton.userId`, `EqBars.size` y `EqBars.barWidth`.
- Acciones `follow` y `unfollow` en `explore/+page.server.ts` y en `user/[id]/[list=followList]/+page.server.ts`, con el `form?.message` que solo servía para ellas.
- Utilidades `media-grid` y `text-count` de `layout.css` (y su mención en `CLAUDE.md`).
- `TrackList.svelte`: el `use:enhance` redundante (queda `use:enhance`) y los `stopPropagation` de la línea 141.
- `+page.server.ts` (inicio): el `redirect` previo a `requireUser`. `+layout.server.ts`: la rama `accountUrl = null`.
- Clases que no hacen nada: `hover:text-fg-2` sobre `text-fg-2` (SegmentedControl, Sidebar, ArtistLink) y el `hidden sm:flex` anidado de `PlayerBar`.
- Separadores vacíos (`<span></span>`, `<div class="flex-1">`) en `CoverForm` y `TopBar`: sustituir por `ml-auto`.
- `sessionCookieOptions` deja de exportarse.
**Cierre:** `grep` de cada símbolo sin resultados; la app se comporta igual.
---
## P5 · Modelo `Track` único (L)
Objetivo: una sola forma de pista, normalizada en la frontera del servidor. Es el cambio que más código quita.
1. **Tipos de dominio** en `$lib/types.ts`:
```ts
export interface Track {
id: string;
title: string;
artist: string | null;
ownerUserId: string | null;
explicit: boolean;
duration: number;
listensCount: number;
}
export interface LikedTrack extends Track {
likedAt: number;
}
```
Más `Album` y `Playlist` normalizados (`trackCount: number`, `releaseYear: number | null`, ids como `string`).
2. **Mappers de servidor** en `$lib/server/mappers.ts`: `toTrack`, `toAlbum`, `toPlaylist`, `toMixTrack` (sustituye a `mixItemTrack`). Hacen todas las conversiones `Number()` y `String()`. Todos los `load` y los endpoints `/api` que devuelven pistas los usan.
3. **El reproductor usa `Track`.**
- Se eliminan `PlayerTrack`, `QueueItem`, `ApiTrackLike`, `LikeToggleInput` y `TrackListTrack` (que pasa a ser `Track & { date?: string | number }`).
- Se eliminan `toQueueItems`, `toQueueItem`, `trackFromQueueItem` y `toTrack` del cliente.
- `current` pasa a ser `Track | null` en vez del centinela `EMPTY` con `id: ''`.
4. **Páginas.** Quitar todos los `Number(...)`, los `?? undefined` y los mapeos intermedios (`listTracks`, `songRows`, `spotlightRows`, `popularTracks`…).
**Cierre:** `grep -rn "Number(" src/routes --include=*.svelte` vacío; `isExplicit` solo aparece en `mappers.ts`; tests de los mappers.
---
## P6 · Capa de servidor (M)
1. **Helpers en `$lib/server/api.ts`.**
- `apiFor(event)` sustituye los 15 `createApiClient({ fetch, accessToken: locals.accessToken ?? undefined })`.
- Exportar `type ApiClient` (hoy se redefine en `follows.ts`).
- `loginRedirect(url)`, usado por `requireUser`, `optionalUser` y `+layout.server.ts`.
2. **Envoltorio de acciones** `authedAction(handler)`: comprueba el token (401 si falta), crea `api`, lee `form` y pasa `{ api, form, params, locals }`. Sustituye el bloque `requireAccessTokenAction` + `typeof` + `formData` + `createApiClient` de las ~10 acciones.
3. **Lectura de formularios:** `formString(form, key)` y `formFile(form, key)` (devuelve `null` si el archivo viene vacío). Sustituyen los 17 `String(form.get(...) ?? '')` y los `instanceof File && size > 0`.
4. **Nombres.**
- `unwrapOrFail` → `failOnError` (no desenvuelve nada).
- Desestructurar siempre como `const { data, error } = ...` y usar `catch (err)`.
- La acción `rename` de playlist pasa a `edit` y devuelve `{ edited: true }`, como la de álbum.
5. **Sin aserciones no nulas:** quitar `urlsResult.data!` y `trackResult.data!` de `upload/+page.server.ts` usando el patrón de `failOnError` que estrecha el tipo.
6. **`+layout.server.ts`:** una sola función para las tres cargas con fallback, sin `try/catch` duplicado (openapi-fetch solo lanza con error de red).
7. **Tamaños de página:** todos los `pageSize` salen de `config.ts` (quitar `50`, `200`, `500` sueltos y el `PAGE_SIZE` local de `library`). Quitar el alias `EXPLORE_PAGE_SIZE as PAGE_SIZE`.
**Cierre:** ninguna acción repite la plantilla de autenticación; `grep "createApiClient(" src/routes` vacío.
---
## P7 · Formularios y validación compartida (M)
1. **Límites en un único sitio:** `$lib/validation.ts` (compartido entre cliente y servidor):
```ts
export const LIMITS = {
playlistName: 100,
playlistDescription: 300,
albumTitle: 200,
albumDescription: 256,
trackTitle: 100
} as const;
```
Antes de fijarlos, comprobar los validadores de `Musify.Api` y usar sus valores. `ALBUM_EARLIEST_YEAR` se mueve aquí.
2. **Parsers de servidor** en `$lib/server/forms/`: `parseAlbumForm` (existente), `parsePlaylistForm` (nuevo, sustituye el código duplicado de `create` y `edit`), `parseTrackUploadForm` (sacado de `upload/+page.server.ts`). Todos validan con `LIMITS`.
3. **Portadas:** `uploadOptionalCover(api, endpoint, file)` sustituye los 4 bloques `if (cover ...) uploadPresignedImage(...)`.
4. **Formularios de cliente:** `AlbumForm`, `PlaylistForm` y la subida usan `LIMITS` en `maxlength` y en el contador. Añadir el contador que falta en `AlbumForm` para que los tres se comporten igual.
5. `formatSize` de la subida pasa a `utils/format.ts`.
**Cierre:** cambiar un límite en `LIMITS` actualiza cliente y servidor; tests de los parsers.
---
## P8 · Rendimiento 🔗 (M, parte en backend)
1. **Backend:** añadir `trackCount` y `durationSeconds` a la respuesta de playlist.
2. **Frontend:**
- `playlists/+page.server.ts` deja de pedir hasta 500 pistas por playlist.
- `user/[id]/+page.server.ts` deja de hacer una petición por playlist.
- La destacada de inicio recupera la duración real.
3. **Explorar:** cachear `/genres` en `$lib/server/genres.ts` (como `playbackConfig`, con TTL de unos minutos) y lanzar todas las peticiones en paralelo en vez de esperar primero a los géneros.
4. **Añadir álbum a playlist** (`playlistActions.ts`): hoy hace un POST por pista en serie y se corta en 200. Pedir un endpoint de inserción múltiple 🔗; mientras tanto, paralelizar con un límite de concurrencia.
**Cierre:** la página de playlists hace una sola petición a la API; el perfil hace dos.
---
## P9 · Separación de responsabilidades en `lib/` (L)
1. **Dividir `player.svelte.ts`:**
- `player/queue.ts`: operaciones de cola (de P0).
- `player/stream.ts`: `resolveStream(id)` (fetch de `/stream`, ticket, `listenId`) y `readErrorMessage`.
- `player/mediaSession.ts`: registro de acciones y sincronización de metadatos y estado.
- `player/progressClock.ts`: el bucle `requestAnimationFrame` de progreso suave.
- `player/actions.ts`: `playAllOrToggle`, `playShuffled`, `isQueueCurrent`.
- `player/player.svelte.ts`: solo el estado y la orquestación.
2. **Acento como número.**
- Nuevo `theme/accent.svelte.ts`: caché, `extractAccent` y el tono actual como `number`.
- Ahí mismo vive el fundido de tono que hoy está en `+layout.svelte` (`animateThemeHue()`).
- Se elimina `parseHue` (ya no hay que leer el tono de una cadena `oklch(...)`).
3. **Dividir `state/navigation.svelte.ts`:**
- `state/history.svelte.ts`: `trackNavigation`, `previousPage`.
- `state/scroll.ts`: `restoreScroll`.
- `utils/hrefs.ts`: `searchHref`, `genreHref` y las URLs de portada (`trackCover(id, size)`, `playlistCover(playlist, size)` con el `v=updatedAt`, `albumCover(id, size)`), que hoy se escriben a mano en más de 15 sitios.
- `components/layout/navLinks.ts`: `appNavLinks` e `isNavActive` (sustituye el `isActive` duplicado de Sidebar y TabsBar).
4. **Menús.** `contextMenuPosition` y `MenuPosition` pasan a `utils/menuPosition.ts`, sin `preventDefault()` dentro (lo hace quien llama). `state/menus.svelte.ts` deja de importar de un componente.
5. **Movimientos de archivos:**
- `data/collections.ts` → `utils/collections.ts`.
- `player/queuePanel.svelte.ts` y `state/playlists.svelte.ts` → `state/panels.svelte.ts` (`queuePanel`, `createPlaylistModal`).
- `data/search.ts`: claves internas (`tracks`, `albums`, `playlists`, `users`) y etiquetas aparte en un mapa de textos.
6. Tipar `+layout.svelte` con `interface Props`.
**Cierre:** `player.svelte.ts` por debajo de ~250 líneas; ningún archivo de `state/` importa de `components/`.
---
## P10 · Componentes extraídos y páginas más finas (L)
1. **Menú contextual.**
- `state/menu.svelte.ts` con `createMenu<T>()` genérico: sustituye `createTrackMenu` y el `albumMenu` hecho a mano de explorar.
- Componente `TrackContextMenu` que encapsula el bloque de 15 líneas repetido en inicio, explorar y mezcla. `AlbumContextMenu` para explorar.
2. **`PlayAllButton {items}`** con el "Reproducir/Pausar" de álbum, playlist, mezcla, me gusta y la portada de inicio.
3. **`createPagedList(fetcher)`** en `state/pagedList.svelte.ts` para la paginación infinita de explorar y de seguidores (estado, `appendUnique`, `hasMore` y un estado de error visible en vez de tragarse el fallo).
4. **Primitivas.**
- `Button`, `IconButton`, `Chip` y `MenuItem` usan `<svelte:element this={href ? 'a' : 'button'}>` en vez de duplicar las dos ramas.
- Acción `use:onEscape` para `Modal`, `ContextMenu` y `AccountMenu`.
- `AccountMenu` incluye su propio disparador con el `Avatar`; `TopBar` y `MobileHeader` dejan de duplicarlo.
- `Artwork` y `Avatar` usan `{#key src}` en vez del `$effect` que reinicia `failed`.
5. **Páginas.**
- Quitar los alias `const x = $derived(data.x)` y las funciones de una línea (`playSpotlight`, `shuffleSpotlight`, `openContextMenu`, `stepClass`…).
- Sustituir los `$effect` que copian `data` a estado local por `$derived` escribible cuando sea posible.
- Navegación con enlaces reales: `ListRow href` en vez de `onclick={() => goto(...)}`, y la destacada de inicio como `<a>`.
- `upload`: el indicador de pasos solo refleja estados reales (o se simplifica a "subiendo / listo").
**Cierre:** ninguna página repite el bloque del menú ni el botón de reproducir; las tarjetas se pueden abrir con clic central.
---
## P11 · Consistencia (M, mecánico)
1. **Imports:** siempre `$lib/...`, nunca `../`. Se hace cumplir en P12.
2. **Convenciones de nombres** (se documentan en `CLAUDE.md`):
- Mapas de variantes en constantes de módulo en mayúsculas (`SIZE`, `VARIANT`).
- Errores: `error` al desestructurar y `err` en `catch`.
- Manejadores de reproducción: `playFrom(index)`, `playAll()`.
- Acciones: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
3. **Glosario de interfaz:** "Playlist" (nunca "Lista"), "Mezcla" (nunca "Mix"), "Modo claro". Duraciones totales siempre con `fmtDurationLong`.
4. **Tokens.**
- Iconos siempre con `size-icon-*`; añadir `size-icon-2xs` si hace falta para los de 14 px.
- Nada de `size={16}` en iconos Lucide.
- No usar tokens de icono como espaciado (`pb-icon-md`, `gap-icon-md`).
- Anchos siempre con clase (`w-(--mf-sidebar-w)`), no con `style`.
- Nueva utilidad `focus-ring` usada por `Button`, `IconButton`, `Chip` y los enlaces interactivos.
5. **Rutas:** mover `routes/+page.svelte` y `+page.server.ts` (inicio) dentro de `(app)`, para que el grupo tenga sentido: todo lo de la app dentro y `auth/` fuera.
**Cierre:** `npm run lint` en verde con las reglas nuevas de P12.
---
## P12 · Guardarraíles y documentación (S)
1. **ESLint:**
- `no-restricted-imports` que prohíba `../*`.
- `@typescript-eslint/no-non-null-assertion`.
- `svelte/no-unused-props`.
2. **`scripts/check-tokens.mjs`:** fallar con `size={N}` en iconos y con `size-N` sobre componentes Lucide.
3. **Opcional:** `knip` para detectar exportaciones y archivos sin uso.
4. **Documentación:**
- `CLAUDE.md`: nueva estructura de carpetas, helpers de servidor (`apiFor`, `authedAction`, mappers), convenciones de nombres y glosario.
- `docs/paginas.md`: está desactualizado (menciona el grupo `(marketing)` y la landing, que ya no existen).
**Cierre:** las reglas nuevas fallan si se reintroduce cualquiera de los patrones eliminados.
---
## Registro
| Parte | Estado | PR / notas |
| --------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
| P0 Red de seguridad | ✅ | Vitest + 37 tests; `player/queue.ts` extraído. `lint` ahora ejecuta `test` |
| P1 Seguridad | ✅ | Hecho en la rama `claude/frontend-audit-plan-4f9432` |
| P2 Reproductor y cola | ✅ | Sin probar en navegador (API caída) |
| P3 Bugs de páginas | ✅ | Backend: `ownerUserId` en playlist. Sin probar en navegador |
| P4 Código muerto | ✅ | Se mantiene el redirect a `/explore` de inicio (cubre escucha anónima) |
| P5 Modelo Track único | ✅ | `Paged<T>`, `Mix`, `PlaylistSummary` añadidos. Sin probar en navegador |
| P6 Capa de servidor | ✅ | `failOnError` + `requireData`; `parseAlbumForm` usa `formString` |
| P7 Formularios y validación | ✅ | Límites según las columnas de BD del backend (playlist 50/256, pista 200) |
| P8 Rendimiento 🔗 | ✅ | Backend: `trackCount` y `durationSeconds` en playlist. Añadir álbum usa `POST /playlists/{id}/albums/{albumId}` (bulk) |
| P9 Responsabilidades | ✅ | `player.svelte.ts` queda en ~320 líneas |
| P10 Componentes y páginas | ✅ | Quedan algunos alias `$derived(data.x)`; `{#key}` sustituido por `failedSrc` |
| P11 Consistencia | ✅ | |
| P12 Guardarraíles y docs | ✅ | `knip` no añadido (opcional) |
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
+185
View File
@@ -0,0 +1,185 @@
# Plan de estructura del frontend
Plan derivado de la revisión de estructura del `web-player` (2026-09-24), hecha después de cerrar `frontend-audit-plan.md` (P0–P12). Cada parte es un PR independiente que deja `npm run check` y `npm run lint` en verde y no cambia el comportamiento visible. Antes de empezar una parte, mira el **Registro** del final.
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más).
## Orden y dependencias
```
E1 Layout del grupo (app)
└─ E2 Carpeta shell
└─ E3 Frontera de ui/ ─ E6 Explorar en componentes
E4 Ubicación de estados (independiente)
E5 Capa de servidor (independiente)
E7 Acento fuera del reproductor (después de E1)
E9 Tests (antes o junto con E1 y E5)
E8 Documentación (al final)
```
E1 → E2 → E3 van en orden porque tocan los mismos imports. E4 y E5 se pueden hacer en cualquier momento. Conviene hacer E9 primero, o al menos sus puntos 1 y 2, para que E1 y E5 muevan el código de sesión con tests que detecten si algo se rompe.
---
## E1 · Layout del grupo `(app)` (M)
Problema: el grupo `(app)` no tiene `+layout`. El shell y las cargas de la app están en la raíz, que distingue la pantalla de login a mano (`isAuthPage` en `+layout.svelte`, `PUBLIC_PATHS` en `+layout.server.ts`). Por eso `/auth` también consulta `playbackConfig`, y `+error.svelte` se pinta fuera del shell (un 404 dentro de la app pierde la barra lateral y el reproductor).
1. **`routes/+layout.svelte`** se queda con lo global: fuentes, `theme.css`, `layout.css`, favicon, el `<style>` inicial del tema, `animateThemeHue` y `<DialogHost />`. Renderiza `children` sin envoltorio.
2. **`routes/+layout.server.ts`** desaparece (o queda vacío). Si alguna ruta fuera de `(app)` necesita `user`, lo lee de `locals` en su propio `load`.
3. **Nuevo `routes/(app)/+layout.server.ts`** con todo lo que hoy hay en la raíz: `getAllowAnonymousListening`, el guard (`loginRedirect` si no hay usuario ni escucha anónima), `accountUrl`, playlists del usuario, me gusta y última pista. Sin `PUBLIC_PATHS`.
4. **Nuevo `routes/(app)/+layout.svelte`** con el shell: backdrop, `Sidebar`, `MobileHeader`, `TopBar`, `<main>` con el `{#key}`, `Queue`, `PlayerDock`, el modal de crear playlist, la hidratación de `liked` y `player`, la apertura automática de la cola y el atajo de la barra espaciadora. Desaparecen `isAuthPage` y la rama `!data.user && !data.allowAnonymousListening`, porque el guard ya ha redirigido.
5. **Nuevo `routes/(app)/+error.svelte`** (el contenido del actual) para que los errores dentro de la app se vean con el shell. El `+error.svelte` de la raíz se queda como fallback mínimo para errores fuera del grupo.
6. **No quitar** los `requireUser`/`optionalUser` de las páginas ni `authedAction`. Los `load` de página se ejecutan en paralelo con los del layout, y las acciones de formulario no ejecutan el `load` del layout, así que el guard del layout no basta.
**Cierre:** `grep -rn "isAuthPage\|PUBLIC_PATHS" src` vacío; `/auth` no llama a la API; un 404 en `/albums/xxx` se ve dentro del shell; prueba manual con sesión, sin sesión y con escucha anónima.
---
## E2 · Carpeta `shell` (S)
Problema: hay dos carpetas `layout` con significados distintos (`components/layout/` es el shell de la app y `components/ui/layout/` son piezas para montar páginas). `AccountMenu` está en `ui/overlay/`, pero solo lo usan `TopBar` y `MobileHeader`.
1. Renombrar `components/layout/` a `components/shell/` (`Sidebar`, `TopBar`, `TabsBar`, `MobileHeader`, `navLinks.ts`).
2. Mover `components/ui/overlay/AccountMenu.svelte` a `components/shell/AccountMenu.svelte`.
3. Actualizar imports en `(app)/+layout.svelte` y `components/player/PlayerDock.svelte`.
**Cierre:** `grep -rn "components/layout/\|overlay/AccountMenu" src` vacío.
---
## E3 · Frontera de `components/ui/` (M)
Problema: `ui/` se presenta como el design system, pero cuatro componentes dependen del estado global de la app. `TrackList` y `PlayAllButton` importan `player`. `TrackContextMenu` y `AlbumContextMenu` importan `player`, y `AlbumContextMenu` también importa `$lib/data/albums`. El resto de `ui/media/` son componentes de presentación sin estado (reciben props), y así deben quedarse.
1. **Nueva carpeta `components/music/`** para los componentes que conectan la interfaz con el estado de la app:
- `ui/media/TrackList.svelte` → `music/TrackList.svelte`
- `ui/media/PlayAllButton.svelte` → `music/PlayAllButton.svelte`
- `ui/overlay/TrackContextMenu.svelte` → `music/TrackContextMenu.svelte`
- `ui/overlay/AlbumContextMenu.svelte` → `music/AlbumContextMenu.svelte`
2. **Regla de ESLint:** un bloque con `files: ['src/lib/components/ui/**']` y `no-restricted-imports` que prohíba `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `$lib/components/music/*`, `$lib/components/shell/*` y `$lib/components/player/*`. Hay que repetir en ese bloque el patrón `../*` del bloque general, porque la configuración de un override sustituye a la del bloque general, no se suma.
3. Comprobar que la regla pasa sin excepciones. `ui/` puede seguir importando `$lib/state/*` (historial, diálogos), `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` y `$lib/types`.
**Cierre:** `npm run lint` en verde; si un componente de `ui/` importa `player`, el lint falla.
---
## E4 · Ubicación de estados (S)
1. `player/liked.svelte.ts` → `state/liked.svelte.ts`. Guarda los me gusta del usuario, no el estado del reproductor, y lo usan `Sidebar`, `TrackInfo`, el layout y las páginas.
2. `data/recentlyPlayed.ts` → `player/recentlyPlayed.ts`. Solo mezcla `player.recentlyPlayed` con el historial, y hoy es el único archivo de `data/` que depende del reproductor.
**Cierre:** `grep -rn "player/liked\|data/recentlyPlayed" src` vacío; `data/` no importa de `player/`.
---
## E5 · Capa de servidor (S–M)
Problema: `server/api.ts` mezcla el cliente de la API, los guards de sesión, la lectura de formularios y el envoltorio de acciones. Los parsers de `server/forms/` importan `api.ts` solo para usar `formString`/`formFile`.
1. **`server/api.ts`** se queda con el cliente y el manejo de errores: `createApiClient`, `ApiClient`, `apiFor`, `unwrapOrError`, `apiErrorDetail`, `failOnError`, `requireData`.
2. **Nuevo `server/guards.ts`:** `loginRedirect`, `requireUser`, `optionalUser` (14 imports en `routes/` más `(app)/+layout.server.ts`).
3. **Nuevo `server/forms/fields.ts`:** `formString`, `formFile`. Los parsers de `forms/` lo importan desde ahí.
4. **Nueva carpeta `server/actions/`:**
- `authedAction.ts` con `authedAction` y `ActionContext`.
- `followActions.ts` → `actions/follow.ts`.
- `playlistActions.ts` → `actions/playlist.ts`.
**Cierre:** `server/forms/` no importa `server/api`; `api.ts` no importa `redirect` ni `RequestEvent` salvo para `apiFor`.
---
## E6 · Explorar en componentes (M)
Problema: `(app)/explore/+page.svelte` (393 líneas) resuelve tres vistas en una: la rejilla de géneros, la vista de un género y los resultados de búsqueda (mejor resultado y cuatro grupos).
1. Sacar componentes junto a la página (SvelteKit ignora los archivos sin `+` dentro de `routes/`):
- `explore/GenreGrid.svelte`: la rejilla de géneros y el estado vacío.
- `explore/TopResult.svelte`: el snippet `topResultBody` y `playTopResult`.
- `explore/SearchResults.svelte`: los cuatro grupos, `capped`, `showGroup` y el `InfiniteScroll` de pistas.
2. `+page.svelte` queda como selector de vista: cabecera, chips, mensaje de "sin resultados" y los dos menús contextuales.
3. El estado compartido (`createPagedList`, `trackMenu`, `albumMenu`) se crea en la página y se pasa por props; los componentes no leen `page.data`.
**Cierre:** `explore/+page.svelte` por debajo de ~150 líneas; ninguna vista nueva supera las ~150.
---
## E7 · Acento fuera del reproductor (S)
Problema: `player.svelte.ts` importa `theme/accent.svelte` y en `#announce` llama a `accent.follow(...)`, así que el estado del reproductor depende del tema visual.
1. Quitar `accent` y `trackCover` de `player.svelte.ts`; `#announce` solo actualiza `mediaSession`.
2. En `(app)/+layout.svelte`, un `$effect` que siga a `player.currentId` y llame a `accent.follow(trackCover(id, 'small'), () => player.currentId === id)`.
3. Comprobar que la hidratación de la última pista también actualiza el acento (el efecto depende de `currentId`, así que debería).
**Cierre:** `player/` no importa de `theme/`; el color cambia al cambiar de canción y al cargar la página con una última pista.
---
## E8 · Documentación y restos (S)
1. **`CLAUDE.md`:**
- Estructura de carpetas con `components/shell/`, `components/music/`, la regla de frontera de `ui/`, `state/liked.svelte.ts`, `server/guards.ts`, `server/forms/fields.ts` y `server/actions/`.
- Documentar lo que falta: `lib/actions/` (`clickOutside`, `onEscape`, `pressable`), `state/toggle.svelte.ts` y `theme/color.ts`.
- Corregir la referencia a `$lib/state/navigation.svelte.ts` en "App shell" (ahora es `restoreScroll` en `$lib/state/scroll.ts`) y describir el shell en `(app)/+layout.svelte`.
- La skill `musify-web` que cita la introducción no está en `web-player/.claude/skills`: añadirla o quitar la referencia.
2. **Conversiones fuera de los mappers.** La regla de P5 dice que los `Number(...)` sobre DTOs solo se hacen en `server/mappers.ts`, pero quedan varios en `routes/`:
- `(app)/+page.server.ts`: las estadísticas de escucha (nuevo `toListeningStats`, con test) y `totalItemCount` de las playlists.
- `(app)/explore/+page.server.ts`: `albumsTotal` y `usersTotal`.
- `(app)/user/[id]/+page.server.ts`: `followersCount`.
- La carga de playlists del layout (que tras E1 está en `(app)/+layout.server.ts`): `totalItemCount`.
Los totales pueden salir de `toPage(...).totalItemCount` o de un helper `toCount` en los mappers.
3. **Planes:** marcar `frontend-audit-plan.md` como cerrado (o archivarlo) y dejar este como el plan activo.
**Cierre:** cada ruta citada en `CLAUDE.md` existe; `grep -rn "Number(" src/routes` solo devuelve lecturas de parámetros o formularios.
---
## E9 · Tests de los arreglos y del código con efectos (M)
Los tests se quedan junto al archivo que prueban (`x.ts` + `x.test.ts`); no se crea una carpeta `tests/`. Hoy cubren bien la lógica pura (formatos, mappers, parsers, cola, búsqueda), pero no el código con fetch, estado o tiempo, ni los arreglos de seguridad de P1 y P2, que pueden volver sin que nada falle.
1. **Sesión y guards.**
- `server/auth.ts`: `encodeSession` → `decodeSession` devuelve la misma sesión; un token manipulado, cifrado con otra clave o vacío devuelve `null`. Para el secreto, usar `vi.mock('$env/dynamic/private')` con un `SESSION_SECRET` de prueba, no el `.env` local (en CI no existe).
- Guards (`requireUser`, `optionalUser`, `loginRedirect`; en `server/guards.ts` tras E5): redirigen a `/auth?returnTo=…` con la ruta y la query codificadas, y dejan pasar cuando toca.
- Tras E1, el `load` de `(app)/+layout.server.ts`: redirige sin sesión ni escucha anónima, devuelve datos vacíos en modo anónimo y no llama a la API sin usuario.
2. **Endpoints `/api` (arreglos de P1).** Llamar al `RequestHandler` con un evento falso y un `fetch` simulado:
- `api/likes`: 401 sin token, 400 con `trackId` ausente, vacío o no string, 502 si la API falla.
- `api/listens/[id]/progress`: 400 con JSON inválido, con `playedSeconds` negativo, no finito o no numérico; 404 si la API devuelve 404.
- `api/tracks`: `pageSize` se limita a `MAX_PAGE_SIZE` y `pageNumber` inválido pasa a 1.
3. **Rollback de "Me gusta" (`liked.svelte.ts`, arreglo de P2).** Con `fetch` simulado que falla: se revierte solo esa pista y se conservan los cambios hechos mientras tanto en otras. También los casos de dar y quitar el me gusta, y que `hydrate` solo se aplica una vez.
4. **`ListenTracker`.** Con `vi.useFakeTimers()` y `fetch` simulado:
- Suma solo los avances de hasta 2,5 s; un salto mayor (seek) o hacia atrás no cuenta.
- `interrupt()` hace que el siguiente `tick` no sume.
- Envía a los 30 s y en `flush()`; no reenvía si no hay progreso nuevo.
- Si el envío falla, el siguiente `flush()` vuelve a enviar el total.
- `begin()` envía lo pendiente de la escucha anterior antes de empezar la nueva.
5. **`createPagedList`.** Añade la página siguiente sin duplicados, actualiza `hasMore`, ignora un `loadMore` con otro en curso y, si falla, marca `error` y conserva los elementos. `reset` limpia el error.
6. **`apiErrorDetail`.** Mensajes de `errors` (varios campos, arrays), después `detail`, después `title`, y `undefined` para valores que no son objeto o sin texto.
Fuera de alcance, a propósito: tests de componentes (sobre todo presentación, ya vigilada por `check-tokens`), utilidades triviales (`storage`, `menuPosition`, `navLinks`), `mediaSession`, `progressClock`, `palette` (necesita canvas) y `player.svelte.ts` entero (depende de `<audio>`; su lógica está en `queue.ts`, que ya tiene tests). E2E con Playwright queda para cuando CI pueda levantar el backend y Zitadel.
**Cierre:** cada punto anterior tiene su `*.test.ts` junto al módulo; `npm run test` en verde sin `.env` local; volver a introducir cualquiera de los bugs de P1/P2 cubiertos hace fallar un test.
---
## Registro
| Parte | Estado | PR / notas |
| ------------------------------- | ------ | ---------------------------------------- |
| E1 Layout del grupo (app) | ✅ | |
| E2 Carpeta shell | ✅ | |
| E3 Frontera de ui/ | ✅ | Regla de ESLint sobre `components/ui/**` |
| E4 Ubicación de estados | ✅ | |
| E5 Capa de servidor | ✅ | |
| E6 Explorar en componentes | ✅ | `+page.svelte` queda en ~180 líneas |
| E7 Acento fuera del reproductor | ✅ | |
| E8 Documentación y restos | ✅ | |
| E9 Tests | ✅ | 121 tests, sin `.env` local |
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
+28
View File
@@ -48,6 +48,34 @@ export default ts.config(
'@typescript-eslint/no-unused-expressions': 'off'
}
},
{
files: ['src/lib/components/ui/**'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['../*'],
message: 'Use $lib/... instead of relative parent imports.'
},
{
group: [
'$lib/player/*',
'$lib/data/*',
'$lib/server/*',
'$lib/components/music/*',
'$lib/components/shell/*',
'$lib/components/player/*'
],
message:
'components/ui is the presentation layer: it cannot depend on app state, data or other component layers.'
}
]
}
]
}
},
{
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
}
+70 -26
View File
@@ -40,12 +40,36 @@
"vitest": "^5.0.1"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -335,21 +359,24 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
"integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@oxc-project/types": {
@@ -598,6 +625,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
@@ -1119,7 +1156,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9",
@@ -1171,7 +1207,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"license": "MIT",
"peer": true,
"dependencies": {
"deepmerge": "^4.3.1",
"magic-string": "^0.30.21",
@@ -1409,6 +1444,27 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
@@ -1504,9 +1560,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1563,7 +1619,6 @@
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1625,7 +1680,6 @@
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
@@ -1733,7 +1787,6 @@
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -1875,7 +1928,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2137,7 +2189,6 @@
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"workspaces": [
"packages/*"
],
@@ -3226,7 +3277,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -3360,7 +3410,6 @@
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3377,7 +3426,6 @@
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -3548,7 +3596,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -3704,7 +3751,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3936,7 +3982,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3998,7 +4043,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -5,7 +5,7 @@
import { fetchAlbumTracks } from '$lib/data/albums';
import type { Menu } from '$lib/state/menu.svelte';
import type { Playlist } from '$lib/types';
import ContextMenu from './ContextMenu.svelte';
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
interface Props {
menu: Menu<string>;
@@ -3,7 +3,7 @@
import { player } from '$lib/player/player.svelte';
import type { Menu } from '$lib/state/menu.svelte';
import type { Playlist, Track } from '$lib/types';
import ContextMenu from './ContextMenu.svelte';
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
interface Props {
menu: Menu<Track>;
@@ -25,8 +25,8 @@
import { player } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/utils/format';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from './MediaIdentity.svelte';
import EqBars from './EqBars.svelte';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
interface RowAction {
@@ -1,6 +1,6 @@
<script lang="ts">
import PlayerBar from './PlayerBar.svelte';
import TabsBar from '$lib/components/layout/TabsBar.svelte';
import TabsBar from '$lib/components/shell/TabsBar.svelte';
import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types';
@@ -1,6 +1,6 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import { liked } from '$lib/state/liked.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/media/ArtistLink.svelte';
@@ -6,8 +6,8 @@
import Settings from '@lucide/svelte/icons/settings';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './MenuItem.svelte';
import GlassMenu from './GlassMenu.svelte';
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
import GlassMenu from '$lib/components/ui/overlay/GlassMenu.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
import { clickOutside } from '$lib/actions/clickOutside';
import { onEscape } from '$lib/actions/onEscape';
@@ -4,7 +4,7 @@
import Folder from '@lucide/svelte/icons/folder';
import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte';
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import Logo from '$lib/components/ui/primitives/Logo.svelte';
@@ -2,7 +2,7 @@
import { page } from '$app/state';
import { appNavLinks, isNavActive } from './navLinks';
import { playlistCover } from '$lib/utils/hrefs';
import { liked } from '$lib/player/liked.svelte';
import { liked } from '$lib/state/liked.svelte';
import { createPlaylistModal } from '$lib/state/panels.svelte';
import type { SessionUser } from '$lib/types';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
@@ -7,7 +7,7 @@
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte';
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ListenTracker } from './listenTracker';
let fetchMock: ReturnType<typeof vi.fn>;
function sentSeconds(call: number): number {
const init = fetchMock.mock.calls[call][1] as RequestInit;
return JSON.parse(init.body as string).playedSeconds;
}
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function startedTracker(id = 'l1') {
const tracker = new ListenTracker();
tracker.begin(id);
tracker.tick(0);
return tracker;
}
describe('ListenTracker', () => {
it('sums small forward advances', () => {
const tracker = startedTracker();
tracker.tick(1);
tracker.tick(2);
tracker.flush();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(sentSeconds(0)).toBe(2);
});
it('ignores seeks forward and backwards', () => {
const tracker = startedTracker();
tracker.tick(1);
tracker.tick(60);
tracker.tick(10);
tracker.tick(11);
tracker.flush();
expect(sentSeconds(0)).toBe(2);
});
it('does not count the tick after interrupt()', () => {
const tracker = startedTracker();
tracker.tick(1);
tracker.interrupt();
tracker.tick(2);
tracker.tick(3);
tracker.flush();
expect(sentSeconds(0)).toBe(2);
});
it('sends after 30 seconds of wall time', () => {
const tracker = startedTracker();
tracker.tick(1);
expect(fetchMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(30_000);
tracker.tick(2);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(sentSeconds(0)).toBe(2);
});
it('does not resend without new progress', () => {
const tracker = startedTracker();
tracker.tick(1);
tracker.flush();
tracker.flush();
expect(fetchMock).toHaveBeenCalledTimes(1);
tracker.tick(2);
tracker.flush();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sentSeconds(1)).toBe(2);
});
it('sends nothing when there is no listen id', () => {
const tracker = new ListenTracker();
tracker.begin(null);
tracker.tick(0);
tracker.tick(1);
tracker.flush();
expect(fetchMock).not.toHaveBeenCalled();
});
it('resends the total after a failed request', async () => {
fetchMock.mockRejectedValueOnce(new Error('offline'));
const tracker = startedTracker();
tracker.tick(1);
tracker.flush();
await vi.runAllTimersAsync();
tracker.flush();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sentSeconds(1)).toBe(1);
});
it('flushes the previous listen when a new one begins', () => {
const tracker = startedTracker('old');
tracker.tick(1);
tracker.begin('new');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/listens/old/progress');
tracker.flush();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -1,9 +1,7 @@
import { browser } from '$app/environment';
import { accent } from '$lib/theme/accent.svelte';
import { dialog } from '$lib/state/dialog.svelte';
import type { Track } from '$lib/types';
import { readStorage, writeStorage } from '$lib/utils/storage';
import { trackCover } from '$lib/utils/hrefs';
import { ListenTracker } from './listenTracker';
import { mediaSession } from './mediaSession';
import { ProgressClock } from './progressClock';
@@ -140,7 +138,6 @@ class PlayerState {
}
#announce(track: Track) {
accent.follow(trackCover(track.id, 'small'), () => this.currentId === track.id);
mediaSession.setTrack(track);
}
@@ -0,0 +1,27 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { apiFor, type ApiClient } from '$lib/server/api';
export interface ActionContext {
api: ApiClient;
form: FormData;
params: Record<string, string>;
locals: App.Locals;
url: URL;
}
export function authedAction<R>(
handler: (context: ActionContext) => R | Promise<R>,
unauthorizedMessage = 'Inicia sesión.'
) {
return async (event: RequestEvent) => {
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
const form = await event.request.formData();
return handler({
api: apiFor(event),
form,
params: event.params as Record<string, string>,
locals: event.locals,
url: event.url
});
};
}
@@ -1,5 +1,5 @@
import { fail } from '@sveltejs/kit';
import { authedAction } from '$lib/server/api';
import { authedAction } from '$lib/server/actions/authedAction';
const setFollowing = (follow: boolean) =>
authedAction(async ({ api, params }) => {
@@ -1,5 +1,6 @@
import { fail } from '@sveltejs/kit';
import { authedAction, formString } from '$lib/server/api';
import { formString } from '$lib/server/forms/fields';
import { authedAction } from '$lib/server/actions/authedAction';
export const addTrackAction = authedAction(async ({ api, form, params }) => {
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { apiErrorDetail } from './api';
describe('apiErrorDetail', () => {
it('joins the validation messages from errors', () => {
expect(
apiErrorDetail({ errors: { Name: ['Too short.', 'Required.'], Genre: ['Invalid.'] } })
).toBe('Too short. Required. Invalid.');
});
it('flattens arrays and ignores non-string entries', () => {
expect(apiErrorDetail({ errors: { A: ['one', 2, null], B: 'two' } })).toBe('one two');
});
it('falls back to detail when errors has no text', () => {
expect(apiErrorDetail({ errors: {}, detail: 'Bad request.' })).toBe('Bad request.');
});
it('falls back to title after detail', () => {
expect(apiErrorDetail({ detail: '', title: 'Conflict' })).toBe('Conflict');
expect(apiErrorDetail({ title: 'Conflict' })).toBe('Conflict');
});
it('returns undefined for non-objects and empty objects', () => {
expect(apiErrorDetail(undefined)).toBeUndefined();
expect(apiErrorDetail(null)).toBeUndefined();
expect(apiErrorDetail('boom')).toBeUndefined();
expect(apiErrorDetail({})).toBeUndefined();
expect(apiErrorDetail({ detail: 3, title: '' })).toBeUndefined();
});
});
+1 -55
View File
@@ -1,7 +1,6 @@
import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
import { error, fail, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema';
import type { SessionUser } from '$lib/types';
import { apiConfig } from '$lib/server/config';
interface ApiClientOptions {
@@ -23,59 +22,6 @@ export function apiFor({ fetch, locals }: Pick<RequestEvent, 'fetch' | 'locals'>
return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
}
export function loginRedirect(url: URL): never {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) loginRedirect(url);
return locals.user;
}
export function optionalUser(
locals: App.Locals,
url: URL,
allowAnonymousListening: boolean
): SessionUser | null {
if (locals.user) return locals.user;
if (!allowAnonymousListening) loginRedirect(url);
return null;
}
export function formString(form: FormData, key: string): string {
return String(form.get(key) ?? '');
}
export function formFile(form: FormData, key: string): File | null {
const value = form.get(key);
return value instanceof File && value.size > 0 ? value : null;
}
interface ActionContext {
api: ApiClient;
form: FormData;
params: Record<string, string>;
locals: App.Locals;
url: URL;
}
export function authedAction<R>(
handler: (context: ActionContext) => R | Promise<R>,
unauthorizedMessage = 'Inicia sesión.'
) {
return async (event: RequestEvent) => {
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
const form = await event.request.formData();
return handler({
api: apiFor(event),
form,
params: event.params as Record<string, string>,
locals: event.locals,
url: event.url
});
};
}
export function unwrapOrError<T>(
result: { data?: T; error?: unknown; response?: Response },
message: string,
+1 -1
View File
@@ -1,4 +1,4 @@
import { formString } from '$lib/server/api';
import { formString } from '$lib/server/forms/fields';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
interface AlbumFormBody {
@@ -0,0 +1,8 @@
export function formString(form: FormData, key: string): string {
return String(form.get(key) ?? '');
}
export function formFile(form: FormData, key: string): File | null {
const value = form.get(key);
return value instanceof File && value.size > 0 ? value : null;
}
@@ -1,4 +1,4 @@
import { formFile, formString } from '$lib/server/api';
import { formFile, formString } from '$lib/server/forms/fields';
import { LIMITS } from '$lib/validation';
interface PlaylistFormBody {
@@ -1,4 +1,4 @@
import { formFile, formString } from '$lib/server/api';
import { formFile, formString } from '$lib/server/forms/fields';
import { LIMITS } from '$lib/validation';
interface TrackUploadBody {
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { loginRedirect, optionalUser, requireUser } from './guards';
const user = { sub: 'u1', name: 'Ana' };
function redirectOf(fn: () => unknown): { status: number; location: string } {
try {
fn();
} catch (thrown) {
return thrown as { status: number; location: string };
}
throw new Error('Expected a redirect');
}
describe('loginRedirect', () => {
it('redirects to /auth with the encoded path and query', () => {
const result = redirectOf(() => loginRedirect(new URL('http://x/albums/1?tab=a&b=c')));
expect(result.status).toBe(302);
expect(result.location).toBe(`/auth?returnTo=${encodeURIComponent('/albums/1?tab=a&b=c')}`);
});
});
describe('requireUser', () => {
it('returns the user when signed in', () => {
const locals = { user, accessToken: 't' } as App.Locals;
expect(requireUser(locals, new URL('http://x/'))).toBe(user);
});
it('redirects when there is no user', () => {
const locals = { user: null, accessToken: null } as App.Locals;
const result = redirectOf(() => requireUser(locals, new URL('http://x/library')));
expect(result.location).toBe('/auth?returnTo=%2Flibrary');
});
});
describe('optionalUser', () => {
it('returns the user when signed in', () => {
const locals = { user, accessToken: 't' } as App.Locals;
expect(optionalUser(locals, new URL('http://x/'), false)).toBe(user);
});
it('returns null for anonymous listening', () => {
const locals = { user: null, accessToken: null } as App.Locals;
expect(optionalUser(locals, new URL('http://x/'), true)).toBeNull();
});
it('redirects anonymous visitors when listening is not allowed', () => {
const locals = { user: null, accessToken: null } as App.Locals;
const result = redirectOf(() => optionalUser(locals, new URL('http://x/albums/1'), false));
expect(result.location).toBe('/auth?returnTo=%2Falbums%2F1');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from '@sveltejs/kit';
import type { SessionUser } from '$lib/types';
export function loginRedirect(url: URL): never {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) loginRedirect(url);
return locals.user;
}
export function optionalUser(
locals: App.Locals,
url: URL,
allowAnonymousListening: boolean
): SessionUser | null {
if (locals.user) return locals.user;
if (!allowAnonymousListening) loginRedirect(url);
return null;
}
+37 -1
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest';
import type { components } from '$lib/api/schema';
import { toAlbum, toDatedTrack, toLikedTrack, toMix, toPage, toPlaylist, toTrack } from './mappers';
import {
toAlbum,
toCount,
toDatedTrack,
toLikedTrack,
toListeningStats,
toMix,
toPage,
toPlaylist,
toTrack
} from './mappers';
const trackDto: components['schemas']['TrackApplicationResponse'] = {
id: 't1',
@@ -129,3 +139,29 @@ describe('toPage', () => {
).toEqual({ items: [2, 4], pageNumber: 2, hasNextPage: true, totalItemCount: 10 });
});
});
describe('toCount', () => {
it('normalizes numbers, numeric strings and missing values', () => {
expect(toCount(4)).toBe(4);
expect(toCount('12')).toBe(12);
expect(toCount(undefined)).toBe(0);
expect(toCount(null)).toBe(0);
expect(toCount('abc')).toBe(0);
});
});
describe('toListeningStats', () => {
it('converts string counters to numbers', () => {
expect(
toListeningStats({ tracksThisWeek: '5', secondsThisWeek: 300, streakDays: '2' })
).toEqual({ tracksThisWeek: 5, secondsThisWeek: 300, streakDays: 2 });
});
it('falls back to zeros without data', () => {
expect(toListeningStats(undefined)).toEqual({
tracksThisWeek: 0,
secondsThisWeek: 0,
streakDays: 0
});
});
});
+15 -1
View File
@@ -1,5 +1,5 @@
import type { components } from '$lib/api/schema';
import type { Album, LikedTrack, Mix, Paged, Playlist, Track } from '$lib/types';
import type { Album, LikedTrack, ListeningStats, Mix, Paged, Playlist, Track } from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
@@ -82,6 +82,20 @@ export function toPlaylist(dto: PlaylistDto): Playlist {
};
}
type ListeningStatsDto = components['schemas']['ListeningStatsResponse'];
export function toCount(value: number | string | null | undefined): number {
return Number(value ?? 0) || 0;
}
export function toListeningStats(dto: ListeningStatsDto | null | undefined): ListeningStats {
return {
tracksThisWeek: toCount(dto?.tracksThisWeek),
secondsThisWeek: toCount(dto?.secondsThisWeek),
streakDays: toCount(dto?.streakDays)
};
}
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
return {
items: dto.items.map(map),
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { EncryptJWT } from 'jose';
const testEnv = vi.hoisted(() => ({ SESSION_SECRET: 'a'.repeat(40) }) as Record<string, string>);
vi.mock('$env/dynamic/private', () => ({ env: testEnv }));
import { decodeSession, encodeSession, type Session } from './auth';
const session: Session = {
sub: 'user-1',
name: 'Test User',
email: 'test@example.com',
picture: '',
accessToken: 'access',
refreshToken: 'refresh',
idToken: 'id',
expiresAt: 1_900_000_000
};
async function encryptWith(secret: string, ttlSeconds: number): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
return new EncryptJWT({ ...session })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt(now)
.setExpirationTime(now + ttlSeconds)
.encrypt(new Uint8Array(digest));
}
describe('encodeSession / decodeSession', () => {
it('round-trips a session', async () => {
const token = await encodeSession(session);
const decoded = await decodeSession(token);
expect(decoded).toMatchObject({ sub: session.sub, accessToken: session.accessToken });
});
it('returns null for a missing or empty token', async () => {
expect(await decodeSession(undefined)).toBeNull();
expect(await decodeSession('')).toBeNull();
});
it('returns null for a tampered token', async () => {
const token = await encodeSession(session);
const tampered = token.slice(0, -4) + (token.endsWith('AAAA') ? 'BBBB' : 'AAAA');
expect(await decodeSession(tampered)).toBeNull();
expect(await decodeSession('not.a.token')).toBeNull();
});
it('returns null for a token encrypted with another key', async () => {
const token = await encryptWith('b'.repeat(40), 3600);
expect(await decodeSession(token)).toBeNull();
});
it('returns null for an expired token', async () => {
const token = await encryptWith(testEnv.SESSION_SECRET, -50);
expect(await decodeSession(token)).toBeNull();
});
it('accepts a token built with the configured key', async () => {
const token = await encryptWith(testEnv.SESSION_SECRET, 3600);
expect(await decodeSession(token)).toMatchObject({ sub: 'user-1' });
});
});
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { LikedTrack, Track } from '$lib/types';
function track(id: string): Track {
return {
id,
title: id,
artist: 'Artist',
duration: 100,
explicit: false,
listensCount: 0
} as unknown as Track;
}
function likedTrack(id: string): LikedTrack {
return { ...track(id), likedAt: 1 } as LikedTrack;
}
async function freshStore() {
vi.resetModules();
return (await import('./liked.svelte')).liked;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('liked store', () => {
it('hydrates only once', async () => {
const liked = await freshStore();
liked.hydrate([likedTrack('a')]);
liked.hydrate([likedTrack('b')]);
expect(liked.isLiked('a')).toBe(true);
expect(liked.isLiked('b')).toBe(false);
expect(liked.count).toBe(1);
});
it('likes and unlikes a track', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
const liked = await freshStore();
await liked.toggle(track('a'));
expect(liked.isLiked('a')).toBe(true);
await liked.toggle(track('a'));
expect(liked.isLiked('a')).toBe(false);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('reverts a failed like', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const liked = await freshStore();
await liked.toggle(track('a'));
expect(liked.isLiked('a')).toBe(false);
});
it('reverts a failed unlike', async () => {
const liked = await freshStore();
liked.hydrate([likedTrack('a')]);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await liked.toggle(track('a'));
expect(liked.isLiked('a')).toBe(true);
});
it('keeps changes made to other tracks while a request fails', async () => {
const liked = await freshStore();
let rejectFirst: (reason: Error) => void = () => {};
const fetchMock = vi
.fn()
.mockImplementationOnce(() => new Promise((_, reject) => (rejectFirst = reject)))
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const first = liked.toggle(track('a'));
await liked.toggle(track('b'));
rejectFirst(new Error('offline'));
await first;
expect(liked.isLiked('a')).toBe(false);
expect(liked.isLiked('b')).toBe(true);
});
});
@@ -48,3 +48,5 @@ export function createPagedList<T>(
}
};
}
export type PagedList<T> = ReturnType<typeof createPagedList<T>>;
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import type { Paged } from '$lib/types';
import { createPagedList } from './pagedList.svelte';
interface Item {
id: number;
}
function page(ids: number[], pageNumber: number, hasNextPage: boolean): Paged<Item> {
return { items: ids.map((id) => ({ id })), pageNumber, hasNextPage, totalItemCount: 99 };
}
describe('createPagedList', () => {
it('appends the next page without duplicates and updates hasMore', async () => {
const fetchPage = vi.fn().mockResolvedValue(page([2, 3], 2, false));
const list = createPagedList<Item>(fetchPage, (item) => item.id);
list.reset(page([1, 2], 1, true));
expect(list.hasMore).toBe(true);
await list.loadMore();
expect(fetchPage).toHaveBeenCalledWith(2);
expect(list.items.map((item) => item.id)).toEqual([1, 2, 3]);
expect(list.hasMore).toBe(false);
});
it('ignores loadMore while another is in flight', async () => {
let resolve: (value: Paged<Item>) => void = () => {};
const fetchPage = vi.fn().mockImplementation(
() =>
new Promise<Paged<Item>>((r) => {
resolve = r;
})
);
const list = createPagedList<Item>(fetchPage, (item) => item.id);
list.reset(page([1], 1, true));
const first = list.loadMore();
expect(list.loading).toBe(true);
await list.loadMore();
expect(fetchPage).toHaveBeenCalledTimes(1);
resolve(page([2], 2, false));
await first;
expect(list.loading).toBe(false);
});
it('flags an error and keeps the items when the fetch fails', async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error('boom'));
const list = createPagedList<Item>(fetchPage, (item) => item.id);
list.reset(page([1, 2], 1, true));
await list.loadMore();
expect(list.error).toBe(true);
expect(list.loading).toBe(false);
expect(list.items).toHaveLength(2);
});
it('reset clears the error', async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error('boom'));
const list = createPagedList<Item>(fetchPage, (item) => item.id);
list.reset(page([1], 1, true));
await list.loadMore();
expect(list.error).toBe(true);
list.reset(page([5], 1, false));
expect(list.error).toBe(false);
expect(list.items).toEqual([{ id: 5 }]);
expect(list.hasMore).toBe(false);
});
});
+6
View File
@@ -51,6 +51,12 @@ export interface Mix {
tracks: Track[];
}
export interface ListeningStats {
tracksThisWeek: number;
secondsThisWeek: number;
streakDays: number;
}
export interface Paged<T> {
items: T[];
pageNumber: number;
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import Button from '$lib/components/ui/primitives/Button.svelte';
</script>
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
<h1 class="mb-2.5 text-display-3 text-fg">
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
</h1>
<p class="mb-6.5 text-body text-fg-2">
{page.status === 404
? 'Comprueba el enlace o vuelve al inicio.'
: 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}
</p>
<Button href="/" variant="secondary">Volver</Button>
</div>
@@ -1,12 +1,11 @@
import type { LayoutServerLoad } from './$types';
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
import { apiFor, loginRedirect } from '$lib/server/api';
import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
import { apiFor } from '$lib/server/api';
import { loginRedirect } from '$lib/server/guards';
import { toCount, toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
import { authConfig } from '$lib/server/config';
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
const PUBLIC_PATHS = new Set(['/auth']);
function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
return load.catch(() => fallback);
}
@@ -14,7 +13,7 @@ function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
if (!locals.user && !allowAnonymousListening) {
loginRedirect(url);
}
@@ -46,7 +45,7 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
})
.then(({ data }) => ({
items: (data?.items ?? []).map(toPlaylist),
total: Number(data?.totalItemCount ?? 0)
total: toCount(data?.totalItemCount)
})),
{ items: [], total: 0 }
),
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import PlayerDock from '$lib/components/player/PlayerDock.svelte';
import Queue from '$lib/components/player/Queue.svelte';
import Sidebar from '$lib/components/shell/Sidebar.svelte';
import TopBar from '$lib/components/shell/TopBar.svelte';
import MobileHeader from '$lib/components/shell/MobileHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/state/panels.svelte';
import { liked } from '$lib/state/liked.svelte';
import { createPlaylistModal } from '$lib/state/panels.svelte';
import { accent } from '$lib/theme/accent.svelte';
import { trackCover } from '$lib/utils/hrefs';
import { trackNavigation } from '$lib/state/history.svelte';
import { restoreScroll } from '$lib/state/scroll';
import { page } from '$app/state';
interface Props {
children: Snippet;
data: LayoutData;
}
let { children, data }: Props = $props();
let scroller = $state<HTMLElement>();
trackNavigation();
restoreScroll(() => scroller);
$effect(() => {
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
});
$effect(() => {
if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack);
});
$effect(() => {
const id = player.currentId;
if (id) accent.follow(trackCover(id, 'small'), () => player.currentId === id);
});
let queueAutoOpened = false;
$effect(() => {
if (!player.playing || queueAutoOpened) return;
queueAutoOpened = true;
queuePanel.show();
});
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName);
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.code !== 'Space' || isTypingTarget(event.target)) return;
event.preventDefault();
player.toggle();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div class="relative flex h-dvh overflow-hidden text-fg antialiased">
<div class="app-backdrop"></div>
<div class="app-vignette"></div>
<Sidebar
user={data.user}
playlists={data.userPlaylists ?? []}
playlistCount={data.userPlaylistsTotal ?? 0}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1">
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} />
<main class="flex-1">
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
</main>
</div>
<Queue />
</div>
<PlayerDock user={data.user} />
</div>
<Modal
open={createPlaylistModal.open}
onClose={() => createPlaylistModal.close()}
title="Crear nueva playlist"
>
<PlaylistForm
action="/playlists?/create"
formMessage={page.form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
helperText="Podrás añadir canciones más tarde."
onCancel={() => createPlaylistModal.close()}
onSuccess={() => createPlaylistModal.close()}
/>
</Modal>
</div>
+7 -10
View File
@@ -1,8 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { apiFor, requireUser } from '$lib/server/api';
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { toCount, toListeningStats, toMix, toPlaylist, toTrack } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/actions/playlist';
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
import { pickGreeting } from '$lib/server/greeting';
@@ -23,12 +24,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
});
const listeningStatsPromise = api
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
.then((res) => ({
tracksThisWeek: Number(res.data?.tracksThisWeek ?? 0),
secondsThisWeek: Number(res.data?.secondsThisWeek ?? 0),
streakDays: Number(res.data?.streakDays ?? 0)
}))
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
.then((res) => toListeningStats(res.data))
.catch(() => toListeningStats(null));
const [playlists, mixes, recentlyPlayed] = await Promise.all([
playlistsPromise,
@@ -48,7 +45,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
})
.then((res) => ({
items: (res.data?.items ?? []).map(toTrack),
totalCount: Number(res.data?.totalItemCount ?? 0)
totalCount: toCount(res.data?.totalItemCount)
}))
.catch(() => ({ items: [], totalCount: 0 }))
: Promise.resolve({ items: [], totalCount: 0 });
+3 -3
View File
@@ -6,7 +6,7 @@
import { player } from '$lib/player/player.svelte';
import { playShuffled } from '$lib/player/actions';
import type { Track } from '$lib/types';
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
import { mergeRecentlyPlayed } from '$lib/player/recentlyPlayed';
import { fetchPlaylistTracks } from '$lib/data/albums';
import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
@@ -16,8 +16,8 @@
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
import TrackContextMenu from '$lib/components/music/TrackContextMenu.svelte';
import { createMenu } from '$lib/state/menu.svelte';
import { HOME_CONTINUE_LIMIT } from '$lib/config';
@@ -0,0 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
error(404, 'Página no encontrada');
};
@@ -1,6 +1,9 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { formFile } from '$lib/server/forms/fields';
import { authedAction } from '$lib/server/actions/authedAction';
import { toAlbum, toPage } from '$lib/server/mappers';
import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
@@ -1,14 +1,9 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser,
unwrapOrError
} from '$lib/server/api';
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
import { optionalUser } from '$lib/server/guards';
import { formFile, formString } from '$lib/server/forms/fields';
import { authedAction } from '$lib/server/actions/authedAction';
import { toAlbum, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
@@ -1,7 +1,7 @@
<script lang="ts">
import { albumCover } from '$lib/utils/hrefs';
import { player } from '$lib/player/player.svelte';
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import Page from '$lib/components/ui/layout/Page.svelte';
@@ -14,7 +14,7 @@
import Button from '$lib/components/ui/primitives/Button.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
import TrackList from '$lib/components/music/TrackList.svelte';
import { albumMeta } from '$lib/utils/format';
let { data, form } = $props();
@@ -1,8 +1,8 @@
import type { PageServerLoad, Actions } from './$types';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { getGenres } from '$lib/server/genres';
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { toAlbum, toCount, toPage, toTrack } from '$lib/server/mappers';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/actions/playlist';
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
@@ -56,9 +56,9 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
genres,
tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
albumsTotal: toCount(albumsRes?.data?.totalItemCount),
users: usersRes?.data?.items ?? [],
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
usersTotal: toCount(usersRes?.data?.totalItemCount),
viewerId: user?.sub ?? null
};
};
+22 -233
View File
@@ -1,45 +1,27 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte';
import type { Paged, Track } from '$lib/types';
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
import { plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte';
import UserRow from '$lib/components/ui/media/UserRow.svelte';
import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { genreHref, playlistCover } from '$lib/utils/hrefs';
import { genreHref } from '$lib/utils/hrefs';
import { createMenu } from '$lib/state/menu.svelte';
import { createPagedList } from '$lib/state/pagedList.svelte';
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
import AlbumContextMenu from '$lib/components/ui/overlay/AlbumContextMenu.svelte';
import TrackContextMenu from '$lib/components/music/TrackContextMenu.svelte';
import AlbumContextMenu from '$lib/components/music/AlbumContextMenu.svelte';
import { genreInfo, genreTiles } from '$lib/data/genres';
import {
type SearchFilter,
showGroup,
capped,
matchPlaylists,
SEARCH_LABELS,
searchTotal,
searchChips,
findTopResult
} from '$lib/data/search';
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
track: 'Canción',
album: 'Álbum',
playlist: 'Playlist',
user: 'Usuario'
};
import GenreGrid from './GenreGrid.svelte';
import TopResult from './TopResult.svelte';
import SearchResults from './SearchResults.svelte';
let { data, form } = $props();
@@ -96,18 +78,7 @@
const chips = $derived(searchChips(counts));
const totalHits = $derived(searchTotal(counts));
type TopResult = NonNullable<
ReturnType<
typeof findTopResult<
Track,
(typeof albums)[number],
(typeof playlistMatches)[number],
(typeof users)[number]
>
>
>;
const topResult = $derived<TopResult | null>(
const topResult = $derived(
sfilter === 'all' && data.query
? findTopResult({
query: data.query,
@@ -118,18 +89,6 @@
})
: null
);
function playTopResult() {
if (topResult?.kind !== 'track') return;
player.playOrToggle([topResult.track], 0);
}
const topResultHref = $derived.by(() => {
if (!topResult || topResult.kind === 'track') return undefined;
if (topResult.kind === 'album') return `/albums/${topResult.album.id}`;
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
return `/user/${topResult.user.id}`;
});
</script>
<svelte:head>
@@ -174,62 +133,7 @@
</div>
{#if topResult}
{#snippet topResultBody()}
{#if topResult.kind === 'track'}
<Artwork
trackIds={[topResult.track.id]}
size="xl"
alt={topResult.track.title}
class="shrink-0"
/>
{:else if topResult.kind === 'album'}
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
{:else if topResult.kind === 'playlist'}
<Artwork
src={playlistCover(topResult.playlist, 'large')}
trackIds={topResult.playlist.coverTrackIds}
size="xl"
class="shrink-0"
/>
{:else}
<Avatar name={topResult.user.name} src={topResult.user.profilePictureUrl} size="lg" />
{/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div class="text-eyebrow leading-none font-semibold text-fg-2">
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
</div>
<div
class="truncate font-display text-lg leading-none font-medium tracking-display text-fg"
>
{topResult.kind === 'track'
? topResult.track.title
: topResult.kind === 'album'
? topResult.album.title
: topResult.kind === 'playlist'
? topResult.playlist.name
: topResult.user.name}
</div>
{#if topResult.kind === 'track' && topResult.track.artist}
<div class="truncate text-sm leading-none text-fg-2">
{topResult.track.artist}
</div>
{/if}
</div>
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
{/snippet}
<svelte:element
this={topResult.kind === 'track' ? 'button' : 'a'}
type={topResult.kind === 'track' ? 'button' : undefined}
role={topResult.kind === 'track' ? 'button' : 'link'}
href={topResult.kind === 'track' ? undefined : topResultHref}
onclick={topResult.kind === 'track' ? playTopResult : undefined}
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
>
{@render topResultBody()}
</svelte:element>
<TopResult result={topResult} />
{/if}
{/if}
@@ -255,137 +159,22 @@
{/if}
{#if !data.query && !data.genre}
<div class="grid-wide">
{#each tiles as tile, i (tile.genre)}
<a
href={genreHref(tile.genre)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
style="--i:{i}; --tile-hue:{tile.hue}"
>
<div class="text-on-art">
{tile.label}
</div>
<div class="text-xs text-on-art-2">{tile.tagline}</div>
</a>
{/each}
</div>
{#if tiles.length === 0}
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
{/if}
<GenreGrid {tiles} />
{/if}
{#if (data.query || data.genre) && !nothingFound}
<div class="mt-9 flex flex-col gap-9">
{#if showGroup(sfilter, 'tracks')}
<div>
{#if items.length > 0}
<SectionHeading title={SEARCH_LABELS.tracks} count={counts.tracks} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, items) as track, i (track.id)}
<li>
<ListRow
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.explicit}
active={player.currentId === track.id}
size="lg"
trackId={track.id}
onclick={() => player.playOrToggle(items, i)}
oncontextmenu={(e) => trackMenu.open(e, track)}
>
{#snippet overlay()}
{#if player.currentId === track.id}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
{#if sfilter === 'tracks' && tracksList.hasMore}
<InfiniteScroll
onLoadMore={() => tracksList.loadMore()}
hasMore={tracksList.hasMore}
loading={tracksList.loading}
error={tracksList.error}
/>
{/if}
{:else}
<EmptyState
icon={Music}
description={activeGenre
? 'Todavía no hay canciones de este género.'
: `No hay canciones que coincidan con «${data.query}».`}
/>
{/if}
</div>
{/if}
{#if hasAlbums && showGroup(sfilter, 'albums')}
<div>
<SectionHeading title={SEARCH_LABELS.albums} count={counts.albums} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, albums) as album (album.id)}
<li>
<ListRow
title={album.title}
href="/albums/{album.id}"
size="lg"
trackIds={album.coverTrackIds}
oncontextmenu={(e) => albumMenu.open(e, album.id)}
>
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{plural(album.trackCount, 'canción', 'canciones')}
</span>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
</div>
{/if}
{#if hasUsers && showGroup(sfilter, 'users')}
<div>
<SectionHeading title={SEARCH_LABELS.users} count={counts.users} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, users) as u (u.id)}
<li>
<UserRow user={u} />
</li>
{/each}
</ul>
</div>
{/if}
{#if hasPlaylists && showGroup(sfilter, 'playlists')}
<div>
<SectionHeading title={SEARCH_LABELS.playlists} count={counts.playlists} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
<li>
<ListRow
title={playlist.name}
subtitle={playlist.description}
href="/playlists/{playlist.id}"
size="lg"
coverSrc={playlistCover(playlist, 'medium')}
trackIds={playlist.coverTrackIds}
/>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<SearchResults
filter={sfilter}
{counts}
query={data.query}
inGenre={!!activeGenre}
{tracksList}
{albums}
playlists={playlistMatches}
{users}
{trackMenu}
{albumMenu}
/>
{/if}
</Page>
@@ -0,0 +1,30 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import { genreHref } from '$lib/utils/hrefs';
import type { genreTiles } from '$lib/data/genres';
interface Props {
tiles: ReturnType<typeof genreTiles>;
}
let { tiles }: Props = $props();
</script>
<div class="grid-wide">
{#each tiles as tile, i (tile.genre)}
<a
href={genreHref(tile.genre)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
style="--i:{i}; --tile-hue:{tile.hue}"
>
<div class="text-on-art">
{tile.label}
</div>
<div class="text-xs text-on-art-2">{tile.tagline}</div>
</a>
{/each}
</div>
{#if tiles.length === 0}
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
{/if}
@@ -0,0 +1,168 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte';
import type { Album, Playlist, Track } from '$lib/types';
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import UserRow from '$lib/components/ui/media/UserRow.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { playlistCover } from '$lib/utils/hrefs';
import type { Menu } from '$lib/state/menu.svelte';
import type { PagedList } from '$lib/state/pagedList.svelte';
import {
type SearchCounts,
type SearchFilter,
showGroup,
capped,
SEARCH_LABELS
} from '$lib/data/search';
interface ResultUser {
id: string;
name: string;
profilePictureUrl: string | null;
}
interface Props {
filter: SearchFilter;
counts: SearchCounts;
query: string;
inGenre: boolean;
tracksList: PagedList<Track>;
albums: Album[];
playlists: Playlist[];
users: ResultUser[];
trackMenu: Menu<Track>;
albumMenu: Menu<string>;
}
let {
filter,
counts,
query,
inGenre,
tracksList,
albums,
playlists,
users,
trackMenu,
albumMenu
}: Props = $props();
const items = $derived(tracksList.items);
</script>
<div class="mt-9 flex flex-col gap-9">
{#if showGroup(filter, 'tracks')}
<div>
{#if items.length > 0}
<SectionHeading title={SEARCH_LABELS.tracks} count={counts.tracks} />
<ul class="flex flex-col gap-1">
{#each capped(filter, items) as track, i (track.id)}
<li>
<ListRow
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.explicit}
active={player.currentId === track.id}
size="lg"
trackId={track.id}
onclick={() => player.playOrToggle(items, i)}
oncontextmenu={(e) => trackMenu.open(e, track)}
>
{#snippet overlay()}
{#if player.currentId === track.id}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
{#if filter === 'tracks' && tracksList.hasMore}
<InfiniteScroll
onLoadMore={() => tracksList.loadMore()}
hasMore={tracksList.hasMore}
loading={tracksList.loading}
error={tracksList.error}
/>
{/if}
{:else}
<EmptyState
icon={Music}
description={inGenre
? 'Todavía no hay canciones de este género.'
: `No hay canciones que coincidan con «${query}».`}
/>
{/if}
</div>
{/if}
{#if albums.length > 0 && showGroup(filter, 'albums')}
<div>
<SectionHeading title={SEARCH_LABELS.albums} count={counts.albums} />
<ul class="flex flex-col gap-1">
{#each capped(filter, albums) as album (album.id)}
<li>
<ListRow
title={album.title}
href="/albums/{album.id}"
size="lg"
trackIds={album.coverTrackIds}
oncontextmenu={(e) => albumMenu.open(e, album.id)}
>
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{plural(album.trackCount, 'canción', 'canciones')}
</span>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
</div>
{/if}
{#if users.length > 0 && showGroup(filter, 'users')}
<div>
<SectionHeading title={SEARCH_LABELS.users} count={counts.users} />
<ul class="flex flex-col gap-1">
{#each capped(filter, users) as u (u.id)}
<li>
<UserRow user={u} />
</li>
{/each}
</ul>
</div>
{/if}
{#if playlists.length > 0 && showGroup(filter, 'playlists')}
<div>
<SectionHeading title={SEARCH_LABELS.playlists} count={counts.playlists} />
<ul class="flex flex-col gap-1">
{#each capped(filter, playlists) as playlist (playlist.id)}
<li>
<ListRow
title={playlist.name}
subtitle={playlist.description}
href="/playlists/{playlist.id}"
size="lg"
coverSrc={playlistCover(playlist, 'medium')}
trackIds={playlist.coverTrackIds}
/>
</li>
{/each}
</ul>
</div>
{/if}
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte';
import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
import { playlistCover } from '$lib/utils/hrefs';
import type { Album, Playlist, Track } from '$lib/types';
import type { TopResult as TopResultData } from '$lib/data/search';
interface ResultUser {
id: string;
name: string;
profilePictureUrl: string | null;
}
interface Props {
result: TopResultData<Track, Album, Playlist, ResultUser>;
}
let { result }: Props = $props();
const KIND_LABEL: Record<Props['result']['kind'], string> = {
track: 'Canción',
album: 'Álbum',
playlist: 'Playlist',
user: 'Usuario'
};
function play() {
if (result.kind !== 'track') return;
player.playOrToggle([result.track], 0);
}
const href = $derived.by(() => {
if (result.kind === 'track') return undefined;
if (result.kind === 'album') return `/albums/${result.album.id}`;
if (result.kind === 'playlist') return `/playlists/${result.playlist.id}`;
return `/user/${result.user.id}`;
});
</script>
{#snippet body()}
{#if result.kind === 'track'}
<Artwork trackIds={[result.track.id]} size="xl" alt={result.track.title} class="shrink-0" />
{:else if result.kind === 'album'}
<Artwork trackIds={result.album.coverTrackIds} size="xl" class="shrink-0" />
{:else if result.kind === 'playlist'}
<Artwork
src={playlistCover(result.playlist, 'large')}
trackIds={result.playlist.coverTrackIds}
size="xl"
class="shrink-0"
/>
{:else}
<Avatar name={result.user.name} src={result.user.profilePictureUrl} size="lg" />
{/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div class="text-eyebrow leading-none font-semibold text-fg-2">
Mejor resultado · {KIND_LABEL[result.kind]}
</div>
<div class="truncate font-display text-lg leading-none font-medium tracking-display text-fg">
{result.kind === 'track'
? result.track.title
: result.kind === 'album'
? result.album.title
: result.kind === 'playlist'
? result.playlist.name
: result.user.name}
</div>
{#if result.kind === 'track' && result.track.artist}
<div class="truncate text-sm leading-none text-fg-2">
{result.track.artist}
</div>
{/if}
</div>
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
{/snippet}
<svelte:element
this={result.kind === 'track' ? 'button' : 'a'}
type={result.kind === 'track' ? 'button' : undefined}
role={result.kind === 'track' ? 'button' : 'link'}
{href}
onclick={result.kind === 'track' ? play : undefined}
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
>
{@render body()}
</svelte:element>
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const allowAnonymous = vi.hoisted(() => vi.fn());
vi.mock('$lib/server/playbackConfig', () => ({ getAllowAnonymousListening: allowAnonymous }));
vi.mock('$lib/server/config', () => ({
authConfig: { issuer: 'https://auth.example.com/' },
apiConfig: { baseUrl: 'http://api.test' }
}));
import { load } from './+layout.server';
type LoadEvent = Parameters<typeof load>[0];
function event(user: { sub: string; name: string } | null, fetchFn = vi.fn()) {
return {
locals: { user, accessToken: user ? 'token' : null },
url: new URL('http://x/library?q=1'),
fetch: fetchFn
} as unknown as LoadEvent;
}
beforeEach(() => {
allowAnonymous.mockReset();
});
describe('(app) layout load', () => {
it('redirects to login without a session or anonymous listening', async () => {
allowAnonymous.mockResolvedValue(false);
await expect(load(event(null))).rejects.toMatchObject({
status: 302,
location: `/auth?returnTo=${encodeURIComponent('/library?q=1')}`
});
});
it('returns empty data in anonymous mode without calling the API', async () => {
allowAnonymous.mockResolvedValue(true);
const fetchFn = vi.fn();
const data = await load(event(null, fetchFn));
expect(data).toMatchObject({
user: null,
allowAnonymousListening: true,
userPlaylists: [],
userPlaylistsTotal: 0,
likedTracks: [],
lastPlayedTrack: null,
accountUrl: 'https://auth.example.com/ui/console'
});
expect(fetchFn).not.toHaveBeenCalled();
});
it('falls back to empty data when the API fails for a signed in user', async () => {
allowAnonymous.mockResolvedValue(false);
const fetchFn = vi.fn().mockRejectedValue(new Error('down'));
const data = await load(event({ sub: 'u1', name: 'Ana' }, fetchFn));
expect(data).toMatchObject({
user: { sub: 'u1' },
userPlaylists: [],
userPlaylistsTotal: 0,
likedTracks: [],
lastPlayedTrack: null
});
});
});
@@ -1,14 +1,10 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { toDatedTrack, toPage } from '$lib/server/mappers';
import {
apiFor,
authedAction,
failOnError,
formString,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { formString } from '$lib/server/forms/fields';
import { authedAction } from '$lib/server/actions/authedAction';
import { LIBRARY_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
@@ -9,7 +9,7 @@
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
import TrackList from '$lib/components/music/TrackList.svelte';
let { data } = $props();
@@ -2,14 +2,14 @@
import Heart from '@lucide/svelte/icons/heart';
import X from '@lucide/svelte/icons/x';
import { player } from '$lib/player/player.svelte';
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import { liked } from '$lib/player/liked.svelte';
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
import { liked } from '$lib/state/liked.svelte';
import { plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
import TrackList from '$lib/components/music/TrackList.svelte';
const tracks = $derived(liked.list);
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
@@ -1,7 +1,8 @@
import type { PageServerLoad, Actions } from './$types';
import { apiFor, requireUser, unwrapOrError } from '$lib/server/api';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { toMix } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions';
import { addTrackAction } from '$lib/server/actions/playlist';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
requireUser(locals, url);
@@ -9,11 +9,11 @@
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
import TrackList from '$lib/components/music/TrackList.svelte';
import TrackContextMenu from '$lib/components/music/TrackContextMenu.svelte';
import { createMenu } from '$lib/state/menu.svelte';
import type { Track } from '$lib/types';
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
let { data, form } = $props();
@@ -1,6 +1,8 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { authedAction } from '$lib/server/actions/authedAction';
import { PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { toPlaylist } from '$lib/server/mappers';
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
@@ -1,13 +1,9 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
apiFor,
authedAction,
failOnError,
formString,
optionalUser,
unwrapOrError
} from '$lib/server/api';
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
import { optionalUser } from '$lib/server/guards';
import { formString } from '$lib/server/forms/fields';
import { authedAction } from '$lib/server/actions/authedAction';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
@@ -3,7 +3,7 @@
import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x';
import { player } from '$lib/player/player.svelte';
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte';
@@ -13,7 +13,7 @@
import Button from '$lib/components/ui/primitives/Button.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
import TrackList from '$lib/components/music/TrackList.svelte';
import { playlistMeta } from '$lib/utils/format';
let { data, form } = $props();
@@ -1,6 +1,8 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
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';
@@ -1,8 +1,9 @@
import type { PageServerLoad, Actions } from './$types';
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPlaylist } from '$lib/server/mappers';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { optionalUser } from '$lib/server/guards';
import { toCount, toPlaylist } from '$lib/server/mappers';
import { PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import { followUserAction, unfollowUserAction } from '$lib/server/actions/follow';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
@@ -26,7 +27,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
return {
profile,
followersCount: Number(profile.followersCount),
followersCount: toCount(profile.followersCount),
playlists,
isOwnProfile,
isAnonymous: viewer === null
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { optionalUser } from '$lib/server/guards';
import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
+12 -10
View File
@@ -3,14 +3,16 @@
import Button from '$lib/components/ui/primitives/Button.svelte';
</script>
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
<h1 class="mb-2.5 text-display-3 text-fg">
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
</h1>
<p class="mb-6.5 text-body text-fg-2">
{page.status === 404
? 'Comprueba el enlace o vuelve al inicio.'
: 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}
</p>
<Button href="/" variant="secondary">Volver</Button>
<div class="min-h-screen bg-bg px-6 text-fg antialiased">
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
<h1 class="mb-2.5 text-display-3">
{page.status === 404 ? 'Página no encontrada' : 'Algo salió mal'}
</h1>
<p class="mb-6.5 text-body text-fg-2">
{page.status === 404
? 'Comprueba el enlace o vuelve al inicio.'
: 'No se pudo cargar la página. Inténtalo de nuevo en unos momentos.'}
</p>
<Button href="/" variant="secondary">Volver al inicio</Button>
</div>
</div>
+2 -103
View File
@@ -1,134 +1,33 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import '@fontsource-variable/sora/index.css';
import '@fontsource-variable/plus-jakarta-sans/index.css';
import '$lib/theme/theme.css';
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import PlayerDock from '$lib/components/player/PlayerDock.svelte';
import Queue from '$lib/components/player/Queue.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import TopBar from '$lib/components/layout/TopBar.svelte';
import MobileHeader from '$lib/components/layout/MobileHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte';
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/state/panels.svelte';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/state/panels.svelte';
import { ACCENT_HUE } from '$lib/theme/color';
import { buildThemeTokens, tokensToCss } from '$lib/theme/tokens';
import { accent, animateThemeHue } from '$lib/theme/accent.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
import { trackNavigation } from '$lib/state/history.svelte';
import { restoreScroll } from '$lib/state/scroll';
import { page } from '$app/state';
interface Props {
children: Snippet;
data: LayoutData;
}
let { children, data }: Props = $props();
let scroller = $state<HTMLElement>();
trackNavigation();
restoreScroll(() => scroller);
$effect(() => {
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
});
$effect(() => {
if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack);
});
const isAuthPage = $derived(page.url.pathname === '/auth');
let { children }: Props = $props();
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
let queueAutoOpened = false;
$effect(() => {
if (!player.playing || queueAutoOpened) return;
queueAutoOpened = true;
queuePanel.show();
});
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName);
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.code !== 'Space' || isTypingTarget(event.target)) return;
event.preventDefault();
player.toggle();
}
$effect(() => animateThemeHue(accent.hue, themeMode.current));
</script>
<svelte:window onkeydown={onWindowKeydown} />
<svelte:head>
<link rel="icon" href={favicon} />
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS generado a partir de números, sin entrada de usuario -->
{@html `<style>${initialThemeCss}</style>`}
</svelte:head>
{#if isAuthPage || (!data.user && !data.allowAnonymousListening)}
<div class="min-h-screen bg-bg text-fg antialiased">
{@render children()}
</div>
{:else}
<div class="relative flex h-dvh overflow-hidden text-fg antialiased">
<div class="app-backdrop"></div>
<div class="app-vignette"></div>
<Sidebar
user={data.user}
playlists={data.userPlaylists ?? []}
playlistCount={data.userPlaylistsTotal ?? 0}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1">
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} />
<main class="flex-1">
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
</main>
</div>
<Queue />
</div>
<PlayerDock user={data.user} />
</div>
<Modal
open={createPlaylistModal.open}
onClose={() => createPlaylistModal.close()}
title="Crear nueva playlist"
>
<PlaylistForm
action="/playlists?/create"
formMessage={page.form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
helperText="Podrás añadir canciones más tarde."
onCancel={() => createPlaylistModal.close()}
onSuccess={() => createPlaylistModal.close()}
/>
</Modal>
</div>
{/if}
{@render children()}
<DialogHost />
@@ -0,0 +1,4 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const fallback: RequestHandler = () => json({ message: 'No encontrado.' }, { status: 404 });
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
import { POST } from './+server';
type PostEvent = Parameters<typeof POST>[0];
function event(body: unknown, options: { token?: string | null; api?: Response } = {}) {
const { token = 'token', api = Response.json(true) } = options;
const fetchFn = vi.fn().mockResolvedValue(api);
const request = new Request('http://x/api/likes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body)
});
return {
event: {
request,
locals: { accessToken: token, user: null },
fetch: fetchFn
} as unknown as PostEvent,
fetchFn
};
}
describe('POST /api/likes', () => {
it('returns 401 without a token', async () => {
const { event: e, fetchFn } = event({ trackId: 't1' }, { token: null });
expect((await POST(e)).status).toBe(401);
expect(fetchFn).not.toHaveBeenCalled();
});
it.each([[{}], [{ trackId: '' }], [{ trackId: 5 }], ['not json']])(
'returns 400 for an invalid body %j',
async (body) => {
const { event: e, fetchFn } = event(body);
expect((await POST(e)).status).toBe(400);
expect(fetchFn).not.toHaveBeenCalled();
}
);
it('returns the liked state from the API', async () => {
const { event: e } = event({ trackId: 't1' });
const res = await POST(e);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ liked: true });
});
it('returns 502 when the API fails', async () => {
const { event: e } = event(
{ trackId: 't1' },
{ api: Response.json({ title: 'boom' }, { status: 500 }) }
);
expect((await POST(e)).status).toBe(502);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
import { PUT } from './+server';
type PutEvent = Parameters<typeof PUT>[0];
function event(body: string, api: Response = new Response(null, { status: 204 })) {
const fetchFn = vi.fn().mockResolvedValue(api);
const request = new Request('http://x/api/listens/l1/progress', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body
});
return {
event: {
request,
params: { id: 'l1' },
locals: { accessToken: 'token', user: null },
fetch: fetchFn
} as unknown as PutEvent,
fetchFn
};
}
describe('PUT /api/listens/[id]/progress', () => {
it('returns 401 without a token', async () => {
const { event: e } = event('{"playedSeconds":5}');
(e.locals as { accessToken: string | null }).accessToken = null;
expect((await PUT(e)).status).toBe(401);
});
it.each([
['invalid JSON', 'nope'],
['negative seconds', '{"playedSeconds":-1}'],
['string seconds', '{"playedSeconds":"5"}'],
['missing seconds', '{}'],
['null seconds', '{"playedSeconds":null}']
])('returns 400 for %s', async (_name, body) => {
const { event: e, fetchFn } = event(body);
expect((await PUT(e)).status).toBe(400);
expect(fetchFn).not.toHaveBeenCalled();
});
it('returns 204 when the API accepts the progress', async () => {
const { event: e, fetchFn } = event('{"playedSeconds":12.5}');
expect((await PUT(e)).status).toBe(204);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('returns 404 when the API returns 404', async () => {
const { event: e } = event('{"playedSeconds":5}', Response.json({}, { status: 404 }));
expect((await PUT(e)).status).toBe(404);
});
it('returns 502 for other API failures', async () => {
const { event: e } = event('{"playedSeconds":5}', Response.json({}, { status: 500 }));
expect((await PUT(e)).status).toBe(502);
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
import { GET } from './+server';
type GetEvent = Parameters<typeof GET>[0];
async function requestedQuery(search: string) {
const fetchFn = vi
.fn()
.mockResolvedValue(
Response.json({ items: [], pageNumber: 1, hasNextPage: false, totalItemCount: 0 })
);
const res = await GET({
url: new URL(`http://x/api/tracks${search}`),
locals: { accessToken: null, user: null },
fetch: fetchFn
} as unknown as GetEvent);
expect(res.status).toBe(200);
const request = fetchFn.mock.calls[0][0] as Request;
return new URL(request.url).searchParams;
}
describe('GET /api/tracks', () => {
it('caps pageSize at MAX_PAGE_SIZE', async () => {
const query = await requestedQuery('?pageSize=100000');
expect(query.get('pageSize')).toBe(String(MAX_PAGE_SIZE));
});
it('uses the default page size for invalid values', async () => {
expect((await requestedQuery('?pageSize=abc')).get('pageSize')).toBe(String(EXPLORE_PAGE_SIZE));
expect((await requestedQuery('')).get('pageSize')).toBe(String(EXPLORE_PAGE_SIZE));
});
it('resets an invalid pageNumber to 1', async () => {
expect((await requestedQuery('?pageNumber=-3')).get('pageNumber')).toBe('1');
expect((await requestedQuery('?pageNumber=abc')).get('pageNumber')).toBe('1');
expect((await requestedQuery('?pageNumber=4')).get('pageNumber')).toBe('4');
});
});
+3 -1
View File
@@ -12,7 +12,9 @@
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
</svelte:head>
<div class="relative grid min-h-screen place-items-center overflow-hidden bg-bg px-6 text-fg">
<div
class="relative grid min-h-screen place-items-center overflow-hidden bg-bg px-6 text-fg antialiased"
>
<div class="pointer-events-none absolute inset-0" style="background:var(--mf-ambient)"></div>
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">