Author SHA1 Message Date
AdminandClaude Sonnet 5 ec6313dda1 web-player: P14 — barrido final y guardarraíles
Añade scripts/check-tokens.mjs (npm run lint:tokens, incorporado a
lint) como guardrail: falla si src/**/*.svelte reintroduce valores
arbitrarios fuera de la lista blanca de B.4, rounded-* de fábrica,
pares h-N w-N (deberían ser size-N), espaciado fraccionario en
cuartos, o colores hex/rgba() literales.

Se limpia el repo hasta dejar el script en 0 violaciones: ~40 pares
h-N w-N → size-N (sobre todo iconos, vía script puntual), ~26
espaciados fraccionarios en cuartos redondeados al entero más cercano
(Δ≤1px, p. ej. py-2.75→py-3, gap-1.75→gap-2), radios de fábrica
sueltos (rounded-sm/lg, rounded a secas) migrados a los tokens
correspondientes, y dos tracking-[-0.02em] que coincidían
exactamente con el token tracking-display ya existente pero nunca se
habían migrado. Se retiran las recetas stroke-thin/regular/bold
(creadas en P1, cero consumidores) — decisión tomada, sin bloquear:
no se migra strokeWidth={n} (13 valores, en casi todos los
componentes) a CSS porque no se puede verificar sin Docker que
lucide-svelte respete stroke-width por clase en vez del prop, y una
regresión ahí sería silenciosa en toda la app; el script documenta
este gap explícitamente en vez de fingir cobertura completa.

Detector de huérfanos: 0 componentes sin importadores. Se borra
docs/design-system-refactor-plan.md (plan anterior, superseded por
frontend-refactor-plan.md). web-player/CLAUDE.md gana una sección
"Estilos" con la lista blanca B.4 y los componentes base a reutilizar.
Se recalcula la tabla de línea base (A.4) y se anota en el Registro
(D.1): 61→47 componentes, 44→11 arbitrarios (los 11 restantes en
lista blanca), 13→8 radios distintos, 0 violaciones de lint:tokens.

