22 KiB
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, scriptnpm run test, incluido enlinto 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
PlayerStatea funciones puras enplayer/queue.ts(insertNext,append,move,nextIndex,previousIndex) y testearlas.PlayerStatepasa a llamarlas. Esto prepara P2.
Cierre: npm run test en verde y cubre las funciones anteriores.
P1 · Seguridad (S)
- Redirección abierta con
returnTo.- Nueva función
safeReturnTo(value: string | null | undefined): stringen$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), enroutes/auth/login/+server.ts(antes de guardar la cookie) y enroutes/auth/callback/+server.ts(al leer la cookie).
- Nueva función
SESSION_SECRETobligatorio. En$lib/server/config.ts,sessionSecretpasa porrequired()y exige una longitud mínima de 32 caracteres. Actualizar el comentario de.env.example.- Validar las entradas de los endpoints
/api.api/likes/+server.ts: no reenviar el body tal cual; leertrackId, comprobar que es un string no vacío y devolver 400 si no.api/listens/[id]/progress/+server.ts: envolverrequest.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: acotarpageSizecon un máximo (MAX_PAGE_SIZEenconfig.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)
- Duplicados en la cola (
player.svelte.tsplayNext/appendToQueue,Queue.svelte).playNextmueve la pista si ya estaba en la cola, en vez de duplicarla.appendToQueueomite las pistas que ya están pendientes.- Con esto las claves
(track.id)vuelven a ser únicas y#index()es fiable.
- 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",
repeatpasará a'off' | 'all' | 'one'(queda fuera de este plan). - La cola se abre sola (
+layout.svelte:44). Abrirla solo la primera vez que empieza a sonar algo en la sesión (currentIdpasa denulla un valor) y no volver a abrirla si el usuario la cierra. - Rollback de "Me gusta" (
liked.svelte.ts:69). En caso de error, revertir solo la entrada de ese id, no sustituir el mapa entero. - localStorage seguro.
- Nuevo
$lib/utils/storage.tsconreadStorage(key, fallback)ywriteStorage(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>.
- Nuevo
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)
- Permisos en la playlist (
playlists/[id]).- El
loaddevuelveisOwner, comparando el propietario de la playlist conlocals.user.sub. Si la API no expone el propietario, añadirlo al DTO 🔗. - Mostrar Editar, Eliminar y "Quitar" solo al propietario. Añadir
BackLinkcomo en el álbum y la mezcla, ysection: '/playlists'cuando sea propia.
- El
- Una sola fuente para las playlists del usuario.
+layout.server.tscarga las playlists del usuario conPLAYLIST_PICKER_PAGE_SIZEy devuelve tambiéntotalItemCount.- 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.datay así explorar y mezcla dejan de pedirla aparte.
- 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$effectque copia a estado.
- Nuevo endpoint
- Borrar canción de la biblioteca con confirmación.
ConfirmDialogaceptafields(inputs ocultos) ylibrarylo usa, igual que álbumes y playlists. - Explorar.
- Usar
plural()para el número de canciones de un álbum. - Contadores de chips y "N coincidencias" a partir de
totalItemCountde cada respuesta, no de lo cargado.
- Usar
- Enlace
/termsen la subida: quitar el enlace hasta que exista una página de términos (o crearla, si se decide). playbackConfig.ts: no guardar en caché cuando la respuesta traeerror.ImageDropzone: liberar la URL anterior conURL.revokeObjectURLal cambiar de archivo y al desmontar.- 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, interfazPlaylist, getterisPlaying,playNextItem(usarplayNext([item])).theme/palette.ts:Accent.gradient(y con ello el tipoAccent, que queda enstring).IconButton: el mapaTEXTy la proptonepasan aplaino por defecto;mutedysubtleson idénticos.Artwork: propfallbacky sus ramasgradientynone.MediaIdentity.badge,FollowButton.userId,EqBars.sizeyEqBars.barWidth.- Acciones
followyunfollowenexplore/+page.server.tsy enuser/[id]/[list=followList]/+page.server.ts, con elform?.messageque solo servía para ellas. - Utilidades
media-gridytext-countdelayout.css(y su mención enCLAUDE.md). TrackList.svelte: eluse:enhanceredundante (quedause:enhance) y losstopPropagationde la línea 141.+page.server.ts(inicio): elredirectprevio arequireUser.+layout.server.ts: la ramaaccountUrl = null.- Clases que no hacen nada:
hover:text-fg-2sobretext-fg-2(SegmentedControl, Sidebar, ArtistLink) y elhidden sm:flexanidado dePlayerBar. - Separadores vacíos (
<span></span>,<div class="flex-1">) enCoverFormyTopBar: sustituir porml-auto. sessionCookieOptionsdeja 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.
- Tipos de dominio en
$lib/types.ts:Másexport 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; }AlbumyPlaylistnormalizados (trackCount: number,releaseYear: number | null, ids comostring). - Mappers de servidor en
$lib/server/mappers.ts:toTrack,toAlbum,toPlaylist,toMixTrack(sustituye amixItemTrack). Hacen todas las conversionesNumber()yString(). Todos losloady los endpoints/apique devuelven pistas los usan. - El reproductor usa
Track.- Se eliminan
PlayerTrack,QueueItem,ApiTrackLike,LikeToggleInputyTrackListTrack(que pasa a serTrack & { date?: string | number }). - Se eliminan
toQueueItems,toQueueItem,trackFromQueueItemytoTrackdel cliente. currentpasa a serTrack | nullen vez del centinelaEMPTYconid: ''.
- Se eliminan
- Páginas. Quitar todos los
Number(...), los?? undefinedy 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)
- Helpers en
$lib/server/api.ts.apiFor(event)sustituye los 15createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }).- Exportar
type ApiClient(hoy se redefine enfollows.ts). loginRedirect(url), usado porrequireUser,optionalUsery+layout.server.ts.
- Envoltorio de acciones
authedAction(handler): comprueba el token (401 si falta), creaapi, leeformy pasa{ api, form, params, locals }. Sustituye el bloquerequireAccessTokenAction+typeof+formData+createApiClientde las ~10 acciones. - Lectura de formularios:
formString(form, key)yformFile(form, key)(devuelvenullsi el archivo viene vacío). Sustituyen los 17String(form.get(...) ?? '')y losinstanceof File && size > 0. - Nombres.
unwrapOrFail→failOnError(no desenvuelve nada).- Desestructurar siempre como
const { data, error } = ...y usarcatch (err). - La acción
renamede playlist pasa aedity devuelve{ edited: true }, como la de álbum.
- Sin aserciones no nulas: quitar
urlsResult.data!ytrackResult.data!deupload/+page.server.tsusando el patrón defailOnErrorque estrecha el tipo. +layout.server.ts: una sola función para las tres cargas con fallback, sintry/catchduplicado (openapi-fetch solo lanza con error de red).- Tamaños de página: todos los
pageSizesalen deconfig.ts(quitar50,200,500sueltos y elPAGE_SIZElocal delibrary). Quitar el aliasEXPLORE_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)
- Límites en un único sitio:
$lib/validation.ts(compartido entre cliente y servidor):Antes de fijarlos, comprobar los validadores deexport const LIMITS = { playlistName: 100, playlistDescription: 300, albumTitle: 200, albumDescription: 256, trackTitle: 100 } as const;Musify.Apiy usar sus valores.ALBUM_EARLIEST_YEARse mueve aquí. - Parsers de servidor en
$lib/server/forms/:parseAlbumForm(existente),parsePlaylistForm(nuevo, sustituye el código duplicado decreateyedit),parseTrackUploadForm(sacado deupload/+page.server.ts). Todos validan conLIMITS. - Portadas:
uploadOptionalCover(api, endpoint, file)sustituye los 4 bloquesif (cover ...) uploadPresignedImage(...). - Formularios de cliente:
AlbumForm,PlaylistFormy la subida usanLIMITSenmaxlengthy en el contador. Añadir el contador que falta enAlbumFormpara que los tres se comporten igual. formatSizede la subida pasa autils/format.ts.
Cierre: cambiar un límite en LIMITS actualiza cliente y servidor; tests de los parsers.
P8 · Rendimiento 🔗 (M, parte en backend)
- Backend: añadir
trackCountydurationSecondsa la respuesta de playlist. - Frontend:
playlists/+page.server.tsdeja de pedir hasta 500 pistas por playlist.user/[id]/+page.server.tsdeja de hacer una petición por playlist.- La destacada de inicio recupera la duración real.
- Explorar: cachear
/genresen$lib/server/genres.ts(comoplaybackConfig, con TTL de unos minutos) y lanzar todas las peticiones en paralelo en vez de esperar primero a los géneros. - 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)
- Dividir
player.svelte.ts:player/queue.ts: operaciones de cola (de P0).player/stream.ts:resolveStream(id)(fetch de/stream, ticket,listenId) yreadErrorMessage.player/mediaSession.ts: registro de acciones y sincronización de metadatos y estado.player/progressClock.ts: el buclerequestAnimationFramede progreso suave.player/actions.ts:playAllOrToggle,playShuffled,isQueueCurrent.player/player.svelte.ts: solo el estado y la orquestación.
- Acento como número.
- Nuevo
theme/accent.svelte.ts: caché,extractAccenty el tono actual comonumber. - 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 cadenaoklch(...)).
- Nuevo
- Dividir
state/navigation.svelte.ts:state/history.svelte.ts:trackNavigation,previousPage.state/scroll.ts:restoreScroll.utils/hrefs.ts:searchHref,genreHrefy las URLs de portada (trackCover(id, size),playlistCover(playlist, size)con elv=updatedAt,albumCover(id, size)), que hoy se escriben a mano en más de 15 sitios.components/layout/navLinks.ts:appNavLinkseisNavActive(sustituye elisActiveduplicado de Sidebar y TabsBar).
- Menús.
contextMenuPositionyMenuPositionpasan autils/menuPosition.ts, sinpreventDefault()dentro (lo hace quien llama).state/menus.svelte.tsdeja de importar de un componente. - Movimientos de archivos:
data/collections.ts→utils/collections.ts.player/queuePanel.svelte.tsystate/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.
- Tipar
+layout.svelteconinterface 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)
- Menú contextual.
state/menu.svelte.tsconcreateMenu<T>()genérico: sustituyecreateTrackMenuy elalbumMenuhecho a mano de explorar.- Componente
TrackContextMenuque encapsula el bloque de 15 líneas repetido en inicio, explorar y mezcla.AlbumContextMenupara explorar.
PlayAllButton {items}con el "Reproducir/Pausar" de álbum, playlist, mezcla, me gusta y la portada de inicio.createPagedList(fetcher)enstate/pagedList.svelte.tspara la paginación infinita de explorar y de seguidores (estado,appendUnique,hasMorey un estado de error visible en vez de tragarse el fallo).- Primitivas.
Button,IconButton,ChipyMenuItemusan<svelte:element this={href ? 'a' : 'button'}>en vez de duplicar las dos ramas.- Acción
use:onEscapeparaModal,ContextMenuyAccountMenu. AccountMenuincluye su propio disparador con elAvatar;TopBaryMobileHeaderdejan de duplicarlo.ArtworkyAvatarusan{#key src}en vez del$effectque reiniciafailed.
- Páginas.
- Quitar los alias
const x = $derived(data.x)y las funciones de una línea (playSpotlight,shuffleSpotlight,openContextMenu,stepClass…). - Sustituir los
$effectque copiandataa estado local por$derivedescribible cuando sea posible. - Navegación con enlaces reales:
ListRow hrefen vez deonclick={() => goto(...)}, y la destacada de inicio como<a>. upload: el indicador de pasos solo refleja estados reales (o se simplifica a "subiendo / listo").
- Quitar los alias
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)
- Imports: siempre
$lib/..., nunca../. Se hace cumplir en P12. - Convenciones de nombres (se documentan en
CLAUDE.md):- Mapas de variantes en constantes de módulo en mayúsculas (
SIZE,VARIANT). - Errores:
erroral desestructurar yerrencatch. - Manejadores de reproducción:
playFrom(index),playAll(). - Acciones:
create,edit,delete,addTrack,removeTrack.
- Mapas de variantes en constantes de módulo en mayúsculas (
- Glosario de interfaz: "Playlist" (nunca "Lista"), "Mezcla" (nunca "Mix"), "Modo claro". Duraciones totales siempre con
fmtDurationLong. - Tokens.
- Iconos siempre con
size-icon-*; añadirsize-icon-2xssi 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 constyle. - Nueva utilidad
focus-ringusada porButton,IconButton,Chipy los enlaces interactivos.
- Iconos siempre con
- Rutas: mover
routes/+page.sveltey+page.server.ts(inicio) dentro de(app), para que el grupo tenga sentido: todo lo de la app dentro yauth/fuera.
Cierre: npm run lint en verde con las reglas nuevas de P12.
P12 · Guardarraíles y documentación (S)
- ESLint:
no-restricted-importsque prohíba../*.@typescript-eslint/no-non-null-assertion.svelte/no-unused-props.
scripts/check-tokens.mjs: fallar consize={N}en iconos y consize-Nsobre componentes Lucide.- Opcional:
knippara detectar exportaciones y archivos sin uso. - 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.