Verificado con npm run check (0 errores), npm run lint:tokens (0
violaciones) y npm run build. Smoke test de /login sin errores de
consola achacables al cambio (Docker no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:05:07 +02:00
AdminandClaude Sonnet 5 d14e22cbf7 web-player: P13 — páginas grandes (Explorar, Inicio) y limpieza de tracks.ts
Se aborda junto con esta parte la tarea 5 diferida desde P5: se
elimina el envoltorio TrackTarget de tracks.ts y sus 8 accesores
(targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/
targetListensCount/isTargetCurrent/queueItemForTarget/
targetForQueueItem). Explorar, Inicio, Mezcla, menus.svelte.ts y
mixes.ts pasan a usar los objetos de pista directamente, con
toQueueItem/trackFromQueueItem (nuevo, en player.svelte.ts) como
únicos conversores. TypeScript reveló en el proceso que el campo
isExplicit no existe en el schema real de /tracks: el badge
"explicit" de Explorar ya era un no-op antes del refactor (siempre
undefined ?? false); se retira el binding en vez de fingir soporte
que nunca existió.

Explorar: GENRE_INFO/genreTiles se extraen a src/lib/genres.ts; la
lógica de búsqueda (showGroup/capped/matchPlaylists/searchCounts/
searchChips/findTopResult) se extrae a src/lib/search.ts como
funciones puras parametrizadas por sfilter/query en vez de cerrar
sobre el estado del componente. "Mejor resultado" unifica el botón/
enlace duplicado con <svelte:element>. Decisión tomada sin bloquear:
no se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/
Playlists) en un único {#each groups} — cada grupo tiene snippets,
menú contextual y props de ListRow genuinamente distintos, y una
configuración genérica habría añadido más indirección de la que
elimina.

Inicio: usa Page en vez de page-x a mano; las 3 estadísticas pasan a
{#each}; los fondos de hero/spotlight (style= con variables CSS) se
convierten en las utilities hero-surface/spotlight-surface, y
gap-11.5 se sustituye por el token --spacing-section (2.875rem) vía
gap-section.

Verificado con npm run check (0 errores) y npm run build. /explore
sin sesión redirige a /login porque +layout.server.ts exige
allowAnonymousListening desde el backend (inalcanzable sin Docker en
esta sesión) — comportamiento preexistente, no una regresión. Sin QA
visual autenticada; smoke test de /login sin errores de consola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:51:43 +02:00
AdminandClaude Sonnet 5 39e6c2d552 web-player: P12 — chrome de la app (layout, navegación, reproductor)
PlayerExtras se fusiona en PlayerBar, que ahora renderiza volumen y
botón de cola inline y usa el nuevo Slider (input range real con
grosor de thumb controlado por variables CSS) para barra de progreso
y volumen, sustituyendo a SeekBar y VolumeControl (se confirmó por
grep que el modo `compact` de SeekBar era código muerto).

Sidebar y TabsBar migran a appNavLinks(user, counts?) (creado en P5),
eliminando la duplicación de los enlaces de navegación entre ambos.
Decisión tomada sin bloquear: Sidebar gana la marca-gradiente Logo
que no tenía antes, para consistencia visual con MobileHeader y la
pantalla de login.

z-index de PlayerDock y MobileHeader corregido a z-(--z-sticky) (son
barras fijas, no menús) y el de SegmentedControl a z-(--z-raised),
tal como indica la nota B.1.5 del plan. glass-bar y theme-transition
se aplican en PlayerBar (ambas variantes), TabsBar, Queue e Inicio
(hero/spotlight), sustituyendo estilos duplicados de blur/transición.

Verificado con npm run check (0 errores) y npm run build; smoke test
de /login sin errores de consola. Sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:39:20 +02:00
AdminandClaude Sonnet 5 90f9a76628 web-player: P11 — formularios (CoverForm, SegmentedControl, Chip)
CoverForm.svelte es el esqueleto compartido (form + enhance + portada
+ mensaje de error + botones) de AlbumForm y PlaylistForm, que quedan
solo con sus campos propios vía snippets `fields` (junto a la
portada) y `extra` (a ancho completo, no está en la spec literal pero
necesario: la descripción del álbum y el selector de visibilidad de
playlist no caben en la columna junto al arte).

ImageDropzone gana `size="hero"` (size-cover-hero + rounded-art-lg) y
un callback `onselect`; la página de subir canción usa ahora este
componente en vez de reimplementar el selector de portada a mano
(envuelto en {#key} para poder resetear tras publicar). Su icono de
posición vacía pasa de 64px a icon-xl (36px), como recomienda el plan.

SegmentedControl.svelte (genérico, N opciones) sustituye el selector
Privada/Pública de PlaylistForm. Chip.svelte sustituye los chips de
filtro de Explorar (selected/count) y los chips de género sugeridos en
"Sin resultados" (href, estilo outline).

@utility field-control en layout.css unifica la cadena de clases
repetida de Input y Textarea.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:58:52 +02:00
AdminandClaude Sonnet 5 2cf3e9f02d web-player: P10 — cabeceras, diálogos y tipografía
PageHeader fusiona CollectionHeader (title/eyebrow/description/meta/
cover?/actions?/align?): sin cover se comporta como el antiguo
PageHeader simple (acciones en línea), con cover como el antiguo
CollectionHeader (acciones debajo). Fija su propio margen inferior
(mb-7/8); se quitan los mt-* que ponía cada página después. Migra los
9 sitios que usaban uno u otro (álbumes, biblioteca, listas, subir,
Explorar ×2, me gusta, mezcla, perfil). CollectionHeader se borra.

AlbumHeader y PlaylistHeader se inlinean en sus páginas y se borran.
Decisión (recomendada, sin bloquear): PlaylistHeader gana iconos en
Editar/Eliminar/Reproducir, igual que ya tenía AlbumHeader.

ConfirmDialog nuevo sustituye los dos diálogos de borrado (álbum y
playlist), que tenían tipografías distintas. Modal pasa a usar
.glass-panel (en vez de duplicar el style= inline) y z-(--z-modal); su
eyebrow y título usan las recetas text-eyebrow/text-display-3.

SectionHeading gana `count?` (línea "N resultados", sustituye 4 copias
en Explorar) y `href?`/`linkLabel?` ("Ver todas", sustituye la de
Inicio). Aplica recetas de tipografía (text-display-2/3,
text-eyebrow, text-body) y tokens de ancho (max-w-prose-sm, max-w-hero)
en EmptyState, +error, "Sin resultados", hero y stats de Inicio, Queue
y Sidebar. El estado vacío "Todavía no has escuchado nada" (repetido
2 veces en Inicio) pasa a un snippet local.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:48:23 +02:00
AdminandClaude Sonnet 5 5dec00faf1 web-player: P9 — tarjetas y grids (MediaCard)
MediaCard pasa a construir su propio arte (src/trackIds vía Artwork
size="fill", o un snippet art a medida) y su botón de reproducir
(onPlay, con scrim y PlayButton glass en hover) en vez de depender de
que cada wrapper le pase un snippet. Sustituye a AlbumCard,
PlaylistCard y MixTile, que se borran junto con MediaGrid.svelte (sus
grids pasan a los @utility grid-cards/grid-cards-lg/grid-tiles/
grid-wide de P1).

format.ts gana albumMeta() y playlistMeta() (antes cálculos ad-hoc
dentro de cada tarjeta). El tile de género en Explorar pasa de un
style= con el gradiente inline a la receta @utility genre-tile
+ --tile-hue. De paso, corrige la pluralización "seguidor/es" del
perfil de usuario que quedó suelta.

Delta documentado: el hover-zoom del arte y el scrim+PlayButton (antes
exclusivos de MixTile) pasan a ser comportamiento de MediaCard
condicionado a la prop onPlay — se aplicarían a cualquier tarjeta
futura que la use, por consistencia.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:30:30 +02:00
AdminandClaude Sonnet 5 a7d5869c23 web-player: P8 — tablas de pistas (TrackList)
Crea TrackList.svelte, que sustituye TrackTable+TrackRow+
TrackIndexCell+TrackMeta y las 6 tablas de pista (biblioteca, me
gusta, mezcla, álbum, playlist, selector de biblioteca en álbum):
columnas declarativas (added/uploaded/plays) con anchos fijos como
constantes del módulo, índice con EQ para la pista activa (o overlay
sobre el arte cuando index={false}, para el selector de biblioteca),
y rowAction que genera el form+IconButton (removeTrack/addTrack/
deleteTrack) o un onclick directo (quitar de Me gusta).

AlbumTrackTable, PlaylistTrackTable y LibraryPicker se inlinean en sus
páginas y se borran, junto con los 4 componentes que sustituye
TrackList.

Decisión (recomendada por el propio plan, sin bloquear): se elimina la
columna "Álbum", que en las 3 tablas que la tenían siempre mostraba
"—" (placeholder sin datos reales).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:21:15 +02:00
AdminandClaude Sonnet 5 5a528e197e web-player: P7 — filas (ListRow, MediaIdentity, EqBars)
Crea MediaIdentity.svelte (renombra y generaliza TrackTitleCell,
puramente presentacional: arte + título + subtítulo, sin manejo de
click) y ListRow.svelte encima (href|onclick, active, size xs/sm/lg,
variant row|card, leading/trailing/overlay). Usa MediaIdentity también
en player/TrackInfo... (pendiente: TrackInfo se deja para P12, que ya
tiene tarea explícita de tocar ese archivo).

Migra a ListRow: SearchResultRow (Explorar, 4 grupos — se borra el
componente), Inicio (Continuar escuchando, Populares, spotlight, Tus
playlists), Queue (A continuación), Sidebar (playlists). Las 6 tablas
de pista (AlbumTrackTable, PlaylistTrackTable, library, liked, mixes,
LibraryPicker) migran a MediaIdentity con su propio use:pressable
local, ya que TrackList (P8) todavía no existe.

EqBars gana prop `overlay` (fondo scrim/45, color on-art) que
sustituye a NowPlaying.svelte (borrado). Corrige de paso 6 llamadas a
plural() que P5 había dejado sin migrar (liked, library, mixes,
playlists, PlaylistHeader, Inicio).

Deltas documentados en el registro del plan: fila activa de Sidebar
ahora usa el tinte de acento (antes gris neutro); tarjeta "Tus
playlists" de Inicio pierde la línea de autor redundante y su radio
pasa a rounded-art; filas de Queue pierden el opacity-80 del arte.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:14:20 +02:00
AdminandClaude Sonnet 5 a0c9c5fca9 web-player: P6 — menú contextual único (ContextMenu) + AccountMenu
Crea src/lib/components/ui/ContextMenu.svelte, que sustituye
TrackContextMenu y AlbumContextMenu (109 y 119 líneas, 90% idénticas):
misma función de posicionamiento (contextMenuPosition, exportada del
módulo, usando MENU_WIDTH/SUBMENU_WIDTH movidas a config.ts), mismo
backdrop, mismo Escape, mismo submenú "Añadir a playlist" genérico vía
{items, playlistAction, playlists}. Usa MenuItem para las acciones
(antes reimplementaban sus clases a mano) y Artwork size="xs" para las
miniaturas de playlist.

menus.svelte.ts (createTrackMenu) se adapta a la nueva
contextMenuPosition; se migran Inicio, Explorar (menú de pista y de
álbum) y Mezcla.

AccountMenu: panelClass (clases Tailwind libres) pasa a prop
width: 'sm'|'md'; añade cierre con Escape (mejora de accesibilidad,
antes no cerraba); TopBar y MobileHeader dejan de envolverlo en un
div.relative duplicado (su propio root ya lo es). z-index del panel
pasa a z-(--z-menu), al mismo nivel que ContextMenu (antes z-20 en
TopBar / sin z explícito en MobileHeader).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:05:53 +02:00
AdminandClaude Sonnet 5 d858c85a72 web-player: P5 (parcial) — helpers TypeScript
Crea src/lib/actions/pressable.ts y clickOutside.ts (Svelte actions;
role/tabindex se mantienen estáticos en el markup por accesibilidad
SSR, la acción solo añade click + Enter/Espacio) y src/lib/toggle.svelte.ts
(createToggle), usado por playlists.svelte.ts y queuePanel.svelte.ts.

format.ts: añade plural(n, uno, muchos); migra las 15 pluralizaciones
manuales del repo, unificando a minúscula (Δ visible en Playlists,
Inicio, liked, Explorar).

player.svelte.ts: añade toQueueItem, isQueueCurrent, playAllOrToggle,
playShuffled; renombra addToQueue → playNextItem (el nombre anterior
confundía: inserta a continuación, no al final). Migra los 5 sitios
que repetían el patrón isCurrentQueue/playAll (álbum, playlist, liked,
mezcla, Inicio). Simplifica liked.svelte.ts y recentlyPlayed.ts con
object spread en vez de reconstruir cada campo a mano.

navigation.svelte.ts: añade searchHref (migra el buildHref duplicado
de TopBar y Explorar) y appNavLinks (creado; su adopción en Sidebar/
TabsBar queda para P12, como indica esa parte).

menus.svelte.ts: createTrackMenu() sustituye el estado manual de menú
contextual (contextMenu + contextMenuStateFor + addToQueue) en Inicio,
Explorar y Mezcla.

Pendiente a propósito: la tarea 5 (eliminar el envoltorio TrackTarget
de tracks.ts y sus 8 accesores) se difiere a P13 — es el cambio de
mayor riesgo del plan, toca tipos de forma transversal en Inicio/
Explorar/Mezcla/menús, y no se pudo verificar visualmente sin Docker
en esta sesión.

Verificado con npm run check/build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:01:13 +02:00
AdminandClaude Sonnet 5 12d76ccecf web-player: P4 — botones (Button, IconButton, PlayButton)
Button: variantes subtle (primary/secondary, se mantienen iguales por
decisión del usuario), glass (antes secondaryOnGlow, con font-normal
propio), accent, strong (cta-strong/ink, sustituye los overrides `!`
del hero de Inicio), danger. Usa los tokens --color-btn* de P1 en vez
de bg-[var(--mf-btn-...)]. Radio unificado a rounded-control.

IconButton: añade shape (square/round), size (xs/sm/md/lg), tone
(muted/subtle/plain), pressed (aria-pressed + text-accent), href/reload
(enlaces), surface (fondo permanente + hover a surface-hover en vez del
alias `hover` genérico, que coincide con surface-2 y no daba contraste).

PlayButton nuevo: un solo sitio para los SVG de play/pausa, variantes
strong/glass, as="span" para overlays no interactivos. Sustituye el
botón principal de TransportControls, el overlay de MixTile y el
círculo de "Mejor resultado" en Explorar.

Migra a IconButton: cerrar cola, "Crear playlist" (Sidebar), Me gusta
(TrackInfo, con pressed), cola (PlayerExtras), atrás (TopBar), login
(MobileHeader) y las acciones de fila en tablas (quitar/añadir,
size xs). BackLink usa size-icon-sm y quita un hover duplicado.

Decisión (sin bloquear): se elimina el botón "adelante" decorativo de
TopBar y el icono AlignJustify decorativo de PlayerExtras (código
muerto visual). Los botones de TransportControls (shuffle/prev/next/
repeat) se dejan sin migrar a propósito: hoy no tienen fondo ni
hit-box propios y IconButton siempre aplica una caja de tamaño fijo —
migrarlos habría añadido una píldora de hover visible no verificable
sin Docker en esta sesión.

Verificado con npm run check/build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:48:08 +02:00
AdminandClaude Sonnet 5 bf133f199c Remove no-op playlist picture migration; cover picture-add transition in tests
The Sept migration MakePlayListPictureColumnsOptional altered the
PlayLists picture columns to nullable:true when they were already
nullable:true (set back in MakePlayListPicturesNullable, two months
earlier) — a dead migration left over from making the Pictures
navigation itself optional, which produces no actual schema change.
Verified with `dotnet ef migrations has-pending-model-changes` and a
full migration run against a fresh Postgres container that removing
it introduces no drift.

Also add a test for updating a playlist that has no picture yet,
which was the one Pictures-related state transition without coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:40:40 +02:00
AdminandClaude Sonnet 5 1186903d22 web-player: P3 — Artwork y Avatar unificados
Crea src/lib/components/ui/Artwork.svelte y Avatar.svelte (B.3) y
migra todos los usos de Cover, PlaylistArt, MixArt y ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explorar, mezcla, perfil de usuario, TopBar,
MobileHeader). Borra los 4 componentes viejos.

Artwork: tamaño decide dimensión y radio automáticamente (tokens
cover-*/radius de P1); cascada src → mosaico (≥4 trackIds) → primera
carátula → fallback, con caída en cascada si cada paso falla (antes
repartida en 3 componentes con lógica ligeramente distinta). Añade un
tamaño 'fill' (aspect-square w-full) no descrito en la spec, necesario
para las tarjetas de grid fluidas hasta que P9 introduzca MediaCard.

TrackTitleCell cambia su prop `coverSize` (clases Tailwind libres) por
`size: 'sm'|'lg'` delegando en Artwork. SearchResultRow pierde su prop
`kind` y el radio por tipo (ahora lo decide Artwork/Avatar).

Decisión de producto (P3, sin bloquear en pregunta): se quita el
nombre bajo el avatar de perfil en /u/[id] por ser redundante con el
título de CollectionHeader.

Small deltas de radio/tamaño documentados en README del plan (auto-
radio por tamaño reemplaza clases ad-hoc; ver tabla B.1.1/B.1.2).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:39:05 +02:00
AdminandClaude Sonnet 5 d447495933 web-player: P2 — tema: fuente única de verdad y sin parpadeo en claro
Elimina la duplicación de los ~18 tokens dinámicos por hue entre
theme.css y buildThemeTokens(): theme.css ya solo define neutrales
fijos, superficies, layout y z-index; los valores por hue/modo salen
únicamente de tokens.ts, inyectados por SSR como <style> inline en
+layout.svelte (helper tokensToCss) y reaplicados en cada cambio de
hue. app.html fija data-theme antes del primer paint leyendo
localStorage, eliminando el flash oscuro en modo claro guardado.

Los dos fondos con style= inline pasan a @utility app-backdrop/
app-vignette; el padding-bottom del main pasa a @utility app-main
activada por --mf-dock-h vía [data-has-track].

Nota: <style>{expr}</style> no interpola en Svelte (se compila como
CSS con scope al componente) — el bloque SSR se inyecta con
{@html `<style>${css}</style>`} ya que el contenido es 100% generado
(sin input de usuario).

Verificado en el navegador: --mf-accent/--mf-bg resuelven correctamente
en SSR, data-theme='light' se aplica antes del primer render sin flash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:26:37 +02:00
AdminandClaude Sonnet 5 13f4f3457a Clean up Application layer commands/queries readability and correctness
Add missing AsNoTracking() on read-only queries, drop redundant
Update() calls on already-tracked entities, and fix inconsistent
formatting (stray spaces in generics, ternaries, and lambdas) across
the Albums/Tracks/PlayLists/Users command and query handlers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:23:57 +02:00
AdminandClaude Sonnet 5 10fb5b307d web-player: P1 — tokens base del sistema de diseño
Añade de forma aditiva (sin migrar componentes) los tokens de la
sección B.1 del plan de refactor: radios (tag/thumb), tamaños de
carátula e icono, anchos de contenedor, tracking/leading tipográfico,
colores de botón (--color-btn*), recetas @utility de tipografía
(text-display-*, text-eyebrow, text-body, text-count), trazo de icono
(stroke-thin/regular/bold), superficies/grids (glass-bar, grid-cards*,
theme-transition) y el stagger de .animate-enter vía --i. Añade
variables de layout (--mf-player-side-w, --mf-menu-w, --mf-submenu-w,
--mf-range-*) y z-index semántico en theme.css. Limpia utilidades de
color sin uso (--color-accent-dim/glow/hair/line/muted, --color-on-accent)
y borra --mf-sidebar-bg (sin importadores) de theme.css y tokens.ts.
Scrollbar pasa a tokens con variante de modo claro.

Verificado con npm run build (Tailwind compila los @utility con
variantes responsive dentro de @apply).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:21:04 +02:00
AdminandClaude Sonnet 5 8bde6ae49a web-player: P0 — línea base y bugs sueltos
Corrige eslint (imports muertos en liked), tamaño de título ausente en
MediaCard, clases no excluyentes en las filas del spotlight de Inicio,
tipografía del modal de borrado de álbumes, text-base inútil en botones
de icono (Sidebar, Queue) y borra Badge.svelte (sin usos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:14:48 +02:00
AdminandClaude Sonnet 5 17f1ea6b4f Update backend NuGet packages to latest compatible versions
Bump MassTransit family to 8.5.10 (latest free/open-source patch on
the 8.x line; v9 requires a paid commercial license, so it's out of
scope). Align OpenTelemetry.Instrumentation.EntityFrameworkCore and
.Process prerelease versions with the rest of the OpenTelemetry 1.19.0
set. Microsoft.OpenApi and SixLabors.ImageSharp are left as-is: OpenApi
is already pinned to the latest version compatible with
Microsoft.AspNetCore.OpenApi's [2.12.0, 3.0.0) constraint, and
ImageSharp 3.x/4.x carry a commercial licensing model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:09:48 +02:00
Admin 80a8160bff Changes on packages 2026-09-20 16:23:49 +02:00
Admin a9c6f68792 Update packages 2026-09-20 16:00:57 +02:00
AdminandClaude Sonnet 5 09bcf197f5 Checkpoint web-player design-system refactor and backend track genre tagging
Advances the ongoing token/componentization cleanup (anchored player dock,
border-based panels instead of floating shadows, corrected light-mode
accent/gradient tokens) alongside in-progress backend support for track
genre tags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:43:37 +02:00
AdminandClaude Sonnet 5 41ed904a91 Remove YouTube integration from web-player; unify Home track rows
Drops every YouTube-backed route, proxy and type (external
albums/tracks, thumbnail proxy, youtube.ts) and simplifies
player/track/mix types to the local-only catalog now that the
backend contract dropped Source/ExternalId. Genre tiles on Explore
and "Populares esta semana" on Home stay as deliberate client-only
placeholders per product decision, just without the YouTube backing.

Also finishes the design-system unification pass: Home's three
hand-rolled track rows (Continuar escuchando, Playlist destacada,
Populares esta semana) now reuse the shared TrackTitleCell component
instead of duplicating cover+title+artist markup, and the orphaned
Rail.svelte (no importers) is removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:17:14 +02:00
AdminandClaude Sonnet 5 693bfac41f Remove YouTube integration from backend and deployment config
Drops the YouTube-backed second catalog source entirely: the
Local/External split on Track/Album collapses into single concrete
entities, Artist/TrackArtist/ExternalTrack/ExternalAlbum and the
YouTube service/download pipeline (yt-dlp, MassTransit consumer,
routing slip) are deleted, and mixes now source discovery only from
local listening history. Includes the RemoveYouTubeIntegration EF
Core migration and matching Worker/deploy/compose cleanup (no more
yt-dlp/python/deno in the Worker image, no thumbnail-cache/YtDlp
config sections).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:17:03 +02:00
AdminandClaude Sonnet 5 4b805f997d Fix light-mode regression and finish design-system cleanup
A code-review pass on the previous commit found a real bug: the explore
genre tiles used var(--mf-ink) as a decorative gradient endpoint, but that
token is meant for text-on-accent and flips to white in light mode,
turning the tiles solid white. Replaces it with a new, deliberately
theme-invariant --mf-tile-shade token (same category as --mf-cover-grad).

Also: raises CollectionHeader's meta-line contrast in light mode
(text-muted, ~3:1, to text-fg-2), makes its actions prop optional so
pages without conditional actions don't render an empty wrapper, moves
AccountMenu.svelte into $lib/components/ui/ per the repo's own
componentization rule, adds the aria-expanded TopBar's trigger was
missing relative to MobileHeader's, and migrates the profile page
(u/[id]/+page.svelte) onto CollectionHeader — a sixth duplicate of the
same header pattern that the previous commit touched but missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:06:37 +02:00
AdminandClaude Sonnet 5 c947cdac1c Unify web-player design tokens and componentize collection headers
Migrates remaining one-off colors (bg-white/[N%], border-white/N, hardcoded
shadows/gradients) to the existing --mf-* tokens so light mode reacts
consistently everywhere, including a fix to --color-hover which was
hardcoded instead of pointing at --mf-surface-2. Extracts CollectionHeader
(album/playlist/mix/liked headers) and AccountMenu (TopBar/MobileHeader)
to remove five and two duplicated implementations respectively, and
normalizes inconsistent empty-state copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:53:47 +02:00
AdminandClaude Sonnet 5 33a9e886be Redesign explore search and inline top-bar search box
- Explore: replace per-kind colored badges/tints in search results
  with the app's neutral accent, add an always-visible Usuarios chip,
  and keep the top-result type label as plain text
- Move SearchBox into TopBar/MobileHeader as an inline debounced
  search form, dropping the standalone SearchBox component
- Sync design handoff artboards with the current search UI

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 03:35:05 +02:00
AdminandClaude Sonnet 5 ec54645dc8 Add likes, follows and playlist visibility; redesign web-player UI
Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 23:42:45 +02:00
AdminandClaude Sonnet 5 63de050355 Add anonymous listening support and redesign web-player UI
Backend:
- Expose GET /config/playback so the frontend can detect whether
  anonymous listening is enabled without requiring auth
- Wire anonymous access through track streaming and YouTube resolve flows

Frontend:
- Allow browsing/listening without a session when the backend enables it
- Redesign Inicio, Sidebar and top bar with a new dark theme, typography,
  and layout (top bar with live quick-search, sticky/blurred on scroll)
- Populate Top música, Popular and Recomendados with real Musify +
  YouTube Music data instead of placeholders
- Various shape, spacing, color and animation refinements across the
  redesigned components

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 01:29:37 +02:00
AdminandClaude Sonnet 5 5046239c76 Split TrackApplicationResponse into a Local/External hierarchy
Same treatment as AlbumApplicationResponse, but Track is the cleaner
case: OwnerUserId (local-only) and ExternalId (external-only) are
both genuinely exclusive to one side, so the base carries neither and
is abstract — every real value is one of the two derived types.

    TrackApplicationResponse (abstract, shared fields + FromEntity dispatch)
      LocalTrackApplicationResponse    (+ OwnerUserId)
      ExternalTrackApplicationResponse (+ ExternalId)

Hit a real gotcha along the way: the response already had an explicit
Source property, which collides with the "source" discriminator STJ's
polymorphism support injects ("contains property 'source' that
conflicts with an existing metadata property name" — only surfaced at
runtime in the Api integration tests, not at build time). Fixed by
dropping the now-redundant explicit Source field entirely; the
discriminator already serializes the identical "Local"/"YouTube"
value under the same name, so the wire contract is unchanged. Updated
the two call sites that read .Source directly (TrackSearchItemResponse
and a playlist track filter that needed flatMap instead of
filter().map() to keep the narrowing across the chain).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:19:59 +02:00
AdminandClaude Sonnet 5 b46e3391a9 Split AlbumApplicationResponse into a Local/External hierarchy
AlbumApplicationResponse used to carry YouTubeAlbumId/ThumbnailUrl as
two always-present-but-usually-null fields for the sake of a single
flat shape. Since one of these fields IS genuinely exclusive to
external albums, an inheritance split reads better than the flattened
shape: AlbumApplicationResponse now holds only the fields shared by
every album, and ExternalAlbumApplicationResponse adds ExternalId
(renamed from YouTubeAlbumId, matching the source-generalized naming
used everywhere else) and ThumbnailUrl.

[JsonPolymorphic]/[JsonDerivedType] are required here, not optional:
without them System.Text.Json serializes strictly by the declared CLR
type at each call site (both the bare return type and nested inside
AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse
instance would silently lose its extra fields on the wire. This also
gives every response a "source" discriminator field for free,
consistent with AlbumSearchItemResponse.Source, and the generated
OpenAPI schema now models it as a proper oneOf/discriminator instead
of nullable fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:19:31 +02:00
AdminandClaude Sonnet 5 7d1bd1be35 Make album cover pictures mandatory, unify cover handling with playlists
Albums previously had no cover concept at all: no picture upload
flow, no GET /albums/{id}/cover, and AlbumApplicationResponse's
image-key fields were plumbed but never populated. Description,
Pictures and TrackCount already lived on the shared Album base entity
for both local and external albums, so unifying the cover story only
required building the missing local-album picture pipeline.

Backend, cloning PlayList's existing async picture-processing
architecture exactly (MassTransit routing slip + saga), but making
the picture mandatory instead of optional (matching Track's pattern):

- AlbumPictures moves from all-nullable to required fields.
- New AlbumConfiguration (routes/sizes), deliberately without
  PlayList's preset-picture fallback since a picture is now required
  up front.
- CreateAlbum now takes a mandatory PictureIntentId, validates and
  consumes the upload intent, and kicks off the same
  copy-to-final -> resize -> upload -> update-entity routing slip
  PlayLists use, via a new AlbumProcessingState saga that marks the
  album Failed on a picture-processing failure.
- UpdateAlbum takes an optional NewPictureIntentId to replace the
  cover later.
- New GET /albums/{id}/cover and POST /albums/upload-picture
  endpoints, mirroring the playlist ones.
- New EF migration for the AlbumProcessingState saga table.

Frontend:

- AlbumForm gains a cover dropzone, required on create and optional
  on edit (via ImageDropzone's new `required` prop), uploaded through
  the same presigned-upload helper playlists use.
- New /api/albums/{id}/cover proxy route.
- PlaylistArt gains a generic coverUrl prop so AlbumHeader and the
  album list/detail cards render the real uploaded cover instead of
  only the track mosaic fallback.

Delete-time picture cleanup for albums is intentionally left as a
follow-up, matching the existing gap in playlist deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:04:36 +02:00
AdminandClaude Sonnet 5 3fb8328d3b Generalize YouTube-backed endpoints behind a source parameter
Removes the last YouTube-specific routes (/youtube/*) and merges them
into the general track/album/playlist endpoints, keyed by a `source`
string validated against TrackSource. This mirrors the earlier track
search merge for albums and playlist-track additions:

- GetAlbums now merges local albums with live YouTube Music search
  results, paginated via a youtubeContinuationToken, deduped against
  already-materialized ExternalAlbum rows.
- New GET /albums/external/{source}/{externalId} and
  GET /tracks/external/{source}/{externalId}/stream replace the
  YouTube-only equivalents.
- POST /playlists/{id}/tracks replaces the separate
  .../youtube-tracks endpoint, accepting an optional Source/ExternalId
  pair alongside TrackId.
- AlbumSearchItemResponse/AlbumsSearchResponse follow the same
  discriminated-union + trailing-HasNextPage-field pattern already
  used for track search, so HasNextPage stays a required field in the
  generated OpenAPI schema.
- Frontend routes renamed accordingly (/albums/youtube/[albumId] ->
  /albums/external/[source]/[externalId], etc.) with a single
  /api/thumbnail proxy replacing /api/youtube/thumbnail.

Musify.Api.Endpoints.YouTubeEndpoints and the standalone
SearchYouTubeAlbums query are removed as a result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:03:36 +02:00
AdminandClaude Sonnet 5 20d01a0eb2 Add Kotlin Multiplatform mobile client
A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.

deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:03:11 +02:00
AdminandClaude Sonnet 5 7ac1f7bad8 Shorten backend project file names
Each .csproj is renamed to just the project's own name within its folder
(Core/Musify.Application/Musify.Application.csproj -> Application.csproj,
and so on for every Core/Hosts/Tests project) — the Musify.* folder name
already carries that information, so the file name was redundant. Explicit
<AssemblyName>/<RootNamespace> keep the compiled assembly and C# namespaces
unchanged, so this is a build-file-only rename, not a namespace migration.
Dockerfiles, Musify.slnx, and every ProjectReference are updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:02:59 +02:00
AdminandClaude Sonnet 5 89bf44a48a Add anonymous playback with an optional Spotify-style preview cap
New Playback:AllowAnonymousListening / Playback:AnonymousFragmentSeconds
settings control whether a request without a session can stream at all,
and if so, whether it's limited to a fragment instead of the full track:

- GET /tracks/{id}/stream and GET /youtube/tracks/{videoId}/stream no
  longer require authorization outright; the handlers gate on the config
  instead, so both keep working exactly as before when it's off (the
  default).
- The fragment cap is enforced by the Streaming Gateway, not the client:
  the stream ticket now carries an optional maxBytes claim (computed from
  AnonymousFragmentSeconds and the transcoder's known bitrate), and
  TicketValidationMiddleware clamps the Range it forwards to SeaweedFS to
  stay under it — synthesizing one if the request didn't send one, and
  answering 416 outright if the requested range starts past the cap.
- A YouTube track that hasn't been provisioned server-side yet streams
  straight from YouTube's CDN, bypassing the Gateway entirely — there's no
  way to cap that, so an anonymous request for one is refused (409) instead
  of silently handing out an uncapped stream.
- ListeningHistory has a non-nullable UserId, so anonymous plays simply
  don't get recorded rather than trying to force a row through the FK.

Also fixes a real, pre-existing bug this surfaced: the AppSettings.Development.json
of the Api, Worker, and StreamingGateway all pointed StreamTicket/Workers
paths at "../../deploy/..." — one directory short of deploy/'s actual
location at the repo root relative to each project's own folder. Docker
never hit this (it mounts an absolute /keys path), so it had apparently
never been exercised via `dotnet run` before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:56:57 +02:00
AdminandClaude Sonnet 5 3feef07cbf Polish typography, button states, and login screen
Applies the display typeface (already used for small section labels)
consistently to every major heading — page titles, hero titles on
album/playlist/mix pages, the home hero, the sidebar/mobile wordmark, and
empty-state titles — instead of only the smaller ones, fixing an
inconsistent visual hierarchy. Also fixes a couple of real bugs found along
the way: Input had a focus border color with no border width to show it,
Button/IconButton had no focus-visible ring at all, and SectionHeading's
letter-spacing only applied when it had an actions slot.

Button's secondary/subtle variants drop their harsh border and flat grey
hover for a softer accent-tinted wash. The accent (and the per-track
extracted accent) is desaturated for a calmer, more pastel feel without
touching lightness, so on-accent text contrast is unaffected.

The login screen gets the same treatment plus a subtle ambient glow (reusing
the home page's existing accent-gradient technique) and a fluid,
viewport-scaled headline instead of a few fixed breakpoints, so it fills the
available space on wide screens instead of staying pinned to one size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:56:16 +02:00
AdminandClaude Sonnet 5 45f0a94758 Combine track search with live YouTube Music results
GET /tracks now merges the local catalog page with a live YouTube Music
search (deduplicated against tracks already provisioned locally) whenever a
name filter is given, appending the YouTube hits after the local matches and
carrying a continuation token for further pages. Each item is tagged with its
source via the new TrackSearchItemResponse/TracksSearchResponse shapes,
wrapping the existing TrackApplicationResponse or a raw YouTubeSongResult so
neither type had to change.

GET /youtube/search is removed along with its now-unused handler; the
web-player's explore page, its /api/tracks proxy, and the YouTube filler
helper are updated to call the combined endpoint instead of hitting the two
separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:55:58 +02:00
AdminandClaude Sonnet 5 4e28df8907 Strip comments and dedupe normalized literals in backend tests
- Remove all // and /// comments from the backend test suite.
- Replace hardcoded normalized-form string literals (e.g. NormalizedTitle/
  NormalizedName duplicating a title/name in upper case) with a variable
  plus .ToUpperInvariant(), so the two values can't drift apart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 02:45:11 +02:00
AdminandClaude Sonnet 5 d02b1f9406 Add web-player Claude Code skills
Installed skills for the SvelteKit/Tailwind/Vite stack under
web-player/.agents/skills (source of truth) with web-player/.claude/skills
symlinked to it for tool compatibility, plus skills-lock.json tracking
each skill's registry source and content hash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:18:42 +02:00
Admin 0a01a0feee Use block-scoped namespaces in the test suite
Converts all 73 test source files from file-scoped (namespace X;) to
block-scoped (namespace X { ... }) declarations, re-indenting their
contents accordingly. Matches the existing convention already used in
several production files (e.g. Musify.Infrastructure.Configuration,
Musify.Application.Contracts).

Mechanical change only, done with a script, verified with a full
rebuild (0 warnings, 0 errors) and test run (257/257 passing).
2026-09-09 23:09:43 +02:00
Admin d327d8c4f4 Add a config toggle to enable/disable the YouTube Music source
- YouTubeConfiguration.Enabled (default true, Infrastructure): single
  switch for the whole source
- DisabledYouTubeMusicService: null-object IYouTubeMusicService that
  fails every call with Forbidden, registered instead of the real one
  when disabled (AddYouTubeMusicService) so no client is ever built
- AppSettings.json (Api + Worker): document YouTube.Enabled explicitly;
  overridable via YouTube__Enabled env var like every other section
- CurrentUser.cs: small unrelated cleanup, collapse the IsAuthenticated
  computation to a single null-coalescing expression

Search, albums, stream resolution and adding a new YouTube track all
surface as 403 Forbidden through the existing ErrorOr -> HTTP mapping.
Daily mix generation already treated a YouTube search failure as "no
YouTube candidates" and keeps working with local-only candidates —
added a test locking that in. Already-downloaded YouTube tracks are
unaffected since playback never goes through IYouTubeMusicService.

257/257 tests passing (7 new), 0 build warnings.
2026-09-09 23:00:15 +02:00
Admin 91d181d39f Migrate backend test suite from xUnit v2 to v3
- Directory.Packages.props: xunit 2.9.3 -> xunit.v3 4.0.0, bump
  xunit.runner.visualstudio to the matching 4.0.0
- All 5 test csproj: xunit -> xunit.v3 package reference, add
  <OutputType>Exe</OutputType> (v3 test projects are standalone
  executables)
- backend/global.json: opt dotnet test into the new MTP-native mode
  (.NET 10 SDK removed the old VSTest bridge for MTP-based test
  projects like xunit v3)
- IAsyncLifetime implementations (HandlerTestBase, ApiTestFixture,
  InfrastructureTestFixture, StorageServiceTests): v3's IAsyncLifetime
  now IS IAsyncDisposable, so InitializeAsync/DisposeAsync return
  ValueTask instead of Task. ApiTestFixture specifically now overrides
  WebApplicationFactory.DisposeAsync instead of implementing
  IAsyncLifetime.DisposeAsync explicitly, since v3 collapses both to
  the same single DisposeAsync slot
- Replaced CancellationToken.None with TestContext.Current.CancellationToken
  across the suite (a v3-only API) and added it to every call that
  was silently omitting a CancellationToken argument, per the new
  xUnit1051 analyzer warnings that came with the v3 package

Verified: dotnet build (0 warnings, 0 errors) and dotnet test, 250/250
passing under the new MTP runner.
2026-09-09 22:11:29 +02:00
Admin 50b54031f3 Fix test bad practices and tighten weak assertions
- StorageServiceTests: replace out-param + manual dispose with IAsyncLifetime
- PictureServiceTests: Assert.True bound checks -> Assert.InRange
- CreateAlbumCommandHandlerTests: assert the specific NotFound error type
  instead of just IsError, matching what the test name promises
- CreateAlbumRequestValidatorTests: replace a boundary Theory that computed
  its expected result inline with explicit Facts against the real
  AlbumReleaseYear constants
- Remove unused using directives across a handful of production and test
  files found during the review

Full suite verified green: 250/250 tests passing, 0 build warnings.
2026-09-09 21:53:00 +02:00
AdminandClaude Sonnet 5 ca2900acae Add Infrastructure/Api/StreamingGateway test projects
Second pass on backend/Tests/, covering what Domain+Application (the first
pass) deliberately left out — the parts that talk to real infrastructure:

  Musify.Infrastructure.Tests   Database/migrations, StorageService,
                                 StreamTicketService, AuditableEntityInterceptor,
                                 PictureService, SingleFlightCache,
                                 PlayListPresetSeeder.
  Musify.Api.Tests               Real HTTP endpoints via WebApplicationFactory
                                 (routing, auth, ValidationFilter, ErrorOr->HTTP
                                 mapping), plus isolated unit tests for
                                 ErrorOrHttpExtensions, CurrentUser and the
                                 FluentValidation validators.
  Musify.StreamingGateway.Tests  TicketValidator (RS256) and
                                 TicketValidationMiddleware (path traversal,
                                 prefix boundary, ticket extraction).

Musify.Worker gets no test project: its Program.cs is pure DI/host wiring
(Hangfire, MassTransit, jobs); the logic it runs lives in Musify.Infrastructure,
already covered there.

Per the user's direction, the parts that touch real infrastructure use
Testcontainers instead of fakes: a real Postgres (Testcontainers.PostgreSql)
for Infrastructure.Tests and Api.Tests, and — matching deploy/compose.yml
exactly (chrislusf/seaweedfs:latest, same startup command), not MinIO — a
real SeaweedFS container for StorageService. Api.Tests boots the actual
Musify.Api host against that Postgres (real routing/auth/validation) but
substitutes the services that talk over the network (storage, YouTube, the
message bus, stream-ticket signing) with NSubstitute fakes, and swaps the
JwtBearer scheme for a test-only header-based one (FakeAuthenticationHandler)
so tests don't need a reachable Zitadel. Application.Tests' approach — a real
DbContext (SQLite in-memory) rather than mocking IDatabase, since it exposes
DbSet<T> directly and handlers compose real LINQ queries against it — carries
over into Infrastructure.Tests/Api.Tests via real Postgres instead.

Testing against the real backend (not a fake) found two genuine production
bugs, both fixed here:

- StorageService.RemoveFolderAsync threw NullReferenceException against
  SeaweedFS: its DeleteObjects response omits the <Error> list entirely when
  nothing failed, which the AWS SDK deserializes as null rather than an empty
  list (real AWS S3 always returns one). Now treats null as no errors.
- AuditableEntityInterceptor never bumped UpdatedAt on a normal update (load,
  mutate a property, SaveChanges) — only on insert. SavingChanges(Async) fires
  before EF's own automatic change detection for that save, so an entity
  modified through a plain property setter still reported EntityState.Unchanged
  inside the interceptor. Now calls ChangeTracker.DetectChanges() explicitly
  first, which is the documented fix for exactly this interceptor pattern.

Also required to make the Api host testable: Program.cs now ends with `public
partial class Program;` (WebApplicationFactory<Program> needs the top-level-
statements Program class to be public — no behavior change), and
Directory.Packages.props gains Microsoft.AspNetCore.Mvc.Testing, Testcontainers
and Testcontainers.PostgreSql.

249 tests total across all five projects, all green: `dotnet test
backend/Musify.slnx` (needs Docker running for Infrastructure.Tests and
Api.Tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 21:39:06 +02:00
AdminandClaude Sonnet 5 6f6e3b9824 Clear fallbackPackageFolders in backend/nuget.config
Fixes: ResolvePackageAssets fails with "Unable to find fallback package
folder 'D:\Microsoft Visual Studio\Shared\NuGetPackages'".

That folder comes from a machine-wide Visual Studio installer config
(C:\Program Files (x86)\NuGet\Config\Microsoft.VisualStudio.FallbackLocation.config),
not from anything in this repo — it points at a fallback package cache on
whatever drive VS was originally installed on, and NuGet fails outright if
that path doesn't exist on the current machine. nuget.config already had
<clear/> under packageSources for the same reason (pin restore to nuget.org
only); it just didn't do the equivalent for fallbackPackageFolders. Restore
and build now depend only on this file, not on machine-specific VS install
history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:52:46 +02:00
AdminandClaude Sonnet 5 a9d466111c Add backend test suite under backend/Tests/
Two xUnit projects, wired into Musify.slnx under a /Tests/ folder that
mirrors Core/ and Hosts/:

  Musify.Domain.Tests        Value-object logic (TrackAudio/TrackPictures'
                              IsProcessed/IsFailed/IsInProgress). Entities are
                              otherwise anemic EF models with no behavior to
                              test.
  Musify.Application.Tests   All ~45 Application handlers and services
                              (Albums, Tracks, PlayLists, Users, Mixes,
                              YouTube, plus TrackStreamIssuer,
                              UploadIntentValidator, YouTubeTrackProvisioner).

IDatabase exposes DbSet<T> directly, so handlers compose real LINQ queries
against it — that can't be expressed with a mock (no IQueryable provider
behind one). TestSupport/TestDatabase.cs is instead a real DbContext on a
private SQLite in-memory connection per test (fresh per test since xUnit
instantiates the test class per test method): fast, no external dependency,
and — unlike EF Core's InMemory provider — SQLite enforces real foreign keys
and supports transactions, which two handlers need. External dependencies
(IStorageService, IEventBus, IYouTubeMusicService, ...) are substituted with
NSubstitute.

TestSupport/TestEntities.cs and TestConfigurations.cs build fully-populated
domain entities and configuration POCOs with sensible defaults for every
required member, so no test method needs to fill in properties it doesn't
care about, and no builder ever hands back null.

Also, needed to make the Application layer itself testable without a real
Postgres or user-secrets:
- DatabaseDesignTimeFactory now falls back to the Database__ConnectionString
  env var, and DesignSettings.json is optional — unblocks the `migrate`
  one-shot container from deploy/compose.yml, which hit the same gap.
- Directory.Packages.props: xunit, xunit.runner.visualstudio, NSubstitute,
  Microsoft.EntityFrameworkCore.Sqlite (pinned to the same 10.0.8 as the rest
  of EF Core), Microsoft.NET.Test.Sdk, coverlet.collector. Also pins
  SQLitePCLRaw.bundle_e_sqlite3 to 2.1.13 — EFCore.Sqlite 10.0.8 pulls 2.1.11
  transitively, which has a known high-severity advisory (GHSA-2m69-gcr7-jv3q).
- backend/.dockerignore excludes Tests/: no application Dockerfile needs it
  in its build context.

162 tests, all green: `dotnet test backend/Musify.slnx`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 18:29:05 +02:00
AdminandClaude Sonnet 5 f913f6f788 Replace deploy scripts with self-contained compose bootstrap jobs
Removes every wrapper script under deploy/ (up.ps1, up.sh, scripts/*,
zitadel/provision.*) and folds their logic into three one-shot services
in compose.yml, following the pattern already used by seaweedfs-init:

  keys-init     generates the RS256 stream-ticket key pair
  migrate       applies the EF Core migrations (backend/Musify.Migrator.Dockerfile)
  zitadel-init  creates the Musify project + OIDC apps, writes the client
                ids back into the active env file

Deploying is now two plain `docker compose` commands, no scripts to run
or chmod:

  docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
  docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build

(swap compose.dev.yml for compose.prod.yml + --env-file deploy/.env.prod
for production). The first command brings up infra and lets the bootstrap
jobs run to completion; the second builds and starts the apps, which by
then read real OIDC values instead of empty ones — compose interpolates
${VAR} once per invocation, so this can't collapse into a single command
without the apps reading their own config file at container start instead
of via compose env (out of scope here).

Also:
- DatabaseDesignTimeFactory now also reads Database__ConnectionString
  from the environment, and DesignSettings.json is optional, so the new
  migrate container works without user-secrets or a config file excluded
  from the Docker build context by .dockerignore.
- nginx's depends_on no longer lists the apps-profile services: it does
  not need them at its own startup (every vhost resolves its upstream at
  request time via the embedded Docker resolver), and requiring them made
  `compose.prod.yml up -d` (without --profile apps) an invalid project.
- env_get/env_set in zitadel-init strip \r: the mounted env file can have
  CRLF line endings on Windows, which otherwise makes an empty-looking
  value non-empty to `[ -z ... ]`. .env.example/.env.prod.example are
  pinned to LF via .gitattributes for the same reason. Also fixed:
  rewriting the bind-mounted env file in place instead of renaming a temp
  file over it (rename onto a bind-mounted inode fails with "Resource
  busy").
- Removed compose.share.yml and zitadel/.output-share/ — a "temporary"
  personal overlay that had leaked a real Zitadel service-account PAT
  into git history (committed in 70968b3). That token should be rotated.
- Removed deploy/.tmp/ from .gitignore: nothing mounts or creates it
  anymore (it never actually did, either — no compose service referenced
  it, only the now-removed up.ps1 did).

Verified end-to-end with Docker: a clean `down -v` + fresh `up -d` +
`--profile apps up -d --build` on the dev stack brings up all four one-shot
jobs successfully and all four apps healthy (api/gateway return 200,
worker starts its consumers with no errors, web-player responds). Also
verified compose.prod.yml starts nginx cleanly with dummy certs and no
apps running, confirming the depends_on fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:08:34 +02:00
AdminandClaude Sonnet 5 0794be0557 Reorganize backend to mirror Musify.slnx folder structure
Moves the six backend projects on disk into Core/ and Hosts/
subfolders, matching the solution folders already defined in
Musify.slnx, so the file explorer reflects the same layout as the
solution:

  backend/Core/Musify.Domain
  backend/Core/Musify.Application
  backend/Core/Musify.Infrastructure
  backend/Hosts/Musify.Api
  backend/Hosts/Musify.Worker
  backend/Hosts/Musify.StreamingGateway

Updates every reference to the old paths: ProjectReference entries in
Musify.Api.csproj and Musify.Worker.csproj, the three Dockerfiles,
deploy/scripts/migrate.ps1|sh, deploy/zitadel/provision.ps1,
deploy/up.ps1, deploy/README.md and docs/development.md. Also fixes a
stale Dockerfile comment pointing at a compose file that no longer
exists.

Verified with `dotnet build Musify.slnx` against the new layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:27:50 +02:00
Admin 70968b386c Changes 2026-08-18 14:38:00 +02:00
Admin 172a0bac61 Changes 2026-07-25 01:46:59 +02:00
Admin 81698cc039 Return null 2026-07-24 19:09:04 +02:00
Admin f633a0c5c8 Duration as double 2026-07-24 19:01:19 +02:00
Admin 2385ec38e8 Changes 2026-07-24 18:54:31 +02:00
Admin 3c2981c7f5 feat(web-player): rebuild Inicio and Buscar to the design handoff
Applies the handoff's layout and image shapes while keeping the current
Musify design system (Inter, colors, tokens) — no font or palette swap.

Inicio now stacks distinct shelves instead of repeated square grids:
recently played rows, a rail of recently listened albums with an "Álbum"
badge, a "Hecho para ti" bento of mixes, a rail of playlists with a
trailing dashed "Ver todas" tile, tall "Canciones nuevas" posters with a
"Nuevo" badge, a rail of circular top-artist avatars, and a recommended
rail. Buscar splits into Álbumes (square cards + badge), Usuarios (a
circular-avatar directory) and a flat Canciones list.

Top artists and the search user directory are filler data until the
backend exposes them. New shared pieces: Rail, CoverBadge, ArtistAvatar,
MixBentoTile. The orphaned TrackTile and MixCard are removed.
2026-07-22 04:40:14 +02:00
Admin 808637396e fix(web-player): trim the gap under the home hero 2026-07-21 23:18:11 +02:00
Admin 611b2a72cd refactor: tighten the home typography and card craft
The playlist cards were showing "No description was pr…" because the API
stored that English sentence whenever a playlist was created without a
description. Descriptions are optional now and the old rows are cleared.

Section headings drop to 18/20px so they stop competing with the content,
and the space moved from inside the sections to between them.

The three media cards repeated the same anchor, art class and label
block, so they share a MediaCard that also carries the hover lift the
track tiles already had.
2026-07-21 23:14:36 +02:00
Admin 0b149f052e fix(web-player): tune the card sizes so the grids scale
The recently played rows stretched to 378px on a 1920 screen and the
discover wall broke into eight narrow columns. Cards now stay in a
narrow band: 295-349px for the rows, 203-218px everywhere else.
2026-07-21 19:16:37 +02:00
Admin 699b36c966 feat: rework the home layout
Recently played keeps its shape. Mixes and playlists now share one row,
half and half on large screens with smaller cards, capped at six each
with a link to the full list. Albums are the ones the user listened to
most recently, which is a new query joining the listening history with
the album tracks. The endless track wall is now "Descubrir".
2026-07-21 19:06:36 +02:00
Admin 775b5a7a8a fix(web-player): give the track tables room after the duration 2026-07-21 18:55:56 +02:00
Admin 87d36722aa refactor(web-player): let TrackTable declare its own columns
Every one of the seven tables repeated the same header markup and hand
written grid templates, always in the same shape: an optional index, the
title, optional desktop-only meta columns, the duration and an optional
action. TrackTable now takes that shape as props and builds both grid
templates and the headers itself.
2026-07-21 18:50:17 +02:00
Admin 68aeebb746 refactor: name an artist mix after the artist alone
The "Mezcla:" prefix repeated what the header label already says, and it
pushed the long titles into an extra line on the detail page.
2026-07-21 18:44:56 +02:00
Admin 3f7409b41f feat(web-player): label the mix header with its kind 2026-07-21 18:42:10 +02:00
Admin c06066d09e refactor: stop telling mixes apart by source
A mix should read as one list, so the detail page drops the per-source
counters and the YouTube tag on each row, and the generated subtitles no
longer name Musify or YouTube Music.
2026-07-21 18:39:40 +02:00
Admin 2924e91060 Change to disc-2 2026-07-21 18:33:21 +02:00
Admin ae6a78fdbe refactor(web-player): drop the quick mixes shelf
The server-generated mixes cover the same ground and pick better songs,
so the two shuffle-what-is-on-screen cards are redundant.
2026-07-21 18:25:05 +02:00
Admin cbeb52fc05 refactor: drop dead code left after the worktree move
ChipGroup is orphaned since the explore rewrite dropped the filter chips.
Mix.GeneratedOn was written on every generation and never read, and it
duplicated CreatedAt, which IAuditable already sets at the same moment.
2026-07-21 18:22:50 +02:00
Admin 584109bf05 feat: bring over the albums, explore and player work from the worktree
Moves the remaining uncommitted work: the home gets quick mixes and a
"Tus álbumes" shelf, saving a YouTube album now goes through
addYoutubeAlbumToPlaylistAction instead of the SaveYouTubeAlbum command,
and UserAlbum drops its unused ExternalId.

The mixes section keeps its current shape (no refresh action, no badge,
hidden when empty) rather than the older version the worktree carried.
2026-07-21 18:18:23 +02:00
Admin 282a419bd0 feat(web-player): label each explore result with its kind 2026-07-21 18:13:46 +02:00
Admin 76eac308a6 feat(web-player): show the cover mosaic on the explore albums 2026-07-21 18:13:46 +02:00
Admin 30cea988cf perf(web-player): take the cover track ids from the list response 2026-07-21 18:13:46 +02:00
Admin e87d379ddb feat(backend): expose the first cover track ids of playlists and albums 2026-07-21 18:13:18 +02:00
Admin 90e842d084 feat: recommend server-generated mixes from Musify and YouTube Music
Mixes are their own concept, not playlists: they live in their own
Mixes/MixItems tables, cannot be edited and never show up among the
user's playlists. Each item is either a Musify track or a YouTube Music
reference, so YouTube songs stream directly instead of being downloaded.

A daily Hangfire job seeds the mixes from the listening history: one
discovery mix, one daily mix and one per top artist.

The per-user YouTube filler query was already in the working tree and
rides along here because it shares files with this change.
2026-07-21 18:13:07 +02:00
Admin 2cb51f540e feat(backend): show the newest tracks first 2026-07-21 18:12:57 +02:00
Admin 81c5578962 fix(backend): tolerate a repeated track when saving an album
A track appearing twice in a YouTube album would hit the unique link index and
fail the whole save, so the second occurrence is skipped instead.
2026-07-20 20:50:38 +02:00
Admin c624a1332f feat(web-player): page the album results and save YouTube albums
The albums section keeps a Ver más button while YouTube Music has another
page, fed by a new /api/youtube/albums route that forwards the continuation
token. The YouTube album page gains a save button that drops the whole album,
tracks included, into your library and takes you to it.
2026-07-20 20:49:56 +02:00
Admin fe6b0b7268 feat(backend): save a YouTube album with all of its tracks
POST /albums/youtube/{albumId} creates the album in your library and links
every song in it, provisioning each one as an external track without queueing
any download. The external id is kept on the album so saving it twice returns
a conflict instead of a duplicate, and albums can now hold external tracks,
not just the ones you uploaded.
2026-07-20 20:47:50 +02:00
Admin 78f7fe06cf feat(web-player): show YouTube Music albums in the explore search
The Albums chip now returns both your own albums and what YouTube Music has
for the term. A YouTube card opens a read-only album page with its cover,
tracklist and play button, and right-clicking a track still offers to add it
to a playlist through the existing menu.
2026-07-20 20:39:21 +02:00
Admin 07e1daf2ce feat(backend): search albums on YouTube Music
Two read-only endpoints: the album search and the album detail with its
tracklist. Both go through the same single-flight cache as the song search,
and the detail one especially benefits since it costs two upstream calls, the
browse id lookup and the album itself, for metadata that never changes.
Paginator handling is now shared between the song and album searches.
2026-07-20 20:36:28 +02:00
Admin dd15f885cb feat(web-player): filter the explore results with a chip group
Under the search box there are now three checkbox chips choosing what the page
shows. Songs keep the existing grid and Albums searches the albums by title;
Users is rendered disabled until there is something behind it.
2026-07-20 20:30:33 +02:00
Admin 18f22b4c38 feat(web-player): add the albums pages
A grid of your albums with a create dialog, and a detail page with the
tracklist, inline editing and a picker that lists the songs you uploaded so
they can be dropped into the album. Covers reuse the playlist mosaic since an
album has no artwork of its own yet.
2026-07-20 20:21:22 +02:00
Admin 27f3c02915 fix(web-player): follow the API types where they are nullable
The checked-in schema was stale and typed artist, description and externalId
as always present, so the code trusted values the API returns as null. The
regenerated schema tells the truth; the API-shaped types now admit null and it
is normalised on the way into the player, where the state stays non-null.
2026-07-20 20:20:34 +02:00
Admin 22ed923a04 feat(api): expose the album endpoints
Eight routes under /albums covering the listing, the detail, the tracklist and
the owner-only writes, wired to the same validation filter and ErrorOr mapping
the playlists use.
2026-07-20 20:13:40 +02:00
Admin 69468c9dbd feat(backend): add the album use cases
Create, update, delete and list albums, plus adding and removing their tracks,
mirroring the playlist handlers. Only tracks the user uploaded can go into an
album, and removing one renumbers the rest so the tracklist stays 1..n.
2026-07-20 20:10:45 +02:00
Admin 67805062ca feat(backend): model albums in the domain
Album is abstract with UserAlbum as its first table-per-type child, the same
split Track already uses, so the YouTube albums can join later without moving
anything. Tracks are linked through AlbumHasTrack rather than a column on the
track itself: an external track is shared between users and the same song
shows up in more than one album.
2026-07-20 20:08:21 +02:00
Admin 6490ae9822 fix(backend): give track titles room to breathe
Titles were capped at 50 characters and the YouTube provisioner silently cut
them there, which mangles anything with a feature or a remix suffix. Albums
are about to make that far more visible, so the column now holds 200.
2026-07-20 20:06:47 +02:00
Admin ff6103cd58 fix: stop the YouTube covers coming out blurry and cropped
The ytimg variants are 16:9, so picking one by width handed a square tile far
fewer pixels than it needed: a 96px cover got the 120x90 file and kept 90px
after the crop. They are chosen by their short side now, the album art is
preferred over the video frame when the search returns both, and the display
sizes account for the tiles being up to 180px on a 2x screen.
2026-07-20 19:05:44 +02:00
Admin 3cbf64a191 fix(backend): keep the saved cover art at full resolution
Search results now carry a small thumbnail, so the artwork downloaded when a
YouTube track is saved would have shrunk with them. The URL is upsized to the
configured artwork resolution right before the download event goes out.
2026-07-20 17:42:11 +02:00
Admin be662048b1 perf(deploy): cache the YouTube artwork at the nginx edge
Thumbnail responses are immutable for a month, so nginx can answer repeats
from disk and never wake the web player or Google for them. The cache lives in
its own volume, collapses concurrent misses with a lock and serves stale
copies while it refreshes; THUMBNAIL_CACHE_SIZE sizes it.
2026-07-20 17:40:28 +02:00
Admin 8af7840f5c perf(web-player): cut down the searches the explore page fires
Typing in the search box navigated on every pause, so single letters and
round-trips back to the same term each triggered a full YouTube search and a
new grid of covers. The debounce is longer, a term identical to the current
one no longer navigates, YouTube is only queried past a minimum length, and
the infinite scroll waits until the sentinel is closer before pulling a page.
2026-07-20 17:38:37 +02:00
Admin 49b2fcb623 style(web-player): reformat the upload dropzone copy 2026-07-20 17:36:46 +02:00
Admin 2946452a43 perf(web-player): serve YouTube artwork through a caching proxy
The browser used to pull every cover straight from Google, so a search grid
plus infinite scroll fired dozens of cross-origin image requests and started
coming back as 429. Covers now go through /api/youtube/thumbnail, which
resizes the upstream URL, caches the bytes in memory, collapses duplicate
in-flight fetches, caps the concurrency against Google and backs off when it
answers 429. Responses are immutable for a month and revalidate with an ETag,
and serving them same-origin also lets the accent extractor read the pixels.
2026-07-20 17:36:41 +02:00
Admin c302cfd314 perf(backend): cache YouTube searches and shrink the thumbnail URLs
Repeated searches and song lookups now reuse a single-flight memory cache, so
concurrent or debounced queries hit YouTube once instead of on every keystroke.
Thumbnails are resolved at a configurable size instead of the largest variant,
which cuts the image payload the browser pulls from Google.
2026-07-20 17:29:28 +02:00
Admin 71c45abcf1 Changes 2026-07-20 16:36:18 +02:00
Admin ee49f67c8d Responsive design 2026-07-20 16:25:44 +02:00
Admin 6778a69098 docs: rewrite the deploy documentation and fix the stale references
The root README was a design handoff for a prototype that is not in this repo,
web-player/README.md was the sv template, and deploy/README.md only covered
development. Replace all three and document the production deploy: its
prerequisites, what up.sh does and why nginx sits in front.

The docs under docs/ still used the pre-monorepo project paths, Ardalis.Result
instead of ErrorOr and the player.html test console that no longer exists.
2026-07-20 15:14:11 +02:00
Admin fa12fb795d chore(web-player): tighten the container image
There was no .dockerignore, so node_modules and build/ were copied into the
build context on every build. Add one, install from the lockfile with npm ci,
prune the dev dependencies out of the runtime layer and run as the node user.
2026-07-20 15:14:04 +02:00
Admin d683e22cb6 feat(deploy): provision the web player client in development too
The web player's OIDC app had to be created by hand in the Zitadel console and
its client id copied into web-player/.env. provision.ps1 now creates both apps,
regenerates the client secret when it is no longer known, and wires the values
into deploy/.env, Musify.Api user-secrets and web-player/.env.

up.ps1 follows the new compose files, gains -Apps and -Tools for the two
profiles, generates the keys before starting rather than after, and no longer
swallows a provisioning failure as a warning.
2026-07-20 15:14:00 +02:00
Admin 5a7755efa9 feat(deploy): add a one-command production deploy in shell
Production had no orchestrator at all and its provisioning script required
PowerShell 7 on the server. up.sh now does the whole thing in POSIX shell:
it refuses to start until .env.prod and the origin certificate are in place,
generates the stream keys, brings up the infrastructure and the edge,
provisions Zitadel, applies the migrations and finally builds and starts the
applications. It is idempotent, so the same command deploys a new version.

provision.sh replaces provision.prod.ps1 (curl + jq) and also creates the web
player client, and migrate.prod.sh moves to scripts/migrate.sh reading the env
file with a parser instead of sourcing it as a shell script.
2026-07-20 15:13:54 +02:00
Admin e7471ff7e5 feat(deploy): template the nginx vhosts with the deployment domain
ikerdemo.cat was hardcoded in six server blocks, so the stack could only be
deployed under that one domain. The vhosts are now a template rendered by the
nginx entrypoint from MUSIFY_ROOT_DOMAIN, with the envsubst filter set so it
does not eat nginx's own variables. The repeated proxy headers move to a
shared snippet, the web player vhost gets the upload body limit it was missing
(audio is posted to its form actions), and the streaming vhost turns off proxy
buffering so range requests stay responsive.
2026-07-20 15:13:45 +02:00
Admin a779a8bf73 refactor(deploy): merge the three compose files into a base plus overlays
The dev and prod stacks each declared the same nine services, which is how the
S3 credentials and the gateway configuration ended up diverging. compose.yml
now holds every service with no published port and no domain in it, and the
overlays only add what is specific: host ports and pgAdmin for development,
the nginx edge and the mounted secrets for production. The applications sit
behind the `apps` profile, so a plain `up` still starts infrastructure only.

Everything environment-specific moves to the env files: the public URLs, the
credentials and the environment name. The SeaweedFS identity file is rendered
inside the container from S3_ACCESS_KEY and S3_SECRET_KEY, so the store and
the applications can no longer be given different keys.
2026-07-20 15:13:39 +02:00
Admin 6156101029 refactor(backend): clean up the AppSettings files
Drop the dead Keycloak and ConnectionStrings sections from the Worker and move
its hardcoded dev values to AppSettings.Development.json, so the base file is
the empty template the convention describes. Remove the stale Authentication
block from the API dev settings (provisioning writes those to user-secrets) and
the ManifestFileName left over from DASH. Point the stream-ticket key and the
Worker scratch directory at deploy/keys and deploy/.tmp instead of D:\weed and
D:\tempsFilesDev, and make the API listen on localhost instead of a fixed LAN
address.
2026-07-20 15:13:27 +02:00
Admin 29d67e729c fix(web-player): send presigned uploads to a reachable S3 endpoint
The PUT was always sent to 127.0.0.1, which is nothing inside the container, so
uploads failed whenever the web player did not run on the same host as
SeaweedFS. Take an optional S3_UPLOAD_ENDPOINT instead (the compose stacks
point it at seaweedfs:8333) and fall back to the host in the presigned URL. The
signed Host header is preserved either way, so the signature still matches.
2026-07-20 15:13:21 +02:00
Admin 28e00ce0ca fix(gateway): load the proxy routes and ticket settings in every environment
The YARP routes, the bucket path transform and the StreamTicket section only
existed in appsettings.Development.json, so a Production run had no routes at
all and every /media request fell through to a 404. Move them to
appsettings.json and leave only the environment-specific overrides (key path)
in the Development file; the container gets the filer address, the bucket and
the allowed CORS origins from environment variables.
2026-07-20 15:13:15 +02:00
Admin 4d52877b35 Remove copilot 2026-07-20 14:38:38 +02:00
Admin f82d9b7c1b Replace 2026-07-20 14:37:21 +02:00
Admin 06b7a08bfa refactor(web-player): centralise styles and drop dead code
Styles: adds the scrim and on-art tokens so the black overlays and the
white marks drawn over artwork stop being raw tailwind colours, and
routes every component through the semantic layer instead of reaching
into the --mf-* palette. Renames --mf-accent-60, which was actually an
80% mix, to --mf-accent-soft, and removes the unused --mf-radius and
the --color-neutral-950 override.

Dead code: the root +layout.server.ts already redirects everything
except /login, so the anonymous branches in the home and explore loads
and the needsAuth empty state could never run. Deletes the unused
accentSoft helper, and stops exporting helpers nobody imports.

fmtTime and fmtDate move out of theme/color.ts into $lib/format.ts;
they are not colour concerns.

The accent animation now cancels its pending frame on teardown.
2026-07-20 14:35:45 +02:00
Admin 4b06226dd7 refactor(deploy): rename the frontend service to web-player
Matches the folder rename: compose service frontend -> web-player,
container musify-frontend -> musify-web-player, and the nginx upstream
now points at http://web-player:3000.

Deploying this needs --remove-orphans, or the old musify-frontend
container is left behind.
2026-07-20 07:37:31 +02:00
Admin 546aef6d83 refactor: rename frontend/ to web-player/ and format with prettier
Updates the compose build context, the Dockerfile header, the package
name and the docs. The compose service and container keep the name
frontend/musify-frontend, since nginx routes to http://frontend:3000.

The source files also carry the first prettier pass, so the rename and
the reformat land together for them.
2026-07-20 07:35:01 +02:00
Admin 02e76664ef chore(web-player): add prettier and eslint
Flat ESLint config with typescript-eslint and eslint-plugin-svelte,
Prettier with the svelte and tailwindcss plugins. Adds the format and
lint scripts.

Three rules are off on purpose: no-navigation-without-resolve (the app
is served at the root, there is no base path), prefer-svelte-reactivity
(the Set and URLSearchParams uses are local computations, not reactive
state) and no-unused-expressions in .svelte only, where reading a rune
on its own line is how an $effect registers a dependency.
2026-07-20 07:34:55 +02:00
Admin c888c465d5 fix(web): clear svelte-check warnings and log playback failures
The seeded $state initialisers in PlaylistForm intentionally capture the
prop once; untrack() states that instead of tripping
state_referenced_locally. The player now logs the caught exception
rather than discarding it.
2026-07-20 07:28:17 +02:00
Admin 6f7ae9e5bb fix(web): resolve the greeting on the server
It was picked with Math.random() in the component body, so SSR and the
client rendered different text and hydration mismatched. Moving it to
the load keeps the per-visit variety with both sides agreeing, and the
six-level nested ternary becomes a lookup table.
2026-07-20 07:28:12 +02:00
Admin db71c4a331 refactor(web): share track target helpers between home and explore
Both pages reimplemented the same accessors over the target union and a
byte-identical queue-item mapper. Moves the domain type (renamed
ContextMenuTarget -> TrackTarget, since tiles and the queue use it too)
and its helpers to $lib/tracks.ts; TrackContextMenu keeps only its menu
geometry. Home drops from 341 to ~230 lines.

Unifies two divergences the duplication was hiding: home compared the
active tile against track.id while the queue is keyed by
queueIdForTrack, so YouTube-sourced tracks never highlighted; and it
hardcoded explicit to false for local tracks, hiding the badge.

Also cancels in-flight youtube filler results when the effect re-runs,
so a slow response cannot overwrite newer ones.
2026-07-20 07:28:12 +02:00
Admin c8bc26a5e0 fix(web): guard authenticated routes instead of asserting the session user
library, playlists and playlists/[id] read locals.user!.sub with no
check, so an anonymous request threw a TypeError and returned 500
instead of redirecting to login. Adds requireUser(), which redirects to
/login?returnTo=... and returns a non-null SessionUser, and drops the
five non-null assertions.

/upload had no load at all: the page rendered for anonymous users and
only failed on submit. It now redirects up front.
2026-07-20 07:28:04 +02:00
Admin 74b8a1d351 chore(web): remove unused adapter-auto dependency
The project builds with adapter-node; adapter-auto was left over from
the SvelteKit template.
2026-07-20 07:27:57 +02:00
Admin d1915481e0 refactor(youtube): log service failures and decouple error messages
YouTubeMusicService swallowed the exception into an ErrorOr description
with no trace on the server side. Adds ILogger to the three operations.
The download activity no longer names yt-dlp in its messages, since the
downloader is an implementation detail behind IYouTubeDownloaderService.
2026-07-20 07:27:57 +02:00
Admin e2ef2da4c3 feat(worker): install yt-dlp and deno in the worker image
yt-dlp goes through pip in a venv rather than the standalone PyInstaller
binary, which unpacks on every invocation and roughly doubles latency.
deno solves the nsig challenges.
2026-07-20 07:27:51 +02:00
Admin c3b32ad40c feat(deploy): add production stack for the VPS
Compose file for ikerdemo.cat with nginx as TLS terminator, the three
.NET services, Zitadel and the storage stack. Values come from
deploy/.env.prod (untracked). Adds the frontend image, the migration
script and the Zitadel provisioning for production.
2026-07-20 07:27:51 +02:00
Admin e09866716d chore: ignore production env files and mounted secrets
The existing *.env pattern did not match deploy/.env.prod, which holds
database, Zitadel and OIDC credentials. Also ignore deploy/secrets/,
mounted into the api and worker containers.
2026-07-20 07:27:38 +02:00
Admin 99c4bb8408 Changes 2026-07-18 23:29:57 +02:00
Admin 72f42369a0 A lot of changes 2026-07-17 18:56:23 +02:00
Admin 673689278d A lot of changes 2026-07-17 18:56:19 +02:00
Admin db63aabd9c fix(backend): propagate user renames to artist and harden migration backfill
Code review of the Artist/ownership refactor surfaced three gaps:

- SyncUser updated User.Name on every login but never the linked
  Artist row, so renames never reached track credits - defeating the
  point of centralizing names in Artist.
- YouTubeTrackProvisioner looked up artists by ExternalId only when a
  browseId was known, so it could never reuse the ExternalId-less
  rows the migration backfilled from legacy Artist strings, creating
  duplicate Artist rows for the same person. It now adopts the
  backfilled row (assigning it the browseId) when one matches by
  normalized name.
- AddArtistsAndTrackOwnership deduped legacy YouTube artist strings
  case-sensitively before hashing them into a case-insensitive unique
  index, and never guarded against local tracks with no UserHasTrack
  row - either would abort the migration on real data. Backfill now
  dedupes via DISTINCT ON over the normalized name and drops orphaned
  local tracks before the ownership check constraint is added.
2026-07-17 18:00:00 +02:00
Admin aae45a5186 refactor(backend): group track pipeline state into owned types
Track mixed catalog fields (Title, Artist credit, Duration) with
picture-processing and audio-transcode pipeline state as ten flat
columns plus two computed [NotMapped] flags, making the entity read
like a media-pipeline worksheet.

Introduce TrackPictures and TrackAudio as EF owned types mapped onto
the same existing columns (explicit column names, no schema change -
verified via `dotnet ef migrations has-pending-model-changes`).
Track now reads as Title/Artist/Duration/Pictures/Audio instead of
fourteen loose properties.
2026-07-17 17:58:56 +02:00
Admin 6e2f3a257a refactor(backend): read artist credit from TrackArtists join
Drop the now-redundant Track.Artist string column now that every
track (local and YouTube) links its credited artists through
TrackArtist. TrackApplicationResponse formats the display string by
joining TrackArtists ordered by position, and every read query that
projects a track now includes TrackArtists/Artist so the join isn't
empty at response time.
2026-07-17 17:57:22 +02:00
Admin ca275fe7a8 refactor(backend): unify YouTube track creation via a provisioner
Track get-or-create for YouTube videos was duplicated between the
old GetYouTubeTrackStream query handler (which also wrote to the
database) and AddYouTubeTrackToPlayList, with frontend-supplied
metadata (title/artist/duration/thumbnail) trusted at add-to-playlist
time instead of resolving it server-side.

Extract YouTubeTrackProvisioner as the single get-or-create path,
resolving structured artist refs (id + name) from YouTube Music so
multi-artist songs map to distinct Artist rows. Rename
GetYouTubeTrackStream to ResolveYouTubeTrackStream (it writes, so it
is a command, not a query) and delegate to the provisioner.
AddYouTubeTrackToPlayList slims down to (UserId, PlayListId, VideoId)
since all metadata now comes from the provisioner/YouTube API.
2026-07-17 17:56:37 +02:00
Admin cdf89dc632 feat(backend): add Artist entity and track ownership
Track.Artist was a plain string snapshot with no identity, and
UserHasTrack doubled as both "in my library" and "I own this",
conflating collection membership with administrative ownership.

Introduce Artist/TrackArtist to give artist credits their own
identity (needed so YouTube tracks with multiple credited artists,
and future renames, can be modeled), and Track.OwnerUserId to
represent ownership explicitly (local tracks only, enforced by a
check constraint). CreateTrack now provisions the uploader's Artist
row and links it via TrackArtist; DeleteTrack authorizes against
OwnerUserId instead of any UserHasTrack row.
2026-07-17 17:55:32 +02:00
Admin fa45843079 fix(backend): make track media fields and external id nullable
Artist, ExternalId, OriginalPictureName and OriginalAudioName were
non-nullable with '' sentinels, which forced the unique (Source,
ExternalId) index to rely on a filtered "<> ''" clause. Make them
truly nullable and drop the empty-string sentinels, backfilling
existing rows to NULL. Also fixes a formatting regression
(`track != null&&`) introduced alongside the nullability change.
2026-07-17 17:53:57 +02:00
Admin eeec891870 fix(frontend): remove context menu borders and add icon to the playlist row
Dropped the border from both menu panels (shadow alone separates them from the page), and gave 'Añadir a una playlist' a leading ListMusic icon so it matches the icon+text layout of 'Añadir a la cola'.
2026-07-16 22:48:48 +02:00
Admin 05ed7aec2e fix(frontend): stop clipping the add-to-playlist submenu
The redesign added overflow-hidden to the outer menu panel to round its corners, but the playlist submenu is absolutely positioned outside that panel's box (left-full/right-full), so it got clipped invisible. Restored the previous approach: rows get their own rounded-lg inside a padded (not overflow-hidden) panel.
2026-07-16 22:47:14 +02:00
Admin 7be5d22cb7 fix(frontend): restyle the track context menu to match the app's design tokens
Replaced the ad-hoc neutral-900/white-10 Tailwind palette with the same --mf-elevated/--mf-border/--mf-text-2 tokens and rounded-xl/shadow recipe the sidebar's own dropdown already uses, so the menu no longer looks foreign to the rest of the app.
2026-07-16 22:44:51 +02:00
Admin 7532511e1f fix(frontend): make add-to-queue insert the track to play next
It previously appended to the end of the queue; 'Añadir a la cola' is expected to mean the track plays right after the current one, so it now splices in right after the currently playing track's index.
2026-07-16 22:44:51 +02:00
Admin 63f7253714 feat(frontend): add queue option and enlarge first-level context menu text
The right-click menu now has a plain 'Añadir a la cola' item (appends the track to player.tracks via the new player.addToQueue) alongside the existing playlist submenu, and its first-level text is text-base to match the rest of the menu.
2026-07-16 19:19:08 +02:00
Admin 631941f690 fix(frontend): enlarge playlist submenu text, thumbnail and padding 2026-07-16 17:40:34 +02:00
Admin b4894f8169 fix(frontend): close the diagonal hover gap in the playlist submenu
The submenu was positioned with a margin gap after the trigger row, so moving the mouse toward it crossed a dead zone with no hovered element and the CSS :hover state dropped, hiding the submenu before the cursor reached it. The gap is now padding on the submenu's own wrapper, which keeps it part of the hoverable box and bridges the trigger to the submenu seamlessly.
2026-07-16 17:38:43 +02:00
Admin da96c8a112 fix(frontend): extend add-to-playlist context menu to local Musify tracks
Right-clicking a local track in the Musify section now opens the same nested add-to-playlist menu as YouTube results. Also switched the playlist thumbnail corners to rounded-lg.
2026-07-16 17:36:36 +02:00
Admin 62d30c2729 feat(frontend): add server action to attach local tracks to a playlist from explore 2026-07-16 17:36:36 +02:00
Admin 19f497c2d8 fix(frontend): nested add-to-playlist submenu with playlist thumbnails
The right-click menu now shows a single 'Añadir a una playlist' item; hovering it reveals a submenu listing every playlist with a small cover thumbnail next to its name, instead of dumping the whole playlist list into the first-level menu.
2026-07-16 17:33:38 +02:00
Admin 960565a968 fix(frontend): explore search polish
- Rename the local results heading from 'Mi música' to 'Musify'.
- Replace the per-card add-to-playlist icon on YouTube results with a right-click context menu listing all playlists.
- Stop the search box from clearing what the user is typing when a debounced navigation resolves after they've kept typing, by decoupling the input's value from the server-driven query prop.
2026-07-16 17:29:46 +02:00
Admin 82152beb93 fix(frontend): wire hardware media-key next/previous track buttons
The player never registered Media Session action handlers, so the OS/keyboard next-track and previous-track keys had nothing to call — only play/pause worked via the browser's built-in audio-element heuristic. Now previoustrack/nexttrack are wired to the player, and metadata/playback state are kept in sync for the lock-screen/OS media widget.
2026-07-16 17:20:49 +02:00
Admin 03b1166307 chore(frontend): show 27 local tracks per page in explore 2026-07-16 17:20:48 +02:00
Admin 53a3e80c05 fix(frontend): stop duplicating YouTube tracks in recently played after download
The queue/history identity for a song originating on YouTube now always uses its videoId, instead of switching to the local track id once it finishes downloading. Previously that switch made the same song produce two separate entries when merging session and server listening history.
2026-07-16 17:00:35 +02:00
Admin 8684067235 feat(frontend): hide empty local/YouTube sections when searching in explore 2026-07-16 17:00:27 +02:00
Admin 76ff77da7f feat(frontend): search local and YouTube Music together without filter tabs 2026-07-16 06:23:21 +02:00
Admin 6b69998865 feat: show explicit badge on YouTube Music results 2026-07-16 06:15:09 +02:00
Admin 8d19272f35 feat(frontend): unified explore search across local and YouTube Music
Explore now has Todo/Mi musica/YouTube Music filters (searching both by default), YouTube results share the local grid card style, and cards show the artist instead of the upload date.
2026-07-16 06:02:57 +02:00
Admin 74dd1e8374 feat(frontend): show YouTube tracks in recently played with their thumbnails 2026-07-16 01:15:50 +02:00
Admin 6d73fe88ab feat(backend): record listening history for YouTube playback
Playing a YouTube song now creates a lightweight track row (without queueing a download) and writes listening history; saving to a playlist queues the download via the new DownloadRequested flag.
2026-07-16 01:15:36 +02:00
Admin a6f976a065 fix(frontend): show real cover art in the player for YouTube tracks 2026-07-16 01:06:20 +02:00
Admin 560a943bab feat(frontend): YouTube Music search, playback and playlist saving 2026-07-16 01:06:20 +02:00
Admin 24595a17d6 feat(backend): auto-download YouTube tracks saved to playlists 2026-07-16 01:06:04 +02:00
Admin 2bd5ef729e feat(backend): YouTube Music search and playback resolution endpoints 2026-07-16 01:05:42 +02:00
Admin 87997d90a4 feat(backend): add source, external id and artist to tracks 2026-07-16 01:05:28 +02:00
Admin 49b7d54e58 style(frontend): full-width track rows with duration and listens on library page
Rows now span the full page width and show track duration plus
listens count, matching the columns already used elsewhere in the
app. Also trims the trailing bottom padding to match other pages.
2026-07-15 16:18:57 +02:00
Admin a59ce99051 style(backend): formatting pass on GetListeningHistory 2026-07-15 16:15:37 +02:00
Admin e5614178c8 fix(backend): stop loading a user's entire listening history into memory
GetListeningHistory used AsEnumerable() before DistinctBy/Take, which
forced EF Core to materialize every history row for the user just to
keep the last 15 distinct tracks. Now groups by track and takes the
15 most recently played track ids in SQL, then fetches only those.
2026-07-15 16:12:26 +02:00
Admin 5614033294 chore(deploy): add pgAdmin to the dev stack
Runs alongside Postgres in the unified compose file at
http://localhost:5050, config via PGADMIN_* env vars.
2026-07-15 16:10:18 +02:00
Admin 17ce437571 style(frontend): even out page-bottom spacing and header sizing
Home and explore had an oversized trailing bottom padding compared to
the rest of the app; brought both down to match. Also matches "Mis
listas" grid/limit on home to "Novedades", and sizes the library page
header the same as the playlists page header.
2026-07-15 16:10:11 +02:00
Admin 3bbbd52d43 feat(frontend): redesign playlist pages with cover image support
Create and edit playlist modals now upload/preview a cover image
(reusing the presigned-upload flow already used for tracks) and share
identical layout and coloring. Adds a cover proxy route and wires
PlaylistArt to render a playlist's real cover before falling back to
the track mosaic/gradient.

Also reworks the playlist detail page: full-width borderless "Añadir
de tu biblioteca" table with listens count and click-to-play, drops
the empty-playlist placeholder message, and restyles the playlists
grid to match the rest of the app.
2026-07-15 16:10:04 +02:00
Admin a1f7756fc9 fix(backend): compute track listens count via SQL instead of an unloaded navigation
TrackApplicationResponse.FromEntity counted track.ListeningHistories,
but none of the queries that built a response ever Included it, so
the collection was always empty and listensCount was always 0. Each
query now projects the count as a correlated subquery before mapping
to the response.
2026-07-15 16:09:54 +02:00
Admin 461bae1b01 feat(backend): add playlist cover endpoint and relax description validation
Adds GET /playlists/{id}/cover (mirrors the track cover endpoint) so
uploaded playlist pictures can actually be served. Also drops the
NotEmpty rule on playlist description, which rejected the blank
description the frontend treats as optional; CreatePlayList now falls
back to the domain's default text instead.
2026-07-15 16:09:40 +02:00
Admin b0e4ec650a feat(frontend): hide playlists if the user doesn't have any 2026-07-14 19:24:41 +02:00
Admin 84ffb1eef6 Changes 2026-07-14 19:11:58 +02:00
Admin 126f767dca fix(frontend): change max recent tracks 2026-07-14 18:51:42 +02:00
Admin 9e0c164e17 feat(frontend): persistent player volume 2026-07-14 18:48:57 +02:00
Admin e31419b8c8 style(frontend): drop card background on explore results, match home track style 2026-07-14 14:30:19 +02:00
Admin fe874b3608 style(frontend): make explore search bar span full width 2026-07-14 14:28:54 +02:00
Admin 9067e59f6f feat(frontend): search tracks live as you type on explore page 2026-07-14 14:27:38 +02:00
Admin e68bf36481 style(frontend): restore original card sizes and stretch all home grids full width 2026-07-14 14:24:28 +02:00
Admin e7fe2dd888 style(frontend): let recently-played cards fill the full page width 2026-07-14 14:22:55 +02:00
Admin 04ae571eee style(frontend): enlarge recently-played section on home page 2026-07-14 14:20:51 +02:00
Admin befd392971 fix(frontend): give duration and remove-track their own grid columns 2026-07-14 14:11:36 +02:00
Admin 32f3cbe5cf style(frontend): move remove-track button to the far right of the row 2026-07-14 14:08:45 +02:00
Admin 19a4299b71 fix(frontend): contain NowPlaying indicator inside playlist play button 2026-07-14 14:06:48 +02:00
Admin 87cdba6343 style(frontend): polish playlist page header, back link and action buttons 2026-07-14 14:04:31 +02:00
Admin 7d2681aaf6 feat(frontend): show track duration in a Spotify-style table on playlist page 2026-07-14 14:00:23 +02:00
Admin 2e866075a2 feat(backend): expose track duration and listen count in track response 2026-07-14 13:54:12 +02:00
Admin afac7d5dd5 refactor(backend): rename AudioTranscoderConfiguration/Service to AudioConfiguration/AudioService 2026-07-14 13:54:08 +02:00
Admin f6ac22af32 refactor(backend): return AudioTranscodeResult with status code and duration 2026-07-14 13:49:28 +02:00
Admin 8a637d71f0 feat(backend): add migration for Track.Duration column 2026-07-14 13:48:05 +02:00
Admin 209c02ded7 feat(backend): extract audio duration during transcoding and persist to track 2026-07-14 13:47:52 +02:00
Admin c9e083b1c3 chore(frontend): allow dev server to fall back to a free port 2026-07-14 13:26:11 +02:00
Admin 46a6a9757a feat(frontend): show recently played from listening history on home 2026-07-14 13:26:08 +02:00
Admin 78caf588be fix(backend): order listening history by recency and cap to 15 tracks 2026-07-14 13:26:04 +02:00
Admin 937ad139e5 fix(frontend): apply display font to page titles missing it 2026-07-14 13:23:56 +02:00
Admin f10b0b800b refactor(frontend): drop downloads/import nav, promote library above upload 2026-07-14 13:23:51 +02:00
Admin 5bb6a4a1bf feat(frontend): drive seek bar with rAF for smooth playback progress 2026-07-14 13:23:44 +02:00
Admin e9e0b9ae9b docs: add project README and frontend pages notes 2026-07-14 04:55:44 +02:00
Admin a826c39003 refactor(frontend): tidy Navbar, Footer and document shell
Align Navbar and Footer with the new design tokens and enable stable scrollbar gutter on the document.
2026-07-14 04:55:44 +02:00
Admin 4f9ae51971 chore(frontend): add font/icon deps and dev config
Add @fontsource-variable (manrope/sora/inter), @lucide/svelte and @types/node, mark @lucide/svelte as noExternal for SSR, and add the dev-server launch config.
2026-07-14 04:55:39 +02:00
Admin 2eaca66ec2 feat(frontend): rework home page with recent, latest and playlists
Add SSR data loading for the home page and rebuild its layout (greeting hero, recently played, new releases, user playlists).
2026-07-14 04:55:12 +02:00
Admin f155ccc912 feat(frontend): add (app) route group and rework auth routing
Move authenticated pages under an (app) group with a Sidebar shell, add /auth/login and page-based /login, drop the old explore page and login +server endpoint, and add the server upload helper.
2026-07-14 04:55:04 +02:00
Admin 6cc41d1d6f feat(frontend): rework audio player and shared UI components
Expand the player store, split player controls (transport, seek, volume, track info), add Cover/PlaylistArt/NowPlaying UI primitives and a cover image proxy route.
2026-07-14 04:54:51 +02:00
Admin 8c87d84f80 feat(frontend): add theme tokens and dynamic accent palette
Introduce theme.css design tokens, color/palette helpers, and global layout styles (scrollbars, animations, home gradient).
2026-07-14 04:54:45 +02:00
Admin 0b72363c33 feat(tracks): add track cover endpoint
Serve cover images via GET /tracks/{id}/cover with a size parameter, streamed from storage with long-lived immutable cache headers.
2026-07-14 04:54:40 +02:00
Admin a40f3da247 feat(tracks): add user listening history
Record a listening-history entry when a stream ticket is issued and expose GET /users/{id}/listening-history to read it back.
2026-07-14 04:54:35 +02:00
Admin 18d3aea853 feat(frontend): play tracks from the explore page 2026-07-06 02:40:44 +02:00
Admin 9b34f2db84 feat(frontend): music player component with separated controls and stream proxy 2026-07-06 02:40:44 +02:00
Admin 812913156b refactor(application): drop redundant default values on query records 2026-07-06 02:23:35 +02:00
Admin 927f965023 fix(playlists): reject empty/whitespace name and description on update 2026-07-06 02:20:47 +02:00
Admin 7623b4e80e refactor(search): treat empty search strings as no filter (string.IsNullOrEmpty) 2026-07-06 02:12:58 +02:00
Admin fef788462d feat(frontend): add /explore page with track listing and name search 2026-07-06 02:05:47 +02:00
Admin b9cd3533dc chore(frontend): regenerate api types with track name search 2026-07-06 02:04:04 +02:00
Admin ac1c515dec feat(tracks): support search by name in GetTracks 2026-07-06 02:04:01 +02:00
Admin 5d2c9d3986 chore(frontend): generate backend API types from live OpenAPI spec 2026-07-06 01:54:28 +02:00
Admin 93fe2fb942 feat(frontend): add typed backend API client (openapi-fetch) with codegen script 2026-07-06 01:52:25 +02:00
Admin f58f6a0aa5 refactor(frontend): require refresh/id token in Session, validate at callback boundary 2026-07-05 23:57:19 +02:00
Admin ac5fc49800 refactor(frontend): restrict insecure HTTP issuer to dev / explicit opt-in 2026-07-05 23:56:35 +02:00
Admin 6a49b17e38 refactor(frontend): make SessionUser fields non-null and normalize claims at boundary 2026-07-05 23:45:18 +02:00
Admin ea36816777 refactor(frontend): centralize configurable auth/session values in config.ts 2026-07-05 23:43:50 +02:00
Admin 9613cd928d feat(frontend): encrypt session cookie (JWE) and auto-refresh access token 2026-07-05 23:41:49 +02:00
Admin 33ead1daab chore(frontend): remove scaffold comments and add conventions doc 2026-07-05 23:41:42 +02:00
AdminandClaude Opus 4.8 66cb970dd2 fix(frontend): full-page nav for auth links; land on home after login
- Add data-sveltekit-reload to the /login links so the browser performs a full
  navigation (the client router intercepted them and aborted on the external
  Zitadel redirect)
- Default post-login returnTo to / (home) since /explore is not built yet
- 'Empezar gratis' now starts the register flow (/login?mode=register)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:16:25 +02:00
AdminandClaude Opus 4.8 f8d4f83152 fix(deploy): use Zitadel built-in login v1 (disable required Login UI v2)
The 'latest' Zitadel image requires the Login UI v2, which runs as a separate
'login' container this stack doesn't deploy, so OIDC redirected to /ui/v2/login
and returned {code:5, Not Found}. provision.ps1 now disables the loginV2
requirement via the feature API so OIDC uses the built-in /ui/login. Also
applied to the running instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:08:27 +02:00
AdminandClaude Opus 4.8 454bffe4ba feat(frontend): make Zitadel client secret optional (support PKCE apps)
Configure openid-client with None auth (public client + PKCE) when no secret is
provided, so both PKCE and confidential (Code) Zitadel web apps work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:01:44 +02:00
AdminandClaude Opus 4.8 0163da096e feat(frontend): server-side Zitadel OIDC auth (part 2)
Add Authorization Code + PKCE login/logout against Zitadel, handled entirely
server-side with an httpOnly signed session cookie:
- $lib/server/auth: openid-client discovery (config cached), jose-signed
  session encode/decode, cookie/name constants
- routes: /login (builds the authorize URL with PKCE/state/nonce, ?mode=register
  -> prompt=create), /auth/callback (code exchange + session cookie),
  /logout (clears session + Zitadel end_session)
- hooks.server populates locals.user/accessToken from the session
- +layout.server exposes the user; Navbar shows the signed-in user + Salir,
  or Iniciar sesión / Registrarse
- switch to adapter-node; env-driven config (.env gitignored, .env.example
  committed) with issuer http://host.docker.internal:8080

Verified: svelte-check + build clean; dev smoke test shows the landing in the
logged-out state and /login issuing a 302 to Zitadel's authorize endpoint with
PKCE/state/nonce (discovery reachable). Real client id/secret still needed to
complete the token exchange.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:58:47 +02:00
AdminandClaude Opus 4.8 6bb31ad7c5 feat(frontend): scaffold SvelteKit + Tailwind with landing (part 1)
New SvelteKit app in frontend/ (minimal template, TypeScript, TailwindCSS v4).
Adds the app shell (dark music theme navbar + footer) and a landing page with
hero and feature cards. CTAs point to /login and /explore (built in later parts).

Verified: production build succeeds and the page renders with Tailwind styles
applied (checked via preview).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:47:29 +02:00
AdminandClaude Opus 4.8 573539ea1b cleanup(worker): drop empty Worker BackgroundService; fix gateway ticket guard
- Remove the no-op Worker BackgroundService and its registration; the process
  is kept alive and does its work through the MassTransit bus and the upload
  intent jobs (their own hosted services)
- Fix the gateway middleware guard to reject on string.IsNullOrEmpty(prefix)
  instead of 'is null', which no longer matched TicketValidator returning
  string.Empty for invalid tickets (invalid tickets skipped the 401 and an
  empty object key could slip through)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:13:20 +02:00
Admin e15e587267 Formatting 2026-07-05 22:07:43 +02:00
Admin dece2c77e8 Empty string 2026-07-05 22:06:50 +02:00
AdminandClaude Opus 4.8 5aeca4deb1 refactor(gateway): simplify prefix extraction in TicketValidator
Read the prefix claim via ClaimsIdentity.FindFirst instead of a three-part
ternary over the claims dictionary, and drop the XML summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:59:55 +02:00
AdminandClaude Opus 4.8 45ce66a6ee harden(gateway): prefix-boundary + path-traversal checks; simplify ticket validation
Tighten the stream-ticket authorization in the gateway middleware:
- Enforce a path-segment boundary on the prefix check instead of a bare
  StartsWith, so a ticket for 'users/12' no longer grants 'users/1234/...'
  (exact-match still allowed for single-object grants)
- Reject object keys containing '..'/'.' segments before proxying, closing a
  path-traversal bypass that SeaweedFS would otherwise resolve outside the
  authorized prefix

Also simplify TicketValidator.ValidateTicketAsync from ErrorOr<string> to
string? (null = invalid) since it has a single failure mode; drop the now
unused ErrorOr package reference from the gateway.

Verified: build clean; the authorization predicate passes a table of cases
covering both the escalation and traversal vulnerabilities.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:50:43 +02:00
AdminandClaude Opus 4.8 a40de35a57 refactor(api): OpenAPI response metadata, cached user sync, ProblemDetails
- Document every endpoint's responses (Produces<T>/status codes/validation)
  so the generated OpenAPI/Scalar docs carry response schemas and status codes
  instead of empty responses
- Cache recently-synced user ids (IMemoryCache, 15 min) in the JWT
  TokenValidated handler so user provisioning no longer hits the DB on every
  authenticated request
- Add ProblemDetails + UseExceptionHandler for consistent RFC7807 errors
- Add ToCreatedResult ErrorOr helper and use it in the create endpoints
- Normalize the playlists route to lowercase (/playlists)

Verified: booted the Api against RabbitMQ + Postgres in Docker and inspected
/openapi/v1.json — operations now expose response schemas/status codes and the
route is /playlists.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:04:15 +02:00
AdminandClaude Opus 4.8 1d40abcab7 feat(deploy): consolidated dev stack with auto-provisioned Zitadel
Single docker-compose stack for local development (Postgres, Zitadel,
RabbitMQ, SeaweedFS + bucket init, Jaeger) plus an optional overlay to run
the .NET apps in Docker. up.ps1 orchestrates the full bootstrap: stack up,
stream-ticket keys, EF Core migrations and Zitadel provisioning.

- Zitadel is provisioned automatically (service-account PAT -> project +
  OIDC app with JWT access token); the generated ClientId is wired into
  Musify.Api user-secrets and .env. No manual console steps.
- ZITADEL_EXTERNAL_DOMAIN=host.docker.internal so token validation works
  both with local apps and full-docker.
- App Dockerfiles copy AppSettings*.json to lowercase so config loads on
  case-sensitive Linux; Worker image ships ffmpeg.
- Scripts split by responsibility (common/stream-keys/migrate/provision);
  apps overlay de-duped via YAML anchors.
- Remove the superseded standalone seaweedfs compose; point docs/development
  at the unified stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:32:40 +02:00
Admin d6c269d80f Formatting 2026-07-05 16:04:53 +02:00
AdminandClaude Opus 4.8 431b56a072 refactor(infra): register auditable interceptor via DI
Register AuditableEntityInterceptor as a singleton and attach it through the
AddDbContext options callback instead of newing it up inside
Database.OnConfiguring. The interceptor is stateless, so a single shared
instance is reused across contexts and its wiring lives in the DI layer.
The design-time factory keeps working (interceptor is runtime-only, irrelevant
for migrations).

Verified: build + ef migrations list (design-time context) OK; the Worker host
builds and validates the full DI graph (reaches endpoint configuration) with no
captive-dependency or interceptor error.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:14:03 +02:00
AdminandClaude Opus 4.8 c2258569a0 refactor(infra): S3 client singleton, ffmpeg pipe ordering, tidy streams/jobs
- Register IAmazonS3 as a singleton instead of scoped: the AWS SDK client is
  thread-safe and manages its own connection pool, so one instance per DI scope
  (per request / per job iteration) was wasteful and risked socket exhaustion
- AudioTranscoderService: start reading stdout/stderr before writing stdin to
  remove the latent pipe-buffer deadlock shape
- Dispose the resized-picture MemoryStream in ResizePictureActivity
- Background jobs use PeriodicTimer instead of while + Task.Delay

Verified: Worker boots against RabbitMQ + Postgres in Docker with no captive-
dependency error (dev scope validation) and both jobs start.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:07:49 +02:00
AdminandClaude Opus 4.8 a5305b8ed8 feat(infra): playlist processing saga + generalize fault handling (phase 3)
Adds the PlayList picture-processing lifecycle saga (correlated by PlayListId)
mirroring the track one, and generalizes the failure plumbing:
- Rename the track fault translator to ProcessingSlipFaultConsumer and use a
  generic Workflow.SubjectId variable + ProcessKind; it now emits typed
  Track{Picture,Audio}/PlayListPicture ProcessingFailed events for all slips
- PlayListProcessingStateMachine (+ EF-persisted PlayListProcessingState,
  migration): starts on CreatePlayListResourcesEvent, finalizes on
  PlayListPictureProcessed, and on failure marks the playlist Failed and
  removes the orphaned final picture (PlayListProcessingFailedConsumer +
  MarkPlayListAsFailedActivity)
- UpdatePlayListPictureActivity publishes PlayListPictureProcessed on success
- Picture slip carries SubjectId + PlayListPicture kind + fault subscription

Verified end-to-end in Docker (RabbitMQ + migrated Postgres): both saga
endpoints, the fault translator and cleanup consumers configure, and an
addressing cross-check confirms all 21 activity execute queues the builders
address exist (caught + fixed a kebab mismatch on mark-play-list-as-failed).

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:40 +02:00
AdminandClaude Opus 4.8 8664bfb800 feat(infra): track saga failure handling + cleanup (phase 2)
On definitive failure of the picture or audio slip, the saga now reaches a
Failed state and recovers end-to-end:
- Picture/audio track slips carry TrackId + ProcessKind variables and a
  subscription to a fault translator (TrackProcessingSlipFaultConsumer) that
  turns RoutingSlipFaulted into a typed Track{Picture,Audio}ProcessingFailed
- The saga stores the final object keys from the start event and, on failure,
  transitions to Failed and publishes TrackProcessingFailed
- TrackProcessingFailedConsumer runs a cleanup slip that removes the orphaned
  final picture/audio copies and marks the track LifeCycleStatus=Failed
  (MarkTrackAsFailedActivity) — the track stays as a visible, recoverable record
- Migration for the new saga columns

Verified: Worker boots against RabbitMQ + Postgres in Docker; the new
endpoints (fault translator, failed consumer, mark-track-as-failed) configure
and the bus starts clean.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:49:33 +02:00
AdminandClaude Opus 4.8 986941166c feat(infra): add Track processing saga (state machine, phase 1)
Introduce a MassTransit saga state machine that correlates by TrackId and
tracks the two parallel processing slips (picture + audio):
- TrackProcessingStateMachine + EF-persisted TrackProcessingState
  (pessimistic concurrency, EntityFrameworkRepository), migration added
- Starts on CreateTrackResourcesEvent; UpdateTrack{Picture,Audio}Activity now
  publish TrackPictureProcessed / TrackAudioProcessed on success; when both
  arrive the saga finalizes (Ready). Failure transitions to a Failed state
  (cleanup wired in the next phase)
- Add LifeCycleStatus.Failed and the four outcome events

Verified: booted the Worker against RabbitMQ + migrated Postgres in Docker;
the saga endpoint 'track-processing-state' is configured and the bus starts
clean.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:41:19 +02:00
AdminandClaude Opus 4.8 3545d71016 refactor(infra): auto-configure MassTransit endpoints via ConfigureEndpoints
Replace ~240 lines of hand-written per-endpoint wiring with MassTransit's
ConfigureEndpoints + KebabCaseEndpointNameFormatter, and apply retry + EF
outbox globally through AddConfigureEndpointsCallback. Adding a consumer or
activity is now a single AddConsumer/AddActivity line.

- Normalize the 4 mismatched activity endpoint names to kebab-case so the
  routing-slip addressing keeps resolving (update-play-list-picture,
  delete/mark/publish play-list variants); cleanup consumer queue is now
  routing-slip-clean-up
- Drop the now-unused QueueName consts from event consumers and trim
  EndpointHelper / delete the manual endpoint-config partial

Verified end-to-end: booted the Worker against RabbitMQ in Docker and
confirmed all 38 endpoints are configured with the exact names the builders
address (19 execute queues + compensations + consumers + routing-slip-clean-up).

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:22:26 +02:00
AdminandClaude Opus 4.8 d08d82a483 fix(infra): repair TransferFiles compensation + tidy MassTransit consistency
Fix (recoverability bug):
- TransferFilesToBucketActivity returned Completed() with no log, so its
  compensation could never delete the transferred files (orphaned objects on
  fault). IStorageService.TransferFilesAsync now returns the real uploaded
  keys; the activity logs them so Compensate can remove them. Also drops the
  Path.Combine key recomputation (produced backslash keys on Windows).

Consistency / DRY:
- Normalize all courier activities to internal + folder-based namespaces
  (several were public and in the flat .Activities namespace)
- Extract the duplicated exponential retry into UseStandardRetry
- Unify endpoint-name logic into a single EndpointHelper (remove the parallel
  MassTransitEndpointNames helper)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:58:27 +02:00
AdminandClaude Opus 4.8 423e06baf9 refactor(application): fold Pagination into Shared, rename assembly marker
- Move PaginatedResponse from the single-file Pagination/ folder into Shared/
  (namespace Musify.Application.Pagination -> Musify.Application.Shared)
- Rename MusifyApplication.cs -> AssemblyInfo.cs (it only holds the
  [assembly: MediatorOptions] attribute)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:38:12 +02:00
AdminandClaude Opus 4.8 38f7094fb0 refactor(di): add AddApplication entry point and generic AddValidatedOptions
- Add Musify.Application.AddApplication(services, config) as the layer's DI
  entry point: registers Mediator, UploadIntentValidator and all app configs
- Collapse the 5 near-identical AddXConfiguration methods into a single
  generic AddValidatedOptions<T>(config, section)
- Move UploadIntentValidator registration out of Infrastructure into the
  Application layer (fixes layering leak); Infra config binding now uses the
  generic helper
- Api calls AddApplication; Worker uses AddValidatedOptions for its configs

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:36:46 +02:00
AdminandClaude Opus 4.8 e802c8565d refactor(application): co-locate each command/query with its handler
One file per use case under the feature folder (Tracks/PlayLists/Users),
dropping the Commands/Queries/Handlers sub-folders and their namespaces.
Each file now holds the record and its handler in namespace
Musify.Application.<Feature>; external usings updated accordingly.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:12:59 +02:00
AdminandClaude Opus 4.8 6e469dbb4a refactor: replace Ardalis.Result with ErrorOr across the app
Ardalis Result<T> carried incoherent success sub-statuses and a clunky
generic converter; ErrorOr models success-or-errors cleanly.

- Application: all commands/queries/handlers now return ErrorOr<T>
  (non-generic Result -> ErrorOr<Success>); errors via Error.NotFound/
  Conflict/Validation/Unauthorized/Failure; success via value or new Success()
- Drop ResultExtensions.As<>(): error propagation is now just return .Errors
- Rewrite HTTP mapping (ErrorOrHttpExtensions) off Error.Type; success maps
  to 200 (value) or 204 (Success); Created still built in the endpoints
- Endpoints/gateway middleware: !IsSuccess -> IsError
- Swap Ardalis.Result package for ErrorOr 2.1.1 (remove unused ref from Worker)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:05:43 +02:00
AdminandClaude Opus 4.8 a3bf796f17 chore(backend): install AI agent skills for the .NET stack
Run autoskills in backend/: detects .NET/C#/ASP.NET Core/Minimal API and
installs 13 skills for universal (.agents) and claude-code (.claude) agents,
with backend/skills-lock.json. Adds ASP.NET Core, Minimal API + OpenAPI,
file-upload and containerization skills on top of the prior C#/.NET set.
Removes the stale root skills from the old layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:48:31 +02:00
AdminandClaude Opus 4.8 8f34ed4874 chore: group .NET into backend/ and drop non-.NET test artifacts
- Move all .NET (6 projects, solution, Directory.*.props, nuget.config)
  into backend/ so the monorepo root can host non-.NET projects later
- Remove throwaway test artifacts: player.html, pl.json
- Trim dead Flutter rules from .gitignore

Build from backend/Musify.slnx: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:43:52 +02:00
AdminandClaude Opus 4.8 d9c8c17b21 refactor: central package management, shared build props, security fix
- Add Directory.Packages.props (CPM): all package versions centralized,
  remove Version attributes from every PackageReference
- Add Directory.Build.props: shared TargetFramework/Nullable/ImplicitUsings
- Drop redundant per-project RootNamespace/AssemblyName (default = project name)
  and stale NuGet metadata (PackageId/Version/Authors/RepositoryUrl)
- Bump Microsoft.OpenApi to 2.9.0 (CVE-2026-49451, was transitive 2.0.0);
  add explicit reference in Musify.Api which uses it directly
- Add repo-scoped nuget.config pinning nuget.org for reproducible restore

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:40:05 +02:00
AdminandClaude Opus 4.8 f6d668d201 refactor: standardize project names to Musify.* and flatten layout
- Flatten nested per-repo folders into flat Musify.* projects at root
- Rename assemblies/csproj: Domain->Musify.Domain, Application->Musify.Application,
  Infrastructure->Musify.Infrastructure, WebApi->Musify.Api, Worker->Musify.Worker,
  StreamingGateway->Musify.StreamingGateway
- Rename WebApi.* namespaces to Musify.Api.*
- Update all ProjectReference paths and the solution file
- Consolidate stray per-repo dotfiles; add root .gitattributes
- Move flutter client out to _archive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:32:48 +02:00
AdminandClaude Opus 4.8 8ab4d27287 chore: move flutter client out of monorepo into _archive
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:26:36 +02:00
AdminandClaude Opus 4.8 4b80c7dbb0 chore: bring flutter client, docs, deploy and tools into monorepo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:22:24 +02:00
AdminandClaude Opus 4.8 8c0519d125 feat: add unified Musify solution spanning all projects
Groups Domain/Application/Infrastructure under Core and
WebApi/Worker/StreamingGateway under Hosts. Removes the per-repo
.slnx files now superseded by the single solution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:21:32 +02:00
Admin 27922f2d14 chore: import Musify.StreamingGateway into monorepo (preserving history)
git-subtree-dir: Musify.StreamingGateway
git-subtree-mainline: 28426571e6
git-subtree-split: a8ff05892d
2026-07-05 03:20:50 +02:00
Admin 28426571e6 chore: import Musify.Worker into monorepo (preserving history)
git-subtree-dir: Musify.Worker
git-subtree-mainline: c3cdd3bc0f
git-subtree-split: 868860fff9
2026-07-05 03:20:48 +02:00
Admin c3cdd3bc0f chore: import Musify.WebApi into monorepo (preserving history)
git-subtree-dir: Musify.WebApi
git-subtree-mainline: 9d433868cc
git-subtree-split: ca8750c3b4
2026-07-05 03:20:47 +02:00
Admin 9d433868cc chore: import MusifyBackend into monorepo (preserving history)
git-subtree-dir: MusifyBackend
git-subtree-mainline: 6829e769ed
git-subtree-split: ed7b00c2cc
2026-07-05 03:20:43 +02:00
AdminandClaude Opus 4.8 6829e769ed chore: initialize Musify monorepo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:20:11 +02:00
AdminandClaude Opus 4.8 ca8750c3b4 chore: preserve local dev config before monorepo import
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:19:56 +02:00
Admin ed7b00c2cc Adjust 2026-06-03 00:32:37 +02:00
Admin e3b7a0353e Set Content-Type on transferred objects so .m4a is served as audio/mp4 2026-06-02 19:26:45 +02:00
Admin a8ff05892d Serve audio .m4a by range; remove DASH manifest rewriting 2026-06-02 19:05:19 +02:00
Admin 47c3a431c2 Point streaming config to single .m4a; drop DASH ffmpeg settings 2026-06-02 19:05:18 +02:00
Admin b737d59cbe Migrate audio transcoding from DASH to a single AAC (.m4a) file 2026-06-02 19:05:11 +02:00
Admin 2978b69654 Fix bugs 2026-06-01 22:48:42 +02:00
Admin 69eeefd58f Simplify user sync: rely on EF change tracking instead of manual hasChanges flag 2026-06-01 22:11:48 +02:00
Admin 30f57ff50e Auto-set CreatedAt/UpdatedAt via EF interceptor; standardize timestamp names across entities 2026-06-01 22:04:52 +02:00
Admin 55c73cbb3d Changes 2026-06-01 20:50:46 +02:00
Admin e59a5fa8ef Remove checks 2026-06-01 20:41:34 +02:00
Admin bd010e0330 Link created track to its owner via UserHasTrack 2026-06-01 19:07:33 +02:00
Admin 45999af9f8 Add endpoint to list paginated tracks of a playlist 2026-06-01 18:54:26 +02:00
Admin ca4d6c5ed2 Add paginated query for tracks of a playlist; make PlayListHasTrack.Track navigation non-nullable 2026-06-01 18:54:25 +02:00
Admin 58fda0ba9a Extract storage key building into a shared StorageKey helper 2026-06-01 16:45:37 +02:00
Admin 032906de6b Sync user profile (incl. picture) from token claims on validation 2026-06-01 01:47:47 +02:00
Admin 5c5fab86a5 Add user profile picture URL synced from the identity provider on login 2026-06-01 01:47:47 +02:00
Admin ba093e7fd5 Add playlist endpoints to add and remove tracks 2026-06-01 01:20:18 +02:00
Admin 842dd13efa Add and remove tracks from playlists (commands + handlers with ownership checks) 2026-06-01 01:20:17 +02:00
Admin 55549818af Cleanup 2026-06-01 00:24:41 +02:00
Admin 868860fff9 Initial commit: Musify.Worker background worker (MassTransit consumers for picture/audio processing) 2026-06-01 00:19:45 +02:00
Admin 3fea58cd72 Remove unused usings 2026-05-31 19:28:08 +02:00
Admin 03d9fa054f Allow any CORS origin in Development 2026-05-31 19:25:15 +02:00
Admin c1fc0ed1a1 Return 400 for validation errors without identifier and enable permissive CORS in Development 2026-05-31 19:25:15 +02:00
Admin 253a67aabf Fix audio DASH workflow: dispose source stream before delete and store S3 folder key as AudioFolderName 2026-05-31 19:25:15 +02:00
Admin e4452843e7 Use long 2026-05-31 18:25:40 +02:00
Admin 69cc73c374 Use long 2026-05-31 18:25:39 +02:00
Admin 9772879e67 Fixes on scalar 2026-05-30 03:44:32 +02:00
Admin bd92928366 Proxy 2026-05-30 03:30:32 +02:00
Admin 46985bf062 Proxy 2026-05-30 03:29:21 +02:00
Admin 834d7a25c1 Use development 2026-05-30 02:30:18 +02:00
Admin dc1d3c2269 Remove unused using 2026-05-30 02:27:12 +02:00
Admin 67d340fcaa BugFix 2026-05-30 02:26:33 +02:00
Admin 47bff00268 Changes 2026-05-30 02:24:55 +02:00
Admin f20391391a Add project files. 2026-05-30 02:16:39 +02:00
Admin f9aaa1a1fe Add .gitattributes and .gitignore. 2026-05-30 02:16:37 +02:00
Admin b6754a0e76 Update endpoint 2026-05-29 22:42:48 +02:00
Admin 3313560e6c ?? 2026-05-29 22:37:51 +02:00
Admin 8455033e52 Use if condition 2026-05-29 22:37:32 +02:00
Admin 7cb1851b0d Use null 2026-05-29 22:36:33 +02:00
Admin 036dcf72a7 Current User 2026-05-29 22:29:54 +02:00
Admin 39be51304b Added auth 2026-05-29 16:02:34 +02:00
Admin 335e1a785e Remove tests 2026-05-29 15:47:38 +02:00
Admin 5751b9ea08 Remove docs and agents 2026-05-29 15:45:53 +02:00
Admin 5765982a4d Remove tests 2026-05-29 15:45:31 +02:00
Admin ee18fe2ee1 Remove tests 2026-05-29 15:44:20 +02:00
Admin fd41053030 Small changes 2026-05-29 13:49:49 +02:00
Admin af2cf03fb4 Added tests 2026-05-29 13:49:44 +02:00
Admin 1efdbc237a Added tests 2026-05-29 13:37:34 +02:00
Admin be3f832e11 PascalCase 2026-05-29 02:28:35 +02:00
Admin 275c001f1f PascalCase 2026-05-29 02:26:37 +02:00
Admin 285668efc7 Rename 2026-05-29 01:57:28 +02:00
Admin 10124f0184 Rename 2026-05-29 01:57:27 +02:00
Admin b6dea8b041 Changes 2026-05-29 01:48:48 +02:00
Admin 585af60371 Changes 2026-05-29 01:48:46 +02:00
Admin 4dbd87f5d9 Remove unused usings 2026-05-29 00:25:26 +02:00
Admin 05c53058da Fixes and config 2026-05-28 23:49:09 +02:00
Admin 99d202f143 Remove keycloak 2026-05-28 23:16:35 +02:00
Admin de66cb0f48 Remove keycloak 2026-05-28 23:16:30 +02:00
Admin 52a561d52a Change 2026-05-28 23:14:32 +02:00
Admin 3b79b399ce Change on filter 2026-05-28 22:06:09 +02:00
Admin 64653f4fcd validators 2026-05-28 21:53:46 +02:00
Admin 9a3e7d4bb5 Remove guid as is unvalid 2026-05-28 21:49:18 +02:00
Admin 9ecb16f888 Update endpoints 2026-05-28 21:44:28 +02:00
Admin 036786bd59 Update packages 2026-05-28 21:37:46 +02:00
Admin f761405771 Add project files. 2026-05-28 21:35:31 +02:00
Admin f268bc2294 Add .gitattributes and .gitignore. 2026-05-28 21:35:29 +02:00
Admin 130f23f841 Change on files 2026-05-28 21:07:49 +02:00
Admin d35e8eb5f7 PreventOverwrite 2026-05-28 21:04:09 +02:00
Admin 94bf9c61a3 Update structure 2026-05-28 20:42:38 +02:00
Admin ffa3a73bb8 Update structure 2026-05-28 20:42:35 +02:00
Admin 25accf3456 Update structure 2026-05-28 20:41:03 +02:00
Admin 2769c7f38f 1 2026-05-28 18:37:54 +02:00
Admin 6fa7c24516 Changes 2026-05-14 22:22:47 +02:00
Admin e64aa4e90a Compilation fixes 2026-05-14 22:15:26 +02:00
Admin 4cb308b78d Fixes and a lot of changes 2026-05-14 22:13:22 +02:00
Admin 348f5cb813 Changes 2026-05-14 21:42:31 +02:00
Admin deb4bfda90 Generated response 2026-05-14 19:53:26 +02:00
Admin 7aef20fe71 Update plans 2026-05-14 18:48:00 +02:00
Admin 5a00a2478e Update delete workflows 2026-05-14 18:24:20 +02:00
Admin f2febb898e A lot of changes 2026-05-14 14:32:19 +02:00
Admin 7160266763 Bump version 2026-05-14 01:32:29 +02:00
Admin 1ed2ec8d93 Update migrations 2026-05-14 01:06:30 +02:00
Admin 0e65103af2 Renames 2026-05-14 01:05:07 +02:00
Admin f6ac45cb79 Safe code and changes 2026-05-14 01:00:44 +02:00
Admin 1ecf4a872e Remove processingStatus 2026-05-13 21:56:34 +02:00
Admin fa6eaf5a2b Reworks 2026-05-13 02:20:22 +02:00
Admin 57a5358701 Remove keycloak and add missing functions 2026-05-09 19:36:49 +02:00
Admin b28edbbd3a Update routes and changes on queries 2026-05-09 19:09:10 +02:00
Admin be2bbea4cc Changes on routes 2026-05-09 18:57:16 +02:00
Admin 4da960d9a1 AsNoTracking 2026-05-09 18:49:39 +02:00
Admin e5099343d6 Fix stringLength 2026-05-09 18:47:57 +02:00
Admin db78df7f0e Change 2026-05-09 02:56:43 +02:00
Admin 3f10613df0 Update grafana 2026-05-08 21:48:31 +02:00
Admin 5aa8e1db60 Version bump 2026-04-01 09:50:53 +02:00
Admin 049e4723cd UTC 2026-04-01 09:50:30 +02:00
Admin 9585d8f34c Missing configuration DI 2026-03-30 15:55:44 +02:00
Admin c30dfd6923 Version 2026-03-30 10:20:32 +02:00
Admin 7a219e4dea Rename 2026-03-30 10:12:51 +02:00
Admin c6e0c45a50 Remove unused file 2026-03-30 10:07:08 +02:00
Admin ce278dc935 Configure packages 2026-03-30 09:51:59 +02:00
Admin 093b84e660 Remove 2026-03-30 09:43:53 +02:00
Admin 795f8fdab0 Configuration mediator 2026-03-29 20:37:40 +02:00
Admin b9f03eb03c Remove DispatchR and use Mediator 2026-03-29 20:17:32 +02:00
Admin 9e4d5158e8 Fixes 2026-03-29 19:10:24 +02:00
Admin 12aa104d80 Fix and missing service 2026-03-29 18:59:50 +02:00
Admin 164fb5f29a Configurations 2026-03-29 18:31:21 +02:00
Admin f92b1b93ea Rename 2026-03-29 18:26:44 +02:00
Admin e983f5af4d Format 2026-03-29 18:23:13 +02:00
Admin b433060deb Map 2026-03-29 18:21:56 +02:00
Admin 3e54b5db43 Experimental changes 2026-03-29 17:15:39 +02:00
Admin 7d4265d47c Spaces 2026-03-29 00:59:27 +01:00
Admin 2518101a52 Changes on messages 2026-03-28 21:26:45 +01:00
Admin 4fa341d2c8 Rewrite handlers 2026-03-28 20:47:54 +01:00
Admin 06ef70cb3b Renames and changes 2026-03-28 15:32:36 +01:00
Admin d2a1807883 Rename variables 2026-03-28 03:27:31 +01:00
Admin ffaf9990df Changes 2026-03-28 02:57:08 +01:00
Admin c78ec50036 Ops 2026-03-28 02:56:18 +01:00
Admin b9ad1a87a8 Fix 2026-03-28 02:56:08 +01:00
Admin bc9fdad098 Renames 2026-03-27 23:07:05 +01:00
Admin 32b83e84a6 Fixed routes and bugs 2026-03-27 23:06:17 +01:00
Admin 075ff3c7b6 Changes on routes 2026-03-27 21:16:38 +01:00
Admin 9b1ae1a7a1 TransferFiles 2026-03-27 18:07:17 +01:00
Admin 7ffd63ed9a Use variable 2026-03-26 19:59:44 +01:00
Admin 606fa26c3f Missing log 2026-03-26 18:24:11 +01:00
Admin 0384de792b Upload file activity 2026-03-26 18:23:59 +01:00
Admin 6c413f5174 Update paths 2026-03-25 19:40:07 +01:00
Admin b201a4a7af Fix namespaces 2026-03-25 18:12:08 +01:00
Admin cbea2ad9dc Missing files 2026-03-25 18:09:37 +01:00
Admin d3dbde4fec File ignored 2026-03-25 10:41:23 +01:00
Admin 3dea06b0cc Fixes 2026-03-25 02:13:47 +01:00
Admin 6e06d7673d Rename 2026-03-25 00:35:23 +01:00
Admin 10a66c70c8 Delete temporal folders and some changes 2026-03-24 20:52:44 +01:00
Admin 0355bd22a6 Format 2026-03-24 19:55:31 +01:00
Admin bf23224a51 Renames and migrations 2026-03-24 19:47:32 +01:00
Admin 01093abced Changes 2026-03-24 19:32:43 +01:00
Admin 22a65b7efa Changes 2026-03-24 14:45:20 +01:00
Admin 4f5230ddf6 Changes 2026-03-24 14:45:04 +01:00
Admin 510f52a034 TempPath 2026-03-23 19:30:20 +01:00
Admin 0872b8df6f Change on routes 2026-03-23 19:17:15 +01:00
Admin 7a81341218 Logger 2026-03-23 17:22:47 +01:00
Admin 75cb09dbf2 Changes 2026-03-23 17:16:01 +01:00
Admin d62d3441db Refactor audioTranscoder 2026-03-23 17:14:49 +01:00
Admin f1b7191ccc Cambios 2026-03-23 16:53:23 +01:00
Admin 788fb6fa5c Use variables 2026-03-23 16:28:02 +01:00
Admin fab98098c8 Collection expression 2026-03-23 16:13:26 +01:00
Admin 4e089c4da8 Download once 2026-03-23 09:48:15 +01:00
Admin 7c7f118a43 Comments 2026-03-23 09:38:02 +01:00
Admin 9eb6fa826e Changes but failing 2026-03-23 03:13:08 +01:00
Admin d771c6c0a7 Missing file 2026-03-22 04:02:50 +01:00
Admin ff64f1f136 Staged 2026-03-22 04:02:30 +01:00
Admin 4215fb483a First iteration 2026-03-22 04:02:17 +01:00
Admin d4af39f53b Audio Transcoder 2026-03-21 22:23:31 +01:00
Admin 951e42d21a Handler 2026-03-21 21:01:57 +01:00
Admin a98969ec3a Refactor 2026-03-21 20:38:13 +01:00
Admin 2c3b84a233 Move 2026-03-21 20:33:15 +01:00
Admin 801bf08337 Queries y algunos cambios 2026-03-21 04:00:12 +01:00
Admin 1bff5e62ea A lot of changes 2026-03-21 03:45:29 +01:00
Admin df4f4bdf66 Routes 2026-03-21 00:04:19 +01:00
Admin 088016f986 Message 2026-03-20 23:26:49 +01:00
Admin 32c1e3b842 Nullable 2026-03-20 23:24:32 +01:00
Admin 285b93a9be Grafana 2026-03-20 22:28:32 +01:00
Admin 8ce3e68b81 OT 2026-03-20 22:18:37 +01:00
Admin 1bd8c8779d Config 2026-03-20 21:45:36 +01:00
Admin b34a711d2d Rework jobs 2026-03-20 21:40:05 +01:00
Admin 93a975e9b5 Last consumer 2026-03-20 20:33:56 +01:00
Admin 62283bd403 Renames 2026-03-20 20:24:46 +01:00
Admin eda9bef828 Refactor 2026-03-20 20:24:10 +01:00
Admin 9756672160 Changes 2026-03-20 20:09:36 +01:00
Admin af061bc6fc Rename 2026-03-20 12:27:45 +01:00
Admin b5955fda5b Changes 2026-03-20 12:22:07 +01:00
Admin 17944b5344 Refactor 2026-03-20 12:10:54 +01:00
Admin dac5096998 Error message 2026-03-20 12:01:08 +01:00
Admin eb822ff1bf Rename 2026-03-20 11:57:16 +01:00
Admin 031fdb7f46 Refactor 2026-03-20 11:11:45 +01:00
Admin 0b8257a346 Fix job 2026-03-20 10:43:52 +01:00
Admin 676910e462 IEventBus 2026-03-20 09:53:54 +01:00
Admin 18da740e08 Keycloak service 2026-03-20 09:42:05 +01:00
Admin 3c21e0ea1a Rename 2026-03-20 09:41:43 +01:00
Admin a036454220 MaxLength 2026-03-20 09:32:42 +01:00
Admin 535b6e457b Spaces 2026-03-20 01:59:48 +01:00
Admin 4343542dce JobExecution 2026-03-20 01:58:45 +01:00
Admin 58b0974bec [NotMapped] 2026-03-20 01:37:51 +01:00
Admin f3edcd0af4 States 2026-03-20 01:37:34 +01:00
Admin a7203dff02 Missing if 2026-03-20 01:36:22 +01:00
Admin 4387aa146e QOL Changes 2026-03-20 01:35:12 +01:00
Admin 716558c085 A lot of changes 2026-03-19 20:21:02 +01:00
Admin e89fc5b45c Update playlist and some changes 2026-03-19 19:50:15 +01:00
Admin 9f90151fc5 Changes on config and DI 2026-03-19 17:22:27 +01:00
Admin cc720e2c08 Config changes 2026-03-19 17:12:08 +01:00
Admin 4996fd019e Changes 2026-03-19 10:41:32 +01:00
Admin d9cb715622 Add DesignSettings 2026-03-19 09:41:48 +01:00
Admin bc751a467b Fix 2026-03-19 09:33:39 +01:00
Admin f5643c0ccd . 2026-03-19 09:27:22 +01:00
Admin 0c04ddf316 Remove files consumer 2026-03-19 01:20:05 +01:00
Admin 14ae57ead8 jobs and others 2026-03-19 01:09:02 +01:00
Admin f41a6c2d77 Some changes 2026-03-18 21:05:06 +01:00
Admin 28b46e3cc4 Change name 2026-03-18 20:52:11 +01:00
Admin 41b8cbfc9c Small changes 2026-03-18 19:07:01 +01:00
Admin 4f74192cb6 Changes 2026-03-18 18:59:41 +01:00
Admin 9f14bb2c86 Changes 2026-03-18 18:48:01 +01:00
Admin b1ac38d377 Changes 2026-03-18 18:08:53 +01:00
Admin afcdaea4fd username and password 2026-03-18 17:42:16 +01:00
Admin 9147c2c799 Dependency injection 2026-03-18 17:17:37 +01:00
Admin 463d1a6600 Rename 2026-03-18 16:43:34 +01:00
Admin dee4bd53ab Rename to picture 2026-03-18 16:29:19 +01:00
Admin 6ffec218b3 Rename 2026-03-18 16:15:50 +01:00
Admin b1a0eeeb22 Changes 2026-03-18 16:06:57 +01:00
Admin 9816e23401 Config 2026-03-18 15:50:18 +01:00
Admin 076f048efe Changes and fixes 2026-03-18 10:52:00 +01:00
Admin 92bd06751d Upload playlist image 2026-03-18 10:26:40 +01:00
Admin 63c89c1946 Using 2026-03-18 02:22:44 +01:00
Admin 3a45cc27d1 Uploads 2026-03-18 02:19:36 +01:00
Admin 82ddf7dea6 PictureHandler 2026-03-18 01:24:16 +01:00
Admin 28719fe4d1 First Commit 2026-03-18 01:14:49 +01:00
403 changed files with 9368 additions and 11928 deletions
-127
View File
@@ -1,127 +0,0 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+1 -3
View File
@@ -7,9 +7,6 @@ obj/
*.userosscache
.vs/
artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS
.idea/
@@ -28,4 +25,5 @@ Thumbs.db
# Deploy secrets and runtime state
deploy/keys/
deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/
+49 -47
View File
@@ -1,55 +1,57 @@
# Musify
A music streaming app. Upload your own tracks, organize them into playlists
and albums, and stream them back with a web player built around your
listening habits: auto-generated mixes, a home feed, search, likes, and an
Explore page for finding something new.
## What it does
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
- **Playlists**, likes, and a "recently played" history.
- **Mixes**, playlists generated automatically from what you actually listen to.
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
- **Accounts via OIDC login**, with an optional anonymous listening mode.
- Light and dark themes, and a layout that works on both desktop and mobile.
## How it's built
**Backend**: .NET 10, split into a REST API, a background worker, and a
dedicated streaming gateway.
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
- Zitadel for login (OIDC/OAuth2).
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
The session lives on the server, so the backend access token never reaches the browser.
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
shared infrastructure that lives in a separate `Infrastructure` git
repository, used by Musify and by other projects that need the same OIDC
provider and object storage.
## Project layout
Music streaming platform: upload your own tracks, process them asynchronously
(audio transcoded to `.m4a`, thumbnails generated) and stream them without the
bytes ever going through the API.
```
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
web-player/ the SvelteKit web client
deploy/ Docker Compose stacks for dev and production
docs/ architecture notes, one file per area
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
deploy/ Docker Compose stacks — self-contained, no wrapper scripts
docs/ What each piece does and why (in Spanish)
```
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
layout. They live in the separate `Infrastructure` repository instead.
```sh
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS)
```
## Digging deeper
## Run it
[docs/](docs/README.md) covers how each piece works and why: architecture,
authentication, storage, streaming, media processing, and the local dev
setup. [deploy/README.md](deploy/README.md) has the full instructions for
running the stack, in development or on a production server.
Development (Docker Desktop):
```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
```
Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC
provisioning — all as one-shot jobs baked into the compose file. Apps run from
the IDE by default; add `--profile apps up -d --build` as a second command to
run them in Docker instead.
Production, on a server with a domain:
```sh
cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
```
Idempotent — run the same commands again to deploy an update. See
[deploy/README.md](deploy/README.md) for the details, the port map and the
production prerequisites.
## Components
| Service | Port (dev) | Role |
|---|---|---|
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails |
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
More in [docs/](docs/README.md): [architecture](docs/architecture.md),
[authentication](docs/authentication.md), [storage](docs/storage.md),
[streaming](docs/streaming.md), [media processing](docs/media-processing.md),
[development](docs/development.md).
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
.Where(history => history.UserId == request.UserId);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
@@ -1,6 +1,6 @@
namespace Musify.Application.Albums.Responses
{
/// <summary>A page of the user's catalog albums (their own).</summary>
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
@@ -3,7 +3,8 @@ using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration
{
/// <summary>Reads the parts of <see cref="PlaybackConfiguration"/> that are safe to expose to an
/// unauthenticated client, so it can decide whether to gate the app behind sign-in at all.</summary>
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
@@ -2,16 +2,28 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
/// <summary>Controls whether listeners without a session can stream music at all, and if so,
/// how much of a track they get to hear before being cut off (a Spotify-style preview).</summary>
public sealed class PlaybackConfiguration
{
public const string SectionName = "Playback";
/// <summary>When false (the default), every stream endpoint requires a signed-in user, exactly
/// like before this setting existed. When true, an anonymous request is issued a stream ticket
/// too, capped by <see cref="AnonymousFragmentSeconds"/> if that is greater than zero.</summary>
public bool AllowAnonymousListening { get; set; }
/// <summary>How many seconds of a track an anonymous listener may hear, enforced by the
/// Streaming Gateway clamping the byte range it will serve for that ticket. Zero (the default)
/// means no cap: anonymous listeners hear the full track, same as a signed-in user. Ignored
/// when <see cref="AllowAnonymousListening"/> is false.</summary>
[Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
/// <summary>Bytes per second of the transcoded audio, used to turn <see cref="AnonymousFragmentSeconds"/>
/// into a byte cap for the stream ticket. Defaults to the app's own transcoder output (128 kbps
/// AAC, <c>AudioTranscoder:Ffmpeg:AudioBitrate</c>) — update this alongside that setting if it
/// ever changes, since the two aren't wired together.</summary>
[Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
@@ -1,5 +1,7 @@
namespace Musify.Application.Configuration.Responses
{
/// <param name="AllowAnonymousListening">Whether listeners without a session can stream music at all.</param>
/// <param name="AnonymousFragmentSeconds">How many seconds of a track an anonymous listener may hear.
/// Zero means no cap. Meaningless when <paramref name="AllowAnonymousListening"/> is false.</param>
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
}
@@ -2,7 +2,11 @@ namespace Musify.Application.Contracts
{
public interface IStreamTicketService
{
/// <param name="userId">Null for an anonymous listener — the ticket is issued without a
/// <c>sub</c> claim.</param>
/// <param name="keyPrefix">The storage key (prefix) the ticket authorizes reading from.</param>
/// <param name="maxBytes">When set, the Streaming Gateway clamps the byte range it will serve
/// for this ticket to <c>[0, maxBytes)</c> — used for the anonymous preview fragment.</param>
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
}
@@ -1,26 +0,0 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -1,31 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -30,7 +30,7 @@ namespace Musify.Application.Likes
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -12,6 +12,11 @@ namespace Musify.Application.Mixes
public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>;
/// <summary>Builds each user's mixes purely from their own library: a "Descubrimiento" mix of
/// tracks they own but haven't listened to yet, and a "Tu mezcla diaria" mix blending recently
/// listened tracks with unheard ones. There's no artist/genre metadata for local uploads today,
/// so mixes can't be seeded by "similar artist" the way the old YouTube-backed discovery did —
/// once a tagging system exists, this is the place to seed mixes by tag instead.</summary>
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
@@ -38,7 +43,7 @@ namespace Musify.Application.Mixes
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Where(history => history.UserId == userId)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -5,7 +5,9 @@ using Musify.Domain.Entities;
namespace Musify.Application.Mixes
{
/// <summary>Resolves MixItem rows to their MixItemApplicationResponse shape via a single batched
/// track lookup, instead of an EF Include chain deep enough (Mix -&gt; Items -&gt; Track -&gt; Owner) to
/// force a correlated-subquery translation some providers (SQLite, notably) can't produce.</summary>
internal static class MixItemMapper
{
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
@@ -19,7 +21,7 @@ namespace Musify.Application.Mixes
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
@@ -1,86 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
public class AddAlbumToPlayListCommandHandler(
IDatabase database,
ILogger<AddAlbumToPlayListCommandHandler> logger)
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
{
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound(description: $"Album {request.AlbumId} not found");
}
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var existingTrackIds = (await playListTracksQuery
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken)).ToHashSet();
var newTrackIds = albumTrackIds
.Where(trackId => !existingTrackIds.Contains(trackId))
.ToList();
if (newTrackIds.Count == 0)
return new AddAlbumToPlayListResponse(0);
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddRangeAsync(
newTrackIds.Select((trackId, index) => new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition + index
}),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
newTrackIds.Count, request.AlbumId, request.PlayListId);
return new AddAlbumToPlayListResponse(newTrackIds.Count);
}
}
}
@@ -21,8 +21,6 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -38,7 +36,7 @@ namespace Musify.Application.PlayLists
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
}
}
}
@@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -25,8 +25,6 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -36,7 +34,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -46,8 +46,6 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -57,7 +55,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -1,4 +0,0 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
@@ -12,10 +10,7 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
PlaylistVisibility Visibility,
int TrackCount,
double DurationSeconds,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
@@ -24,8 +19,6 @@ namespace Musify.Application.PlayLists.Responses
public static PlayListApplicationResponse FromEntity(
PlayList playList,
int trackCount = 0,
double durationSeconds = 0,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
@@ -35,10 +28,7 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.UserId,
playList.Visibility,
trackCount,
durationSeconds,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
@@ -112,22 +112,7 @@ namespace Musify.Application.PlayLists
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
var totals = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.GroupBy(plt => plt.PlayListId)
.Select(group => new
{
TrackCount = group.Count(),
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
})
.SingleOrDefaultAsync(cancellationToken);
return PlayListApplicationResponse.FromEntity(
playListEntity,
totals?.TrackCount ?? 0,
totals?.DurationSeconds ?? 0,
coverTrackIds);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
@@ -4,6 +4,13 @@ using System.Text.Json.Serialization;
namespace Musify.Application.Serialization;
// Zitadel-issued user ids routinely exceed Number.MAX_SAFE_INTEGER (2^53-1).
// Serialized as a raw JSON number, the frontend's JSON.parse silently rounds
// them to the nearest representable double, corrupting the id (it becomes a
// different, sometimes valid, user id). Applied via [property: JsonConverter(...)]
// on the specific id-shaped `long`/`long?` response properties (not globally —
// unrelated `long` fields like byte sizes must stay numbers), this serializes
// them as a JSON string instead, which round-trips exactly.
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
@@ -30,7 +30,8 @@ public sealed class TrackStreamIssuer(
folderPath,
streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
// Anonymous listeners have no User row to satisfy ListeningHistory's foreign key against, and
// their plays aren't "history" anyone can look back on anyway, so there's nothing to record.
if (userId != null)
{
var newListeningHistory = new ListeningHistory
@@ -40,10 +41,9 @@ public sealed class TrackStreamIssuer(
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
listenId = newListeningHistory.Id;
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
}
private long? AnonymousFragmentBytes(long? userId)
@@ -18,8 +18,7 @@ namespace Musify.Application.Tracks
string Title,
Guid PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
IReadOnlyCollection<Genre> Tags)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler(
@@ -84,7 +83,6 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id,
Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
@@ -1,33 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
database.ListeningHistories.RemoveRange(stale);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count;
}
}
}
@@ -19,7 +19,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId)
.OrderByDescending(t => t.ListenedAt)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
.FirstOrDefaultAsync(cancellationToken);
return track == null
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
{
var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId && l.IsCounted)
.Where(l => l.UserId == query.UserId)
.GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key)
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count })
.ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds
@@ -18,7 +18,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.SingleOrDefaultAsync(cancellationToken);
if (entity == null)
@@ -9,7 +9,8 @@ using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks
{
/// <param name="UserId">Null for an anonymous request — allowed only when
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
: IQuery<ErrorOr<TrackStreamResponse>>;
@@ -5,11 +5,10 @@ using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database)
@@ -29,15 +28,12 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
@@ -40,7 +40,7 @@ namespace Musify.Application.Tracks
var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -1,14 +0,0 @@
namespace Musify.Application.Tracks
{
public static class ListeningRules
{
public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) =>
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
}
}
@@ -1,44 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record RecordListeningProgressCommand(
long UserId,
Guid ListenId,
double PlayedSeconds)
: ICommand<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
{
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories
.Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
if (listen == null)
return Error.NotFound();
var now = DateTime.UtcNow;
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
listen.LastProgressAt = now;
listen.IsCounted = listen.IsCounted
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
await database.SaveChangesAsync(cancellationToken);
return Result.Success;
}
}
}
@@ -15,8 +15,7 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt,
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
IReadOnlyCollection<Genre> Tags)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
@@ -29,7 +28,6 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
track.Tags.Select(tag => tag.Tag).ToList());
}
}
@@ -3,6 +3,5 @@ namespace Musify.Application.Tracks.Responses
public record TrackStreamResponse(
string ManifestUrl,
string Ticket,
int ExpiresInSeconds,
Guid? ListenId = null);
int ExpiresInSeconds);
}
@@ -1,6 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
/// <summary>A page of the user's catalog tracks (their own uploads).</summary>
public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber,
@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public static class FollowVisibility
{
public static async Task<bool> CanViewFollowersAsync(
IDatabase database,
long? viewerId,
long userId,
CancellationToken cancellationToken)
{
if (viewerId is not { } viewer)
return false;
if (viewer == userId)
return true;
var mutualRows = await database.UserFollows
.AsNoTracking()
.CountAsync(f =>
(f.FollowerId == viewer && f.FollowedId == userId) ||
(f.FollowerId == userId && f.FollowedId == viewer),
cancellationToken);
return mutualRows == 2;
}
}
}
@@ -20,18 +20,17 @@ namespace Musify.Application.Users
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory
.Where(l => l.IsCounted)
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId && l.IsCounted)
.Where(l => l.UserId == request.UserId)
.Select(l => l.ListenedAt.Date)
.Distinct()
.ToListAsync(cancellationToken);
@@ -1,46 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowersQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
return Error.Forbidden();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowedId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Follower.Id,
f.Follower.Name,
f.Follower.FirstName,
f.Follower.SecondName,
f.Follower.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -1,43 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowingQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowerId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Followed.Id,
f.Followed.Name,
f.Followed.FirstName,
f.Followed.SecondName,
f.Followed.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -7,7 +7,7 @@ using Musify.Application.Serialization;
namespace Musify.Application.Users
{
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
@@ -17,9 +17,7 @@ namespace Musify.Application.Users
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
int FollowingCount);
public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
@@ -51,14 +49,6 @@ namespace Musify.Application.Users
.AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
database, request.ViewerId, request.UserId, cancellationToken);
return new UserProfileResponse(
user.Id,
user.Name,
@@ -67,9 +57,7 @@ namespace Musify.Application.Users
user.ProfilePictureUrl,
user.CreatedAt,
followersCount,
followingCount,
isFollowing,
canViewFollowers);
followingCount);
}
}
}
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>;
public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{
var usersQuery = database.Users
.AsNoTracking()
@@ -27,19 +27,11 @@ namespace Musify.Application.Users
}
var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery
.OrderBy(u => u.Name)
.Select(u => new UserSummaryResponse(
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
.Select(u => UserApplicationResponse.FromEntity(u))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers);
}
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.Users.Responses
{
public record UserSummaryResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
bool IsFollowedByViewer);
}
@@ -19,12 +19,6 @@ namespace Musify.Domain.Entities
[Required]
public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
public double? PlayedSeconds { get; set; }
public DateTime? LastProgressAt { get; set; }
public bool IsCounted { get; set; }
[ForeignKey(nameof(UserId) )]
public User User { get; set; }
@@ -31,9 +31,6 @@ namespace Musify.Domain.Entities
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -1,6 +1,9 @@
namespace Musify.Domain.ValueObjects
{
/// <summary>
/// Encodes which genre pairs are mutually exclusive on a single track (e.g. a track cannot
/// be tagged as both Classical and Metal at the same time).
/// </summary>
public static class GenreCompatibility
{
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
@@ -28,6 +31,9 @@ namespace Musify.Domain.ValueObjects
public static bool AreCompatible(Genre a, Genre b) =>
a == b || !IncompatiblePairs.Contains((a, b));
/// <summary>
/// Returns every mutually exclusive genre pair found within the given set of tags.
/// </summary>
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
{
var distinctTags = tags.Distinct().ToList();
@@ -15,7 +15,6 @@ namespace Musify.Infrastructure.Jobs
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -1,13 +0,0 @@
using Mediator;
using Musify.Application.Tracks;
namespace Musify.Infrastructure.Jobs
{
public class ListeningHistoryCleanupJob(IMediator mediator)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
}
}
}
@@ -24,6 +24,11 @@ namespace Musify.Infrastructure.Persistence
if (context == null)
return;
// SavingChanges(Async) fires before EF's own automatic change detection for this
// save, so entities modified through plain property assignment (query, then set a
// property, then SaveChanges — the normal case) still show as Unchanged here unless
// something already forced detection. Force it explicitly, otherwise UpdatedAt is
// never bumped on a real update, only on inserts.
context.ChangeTracker.DetectChanges();
var now = DateTime.UtcNow;
@@ -37,7 +42,7 @@ namespace Musify.Infrastructure.Persistence
}
else if (entry.State == EntityState.Modified)
{
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
entry.Entity.UpdatedAt = now;
}
}
}
@@ -13,13 +13,9 @@ namespace Musify.Infrastructure.Persistence
public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase
{
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -18,7 +18,10 @@ namespace Musify.Infrastructure.Persistence
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddDbContext<Database>();
serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260531234306_AddUserProfilePictureUrl")]
partial class AddUserProfilePictureUrl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserProfilePictureUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260601195757_RenameTimestampsAndAudit")]
partial class RenameTimestampsAndAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RenameTimestampsAndAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
@@ -31,6 +31,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
newName: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705123932_AddTrackProcessingStateSaga")]
partial class AddTrackProcessingStateSaga
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackProcessingStateSaga : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
@@ -27,6 +27,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705124807_AddTrackProcessingSagaKeys")]
partial class AddTrackProcessingSagaKeys
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackProcessingSagaKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
@@ -29,6 +29,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705125356_AddPlayListProcessingStateSaga")]
partial class AddPlayListProcessingStateSaga
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlayListProcessingStateSaga : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
@@ -27,6 +27,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260713234847_AddListeningHistory")]
partial class AddListeningHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddListeningHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
@@ -47,6 +47,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260714114537_AddTrackDuration")]
partial class AddTrackDuration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackDuration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260715193951_AddTrackSourceArtistExternalId")]
partial class AddTrackSourceArtistExternalId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackSourceArtistExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
@@ -41,6 +41,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
filter: "\"ExternalId\" <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260715230836_AddTrackDownloadRequested")]
partial class AddTrackDownloadRequested
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackDownloadRequested : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716214442_MakeTrackMediaAndExternalIdNullable")]
partial class MakeTrackMediaAndExternalIdNullable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakeTrackMediaAndExternalIdNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -66,6 +66,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716214752_AddArtistsAndTrackOwnership")]
partial class AddArtistsAndTrackOwnership
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddArtistsAndTrackOwnership : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
@@ -154,6 +154,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716220047_DropTrackArtistColumn")]
partial class DropTrackArtistColumn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class DropTrackArtistColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
table: "Tracks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717164632_MovePlayListPicturesToOwnedType")]
partial class MovePlayListPicturesToOwnedType
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,15 +4,16 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MovePlayListPicturesToOwnedType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717164812_RenameTrackDurationToDurationSeconds")]
partial class RenameTrackDurationToDurationSeconds
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RenameTrackDurationToDurationSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
@@ -16,6 +16,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
newName: "DurationSeconds");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717172507_SplitTrackIntoLocalAndExternal")]
partial class SplitTrackIntoLocalAndExternal
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class SplitTrackIntoLocalAndExternal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
@@ -156,6 +156,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717223358_MakePlayListPicturesNullable")]
partial class MakePlayListPicturesNullable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakePlayListPicturesNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
@@ -51,6 +51,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldMaxLength: 64);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720180629_WidenTrackTitle")]
partial class WidenTrackTitle
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class WidenTrackTitle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
@@ -31,6 +31,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldMaxLength: 50);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720180747_AddAlbums")]
partial class AddAlbums
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAlbums : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
@@ -100,6 +100,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "OwnerUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720184656_AddUserAlbumExternalId")]
partial class AddUserAlbumExternalId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserAlbumExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -28,6 +28,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721155102_AddMixes")]
partial class AddMixes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
@@ -83,6 +83,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "TrackId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721161653_RemoveUserAlbumExternalId")]
partial class RemoveUserAlbumExternalId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveUserAlbumExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -24,6 +24,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "OwnerUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721162102_DropMixGeneratedOn")]
partial class DropMixGeneratedOn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class DropMixGeneratedOn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
table: "Mixes");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721190735_MakePlayListDescriptionOptional")]
partial class MakePlayListDescriptionOptional
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakePlayListDescriptionOptional : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
@@ -27,6 +27,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@

// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724170051_DurationAsDouble")]
partial class DurationAsDouble
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618

Some files were not shown because too many files have changed in this diff Show More