Author SHA1 Message Date
Admin d892d976a3 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).
2026-09-21 15:05:07 +02:00
Admin c07676f486 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.
2026-09-21 14:51:43 +02:00
Admin e4b0ce3df0 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).
2026-09-21 14:39:20 +02:00
Admin 1a2bafe96c 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).
2026-09-21 00:58:52 +02:00
Admin 85f06edc53 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).
2026-09-21 00:48:23 +02:00
Admin a0ba57b569 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).
2026-09-21 00:30:30 +02:00
Admin 8c6d4d6a6d 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).
2026-09-21 00:21:15 +02:00
Admin 137a3b62bb 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).
2026-09-21 00:14:20 +02:00
Admin 9fd0bf46d6 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).
2026-09-20 23:05:53 +02:00
Admin e187016c70 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.
2026-09-20 23:01:13 +02:00
Admin 89326c0a16 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.
2026-09-20 22:48:08 +02:00
Admin 65518e72e3 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.
2026-09-20 22:40:40 +02:00
Admin 521cafc5c4 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).
2026-09-20 22:39:05 +02:00
Admin 3e56725566 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.
2026-09-20 22:26:37 +02:00
Admin fbca8156a7 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.
2026-09-20 22:23:57 +02:00
Admin 4b3d0b5fbd 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).
2026-09-20 22:21:04 +02:00
Admin ec5b84f748 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).
2026-09-20 22:14:48 +02:00
232 changed files with 6672 additions and 2213 deletions
-115
View File
@@ -1,115 +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: Migrate database, generate keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d wait-for-postgres keys-init 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 *.userosscache
.vs/ .vs/
artifacts/ artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS # Rider / VS
.idea/ .idea/
@@ -28,4 +25,5 @@ Thumbs.db
# Deploy secrets and runtime state # Deploy secrets and runtime state
deploy/keys/ deploy/keys/
deploy/secrets/ deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/ deploy/zitadel/.output/
+49 -47
View File
@@ -1,55 +1,57 @@
# Musify # Musify
A music streaming app. Upload your own tracks, organize them into playlists Music streaming platform: upload your own tracks, process them asynchronously
and albums, and stream them back with a web player built around your (audio transcoded to `.m4a`, thumbnails generated) and stream them without the
listening habits: auto-generated mixes, a home feed, search, likes, and an bytes ever going through the API.
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
``` ```
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
web-player/ the SvelteKit web client web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
deploy/ Docker Compose stacks for dev and production deploy/ Docker Compose stacks — self-contained, no wrapper scripts
docs/ architecture notes, one file per area docs/ What each piece does and why (in Spanish)
``` ```
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this ```sh
layout. They live in the separate `Infrastructure` repository instead. 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, Development (Docker Desktop):
authentication, storage, streaming, media processing, and the local dev
setup. [deploy/README.md](deploy/README.md) has the full instructions for ```sh
running the stack, in development or on a production server. 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).
@@ -1,6 +1,6 @@
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses
{ {
/// <summary>A page of the user's catalog albums (their own).</summary>
public record AlbumsSearchResponse( public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items, IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber, int PageNumber,
@@ -3,7 +3,8 @@ using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration 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 record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
@@ -2,16 +2,28 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration 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 sealed class PlaybackConfiguration
{ {
public const string SectionName = "Playback"; 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; } 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)] [Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; } 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)] [Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
} }
@@ -1,5 +1,7 @@
namespace Musify.Application.Configuration.Responses 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); public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
} }
@@ -2,7 +2,11 @@ namespace Musify.Application.Contracts
{ {
public interface IStreamTicketService 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); StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
} }
@@ -12,6 +12,11 @@ namespace Musify.Application.Mixes
public record GenerateMixesForUserCommand(long UserId) public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>; : 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( public class GenerateMixesForUserCommandHandler(
IDatabase database, IDatabase database,
MixConfiguration mixConfiguration, MixConfiguration mixConfiguration,
@@ -5,7 +5,9 @@ using Musify.Domain.Entities;
namespace Musify.Application.Mixes 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 internal static class MixItemMapper
{ {
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
@@ -4,6 +4,13 @@ using System.Text.Json.Serialization;
namespace Musify.Application.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 sealed class LongAsStringConverter : JsonConverter<long>
{ {
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
@@ -30,6 +30,8 @@ public sealed class TrackStreamIssuer(
folderPath, folderPath,
streamGatewayConfiguration.AudioFileName); streamGatewayConfiguration.AudioFileName);
// 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) if (userId != null)
{ {
var newListeningHistory = new ListeningHistory var newListeningHistory = new ListeningHistory
@@ -9,7 +9,8 @@ using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks 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) public record GetTrackStreamQuery(Guid TrackId, long? UserId)
: IQuery<ErrorOr<TrackStreamResponse>>; : IQuery<ErrorOr<TrackStreamResponse>>;
@@ -1,6 +1,6 @@
namespace Musify.Application.Tracks.Responses namespace Musify.Application.Tracks.Responses
{ {
/// <summary>A page of the user's catalog tracks (their own uploads).</summary>
public record TracksSearchResponse( public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items, IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber, int PageNumber,
@@ -1,6 +1,9 @@
namespace Musify.Domain.ValueObjects 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 public static class GenreCompatibility
{ {
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
@@ -28,6 +31,9 @@ namespace Musify.Domain.ValueObjects
public static bool AreCompatible(Genre a, Genre b) => public static bool AreCompatible(Genre a, Genre b) =>
a == b || !IncompatiblePairs.Contains((a, 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) public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
{ {
var distinctTags = tags.Distinct().ToList(); var distinctTags = tags.Distinct().ToList();
@@ -24,6 +24,11 @@ namespace Musify.Infrastructure.Persistence
if (context == null) if (context == null)
return; 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(); context.ChangeTracker.DetectChanges();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -37,7 +42,7 @@ namespace Musify.Infrastructure.Persistence
} }
else if (entry.State == EntityState.Modified) 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) public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase : DbContext, AppIDatabase
{ {
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -18,7 +18,10 @@ namespace Musify.Infrastructure.Persistence
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value); 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>()); serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260531234306_AddUserProfilePictureUrl")] [Migration("20260531234306_AddUserProfilePictureUrl")]
partial class AddUserProfilePictureUrl partial class AddUserProfilePictureUrl
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddUserProfilePictureUrl : Migration public partial class AddUserProfilePictureUrl : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true); nullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260601195757_RenameTimestampsAndAudit")] [Migration("20260601195757_RenameTimestampsAndAudit")]
partial class RenameTimestampsAndAudit partial class RenameTimestampsAndAudit
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RenameTimestampsAndAudit : Migration public partial class RenameTimestampsAndAudit : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -31,6 +31,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
newName: "CreatedAt"); newName: "CreatedAt");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705123932_AddTrackProcessingStateSaga")] [Migration("20260705123932_AddTrackProcessingStateSaga")]
partial class AddTrackProcessingStateSaga partial class AddTrackProcessingStateSaga
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackProcessingStateSaga : Migration public partial class AddTrackProcessingStateSaga : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -27,6 +27,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
}); });
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705124807_AddTrackProcessingSagaKeys")] [Migration("20260705124807_AddTrackProcessingSagaKeys")]
partial class AddTrackProcessingSagaKeys partial class AddTrackProcessingSagaKeys
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackProcessingSagaKeys : Migration public partial class AddTrackProcessingSagaKeys : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -29,6 +29,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true); nullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705125356_AddPlayListProcessingStateSaga")] [Migration("20260705125356_AddPlayListProcessingStateSaga")]
partial class AddPlayListProcessingStateSaga partial class AddPlayListProcessingStateSaga
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddPlayListProcessingStateSaga : Migration public partial class AddPlayListProcessingStateSaga : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -27,6 +27,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
}); });
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260713234847_AddListeningHistory")] [Migration("20260713234847_AddListeningHistory")]
partial class AddListeningHistory partial class AddListeningHistory
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddListeningHistory : Migration public partial class AddListeningHistory : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -47,6 +47,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "UserId"); column: "UserId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260714114537_AddTrackDuration")] [Migration("20260714114537_AddTrackDuration")]
partial class AddTrackDuration partial class AddTrackDuration
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackDuration : Migration public partial class AddTrackDuration : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: 0); defaultValue: 0);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260715193951_AddTrackSourceArtistExternalId")] [Migration("20260715193951_AddTrackSourceArtistExternalId")]
partial class AddTrackSourceArtistExternalId partial class AddTrackSourceArtistExternalId
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackSourceArtistExternalId : Migration public partial class AddTrackSourceArtistExternalId : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -41,6 +41,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
filter: "\"ExternalId\" <> ''"); filter: "\"ExternalId\" <> ''");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260715230836_AddTrackDownloadRequested")] [Migration("20260715230836_AddTrackDownloadRequested")]
partial class AddTrackDownloadRequested partial class AddTrackDownloadRequested
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackDownloadRequested : Migration public partial class AddTrackDownloadRequested : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: false); defaultValue: false);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716214442_MakeTrackMediaAndExternalIdNullable")] [Migration("20260716214442_MakeTrackMediaAndExternalIdNullable")]
partial class MakeTrackMediaAndExternalIdNullable partial class MakeTrackMediaAndExternalIdNullable
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MakeTrackMediaAndExternalIdNullable : Migration public partial class MakeTrackMediaAndExternalIdNullable : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -66,6 +66,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716214752_AddArtistsAndTrackOwnership")] [Migration("20260716214752_AddArtistsAndTrackOwnership")]
partial class AddArtistsAndTrackOwnership partial class AddArtistsAndTrackOwnership
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddArtistsAndTrackOwnership : Migration public partial class AddArtistsAndTrackOwnership : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<long>( migrationBuilder.AddColumn<long>(
@@ -154,6 +154,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716220047_DropTrackArtistColumn")] [Migration("20260716220047_DropTrackArtistColumn")]
partial class DropTrackArtistColumn partial class DropTrackArtistColumn
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class DropTrackArtistColumn : Migration public partial class DropTrackArtistColumn : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
table: "Tracks"); table: "Tracks");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717164632_MovePlayListPicturesToOwnedType")] [Migration("20260717164632_MovePlayListPicturesToOwnedType")]
partial class MovePlayListPicturesToOwnedType partial class MovePlayListPicturesToOwnedType
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,15 +4,16 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MovePlayListPicturesToOwnedType : Migration public partial class MovePlayListPicturesToOwnedType : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717164812_RenameTrackDurationToDurationSeconds")] [Migration("20260717164812_RenameTrackDurationToDurationSeconds")]
partial class RenameTrackDurationToDurationSeconds partial class RenameTrackDurationToDurationSeconds
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RenameTrackDurationToDurationSeconds : Migration public partial class RenameTrackDurationToDurationSeconds : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -16,6 +16,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
newName: "DurationSeconds"); newName: "DurationSeconds");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717172507_SplitTrackIntoLocalAndExternal")] [Migration("20260717172507_SplitTrackIntoLocalAndExternal")]
partial class SplitTrackIntoLocalAndExternal partial class SplitTrackIntoLocalAndExternal
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class SplitTrackIntoLocalAndExternal : Migration public partial class SplitTrackIntoLocalAndExternal : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -156,6 +156,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldNullable: true); oldNullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717223358_MakePlayListPicturesNullable")] [Migration("20260717223358_MakePlayListPicturesNullable")]
partial class MakePlayListPicturesNullable partial class MakePlayListPicturesNullable
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MakePlayListPicturesNullable : Migration public partial class MakePlayListPicturesNullable : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -51,6 +51,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldMaxLength: 64); oldMaxLength: 64);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720180629_WidenTrackTitle")] [Migration("20260720180629_WidenTrackTitle")]
partial class WidenTrackTitle partial class WidenTrackTitle
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class WidenTrackTitle : Migration public partial class WidenTrackTitle : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -31,6 +31,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldMaxLength: 50); oldMaxLength: 50);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720180747_AddAlbums")] [Migration("20260720180747_AddAlbums")]
partial class AddAlbums partial class AddAlbums
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddAlbums : Migration public partial class AddAlbums : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -100,6 +100,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "OwnerUserId"); column: "OwnerUserId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720184656_AddUserAlbumExternalId")] [Migration("20260720184656_AddUserAlbumExternalId")]
partial class AddUserAlbumExternalId partial class AddUserAlbumExternalId
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddUserAlbumExternalId : Migration public partial class AddUserAlbumExternalId : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -28,6 +28,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721155102_AddMixes")] [Migration("20260721155102_AddMixes")]
partial class AddMixes partial class AddMixes
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddMixes : Migration public partial class AddMixes : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -83,6 +83,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "TrackId"); column: "TrackId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721161653_RemoveUserAlbumExternalId")] [Migration("20260721161653_RemoveUserAlbumExternalId")]
partial class RemoveUserAlbumExternalId partial class RemoveUserAlbumExternalId
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RemoveUserAlbumExternalId : Migration public partial class RemoveUserAlbumExternalId : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -24,6 +24,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "OwnerUserId"); column: "OwnerUserId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721162102_DropMixGeneratedOn")] [Migration("20260721162102_DropMixGeneratedOn")]
partial class DropMixGeneratedOn partial class DropMixGeneratedOn
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class DropMixGeneratedOn : Migration public partial class DropMixGeneratedOn : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
table: "Mixes"); table: "Mixes");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<DateOnly>( migrationBuilder.AddColumn<DateOnly>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721190735_MakePlayListDescriptionOptional")] [Migration("20260721190735_MakePlayListDescriptionOptional")]
partial class MakePlayListDescriptionOptional partial class MakePlayListDescriptionOptional
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MakePlayListDescriptionOptional : Migration public partial class MakePlayListDescriptionOptional : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -27,6 +27,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
"""); """);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724170051_DurationAsDouble")] [Migration("20260724170051_DurationAsDouble")]
partial class DurationAsDouble partial class DurationAsDouble
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,15 +4,16 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class DurationAsDouble : Migration public partial class DurationAsDouble : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724181012_AddExternalAlbums")] [Migration("20260724181012_AddExternalAlbums")]
partial class AddExternalAlbums partial class AddExternalAlbums
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddExternalAlbums : Migration public partial class AddExternalAlbums : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -36,6 +36,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724182659_AddExternalAlbumThumbnail")] [Migration("20260724182659_AddExternalAlbumThumbnail")]
partial class AddExternalAlbumThumbnail partial class AddExternalAlbumThumbnail
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddExternalAlbumThumbnail : Migration public partial class AddExternalAlbumThumbnail : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true); nullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260910125022_AddAlbumProcessingStateSaga")] [Migration("20260910125022_AddAlbumProcessingStateSaga")]
partial class AddAlbumProcessingStateSaga partial class AddAlbumProcessingStateSaga
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddAlbumProcessingStateSaga : Migration public partial class AddAlbumProcessingStateSaga : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -28,6 +28,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
}); });
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260914144913_AddPlayListVisibility")] [Migration("20260914144913_AddPlayListVisibility")]
partial class AddPlayListVisibility partial class AddPlayListVisibility
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddPlayListVisibility : Migration public partial class AddPlayListVisibility : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
@@ -18,6 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: 0); defaultValue: 0);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260914150045_AddTrackLikes")] [Migration("20260914150045_AddTrackLikes")]
partial class AddTrackLikes partial class AddTrackLikes
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackLikes : Migration public partial class AddTrackLikes : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -49,6 +49,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260914151900_AddUserFollows")] [Migration("20260914151900_AddUserFollows")]
partial class AddUserFollows partial class AddUserFollows
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddUserFollows : Migration public partial class AddUserFollows : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -49,6 +49,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260915165436_RemoveYouTubeIntegration")] [Migration("20260915165436_RemoveYouTubeIntegration")]
partial class RemoveYouTubeIntegration partial class RemoveYouTubeIntegration
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,16 +5,25 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RemoveYouTubeIntegration : Migration public partial class RemoveYouTubeIntegration : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
// Data cleanup first, while the External*/Local*/UserAlbums tables (and the loose
// MixItem columns) still exist to query/cascade from. Deleting the base Tracks/Albums
// rows for anything that only ever existed as an ExternalTrack/ExternalAlbum cascades
// (FKs are ON DELETE CASCADE) to their own ExternalTracks/ExternalAlbums row, TrackArtist,
// PlayListHasTrack, AlbumHasTrack, TrackLike, ListeningHistory, UserHasTrack and MixItems
// rows that referenced them — this is deliberately lossy: those rows only existed to back
// the YouTube integration being removed here.
migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");"""); migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");""");
migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");"""); migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");""");
// Live YouTube search hits that were seeded into a mix but never materialized into a
// real track (MixItemSource.YouTube, no TrackId) — MixItem.TrackId is about to become
// required, so these rows can't be kept.
migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;"""); migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;""");
migrationBuilder.AddColumn<long>( migrationBuilder.AddColumn<long>(
@@ -123,6 +132,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Restrict); onDelete: ReferentialAction.Restrict);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -16,7 +16,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260916153700_AddTrackTags")] [Migration("20260916153700_AddTrackTags")]
partial class AddTrackTags partial class AddTrackTags
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -6,10 +6,10 @@ using Musify.Domain.ValueObjects;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackTags : Migration public partial class AddTrackTags : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterDatabase() migrationBuilder.AlterDatabase()
@@ -41,6 +41,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
 // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -41,8 +41,13 @@ public sealed class JwtBearerEventsHandler(
if (cache.TryGetValue(cacheKey, out _)) if (cache.TryGetValue(cacheKey, out _))
return; return;
// Access tokens from the IdP are minimal (just sub/scope/aud) — profile attributes live on
// the userinfo endpoint, so ask for them there instead of relying on token claims alone.
var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted); var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted);
// Zitadel also issues short-form JWT claim names ("name", "given_name", "family_name", "email"),
// but the newer JsonWebTokenHandler (default since .NET 8) no longer auto-maps those to the
// legacy ClaimTypes.* URIs the way JwtSecurityTokenHandler used to — so check both forms too.
var username = userInfo?.Name var username = userInfo?.Name
?? userInfo?.PreferredUsername ?? userInfo?.PreferredUsername
?? principal.FindFirstValue("name") ?? principal.FindFirstValue("name")
@@ -39,6 +39,13 @@ public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> op
return Task.CompletedTask; return Task.CompletedTask;
} ); } );
// Properties annotated with [JsonConverter(typeof(LongAsStringConverter))] (id-shaped
// `long` fields — see that converter for why) serialize as a JSON string, but the
// schema generator still reflects the declared CLR `long`/`long?` type as a bare
// int64 number. Reconcile the declared schema so generated clients (openapi-typescript)
// type exactly those properties as `string`, matching what actually goes over the wire —
// scoped to the attribute, not the CLR type, so unrelated `long` fields (byte sizes, …)
// are left alone.
options.AddSchemaTransformer((schema, context, _) => options.AddSchemaTransformer((schema, context, _) =>
{ {
if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter)
+2
View File
@@ -48,4 +48,6 @@ app
app.Run(); app.Run();
// Makes the top-level-statements Program class public so
// WebApplicationFactory<Program> (Musify.Api.Tests) can see it.
public partial class Program; public partial class Program;
+6
View File
@@ -0,0 +1,6 @@
@WebApi_HostAddress = http://localhost:5111
GET {{WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -6,6 +6,10 @@ using Musify.StreamingGateway.Configuration;
namespace Musify.StreamingGateway.Authentication; namespace Musify.StreamingGateway.Authentication;
/// <param name="Prefix">The storage key prefix the ticket authorizes reading from. Empty means the
/// ticket was missing, malformed, expired, or otherwise invalid.</param>
/// <param name="MaxBytes">When set, the caller is limited to the first <c>MaxBytes</c> bytes of the
/// object — the anonymous preview fragment cap carried by the <c>maxBytes</c> claim.</param>
public sealed record TicketValidationResult(string Prefix, long? MaxBytes) public sealed record TicketValidationResult(string Prefix, long? MaxBytes)
{ {
public static readonly TicketValidationResult Invalid = new(string.Empty, null); public static readonly TicketValidationResult Invalid = new(string.Empty, null);
@@ -72,6 +72,12 @@ public sealed class TicketValidationMiddleware(
await next(context); await next(context);
} }
/// <summary>Rewrites the request's <c>Range</c> header so nothing past <paramref name="maxBytes"/>
/// is ever asked of the filer — this is what actually enforces the anonymous preview length,
/// downstream of the ticket carrying that cap. A request with no <c>Range</c> header (a plain,
/// whole-file GET) gets one synthesized, so the filer always responds with a bounded 206 instead of
/// streaming the full object. Returns false when the requested range starts at or past the cap, in
/// which case the caller should answer 416 directly instead of proxying anything.</summary>
private static bool TryClampRange(HttpContext context, long maxBytes) private static bool TryClampRange(HttpContext context, long maxBytes)
{ {
var rangeHeader = context.Request.Headers.Range; var rangeHeader = context.Request.Headers.Range;
+5
View File
@@ -7,6 +7,11 @@ using Musify.Infrastructure.Services;
var builder = Host.CreateApplicationBuilder(); var builder = Host.CreateApplicationBuilder();
// Registering Mediator pulls in every handler in Musify.Application, including ones
// (e.g. CreatePlayList, CreateTrack) that depend on IEventBus, which the Worker never
// registers because it only consumes events, it never publishes them. Only the handlers
// this process actually dispatches (GenerateMixesForUserCommand) are ever constructed,
// so the default eager validate-on-build check is too strict for this host.
builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions
{ {
ValidateOnBuild = false, ValidateOnBuild = false,
+4
View File
@@ -12,6 +12,10 @@ RUN dotnet restore Hosts/Musify.Api/Api.csproj
COPY . . COPY . .
RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore
# Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host
# looks for appsettings*.json. Add lowercase copies so they get loaded.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
+3
View File
@@ -11,6 +11,9 @@ RUN dotnet restore Hosts/Musify.Worker/Worker.csproj
COPY . . COPY . .
RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore
# AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
@@ -44,11 +43,6 @@ namespace Musify.Infrastructure.Tests.Persistence
Assert.Equal(originalCreatedAt, user.CreatedAt); Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt, Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
await using var reader = fixture.CreateDatabase();
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
Assert.True(stored.UpdatedAt > stored.CreatedAt,
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
} }
} }
} }
+24 -29
View File
@@ -1,39 +1,39 @@
# Development configuration for deploy/compose.yml + compose.dev.yml. # Development configuration for deploy/compose.yml + compose.dev.yml.
# Copy to deploy/.env and adjust. # Copy to deploy/.env and adjust. Not versioned.
# #
# cp deploy/.env.example deploy/.env # cp deploy/.env.example deploy/.env
#
# Requires ../../../Infrastructure running first (Postgres, Zitadel,
# SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md.
# Everything marked "must match Infrastructure's .env" below has to be the
# same value as the one in that file.
DOTNET_ENVIRONMENT=Development DOTNET_ENVIRONMENT=Development
# --- Public URLs ------------------------------------------------------------- # --- Public URLs -------------------------------------------------------------
# How each service is reached from *outside* Docker. `host.docker.internal` # How each service is reached from *outside* Docker. `host.docker.internal`
# resolves to the same address from the browser, from apps running on the # resolves to the same address from the browser, from apps running on the host
# host and from inside the containers, which is what makes the OIDC issuer # and from inside the containers, which is what makes the OIDC issuer and the
# and the presigned S3 URLs valid in both run modes. # presigned S3 URLs valid in both run modes.
PUBLIC_WEB_URL=http://localhost:3000 PUBLIC_WEB_URL=http://localhost:3000
PUBLIC_API_URL=http://localhost:5111 PUBLIC_API_URL=http://localhost:5111
PUBLIC_STREAM_URL=http://localhost:8081 PUBLIC_STREAM_URL=http://localhost:8081
PUBLIC_AUTH_URL=http://host.docker.internal:8080 PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (shared by Musify and Zitadel) -------------------------------
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.
POSTGRES_HOST=host.docker.internal
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
POSTGRES_HOST_PORT=59000
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC) ----------------------------------------------------------
# Path to the admin service-account PAT Infrastructure's Zitadel writes on # The masterkey MUST be exactly 32 characters. The external domain/port form
# init. Adjust if your Infrastructure checkout lives somewhere else relative # the token issuer, so they have to match PUBLIC_AUTH_URL. Changing them needs
# to this one. # a re-init: docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
# Allows the OIDC apps this project creates to use plain-http redirect URIs ZITADEL_EXTERNAL_DOMAIN=host.docker.internal
# (dev only). ZITADEL_EXTERNAL_PORT=8080
ZITADEL_EXTERNAL_SECURE=false
ZITADEL_DATABASE_PASSWORD=zitadel
# Console login: <username>@zitadel.<external domain>
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=Password1!
# Allows the OIDC apps to use plain-http redirect URIs (dev only).
ZITADEL_DEV_MODE=true ZITADEL_DEV_MODE=true
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -42,25 +42,17 @@ AUTH_CLIENT_ID=
WEB_CLIENT_ID= WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (MassTransit) --------------------------------------------------
# Must match Infrastructure's .env.
RABBITMQ_HOST=host.docker.internal
RABBITMQ_DEFAULT_USER=guest RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest RABBITMQ_DEFAULT_PASS=guest
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3) ----------------------------------------------------------
# S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env.
SEAWEEDFS_HOST=host.docker.internal
S3_ACCESS_KEY=admin_access_key S3_ACCESS_KEY=admin_access_key
S3_SECRET_KEY=admin_secret_key S3_SECRET_KEY=admin_secret_key
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_PUBLIC_URL=http://host.docker.internal:8333 S3_PUBLIC_URL=http://host.docker.internal:8333
S3_INTERNAL_URL=http://host.docker.internal:8333
S3_USE_HTTP=true S3_USE_HTTP=true
# --- Jaeger (shared instance, tracing) ---------------------------------------
JAEGER_HOST=host.docker.internal
# --- Web player -------------------------------------------------------------- # --- Web player --------------------------------------------------------------
# Key material for the encrypted session cookie: any long random string. # Key material for the encrypted session cookie: any long random string.
SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef
@@ -69,3 +61,6 @@ AUTH_ALLOW_INSECURE_HTTP=true
# --- Misc -------------------------------------------------------------------- # --- Misc --------------------------------------------------------------------
AUTH_REQUIRE_HTTPS=false AUTH_REQUIRE_HTTPS=false
PGADMIN_DEFAULT_EMAIL=admin@musify.dev
PGADMIN_DEFAULT_PASSWORD=admin
PGADMIN_HOST_PORT=5050
+28 -32
View File
@@ -1,41 +1,41 @@
# Production configuration for deploy/compose.yml + compose.prod.yml. # Production configuration for deploy/compose.yml + compose.prod.yml.
# Copy to deploy/.env.prod, then: # Copy to deploy/.env.prod, then:
# #
# 1. replace every `*.example.com` with your actual domains (each service # 1. replace every `example.com` with your domain,
# below has its own, independent domain — they don't need to share a
# root, e.g. for DNS providers that don't allow multi-level
# subdomains),
# 2. replace every CHANGE_ME with a strong random value # 2. replace every CHANGE_ME with a strong random value
# (`openssl rand -hex 24`), # (`openssl rand -hex 24`; the Zitadel masterkey needs exactly 32 chars),
# 3. point each domain below at the server and add its vhost to the shared # 3. point these DNS records at the server, all proxied/TLS-terminated the
# edge (Infrastructure's edge/conf.d). # same way: example.com, www, api, auth, stream, s3.
# #
# Requires ../../../Infrastructure running first, on the same Docker host, # Not versioned. Keep it readable only by the deploy user (chmod 600).
# with its own `infra-net` network already created. See
# ../../../Infrastructure/README.md. Not versioned. Keep it readable only by
# the deploy user (chmod 600).
DOTNET_ENVIRONMENT=Production DOTNET_ENVIRONMENT=Production
# --- Domains ------------------------------------------------------------- # --- Domain ------------------------------------------------------------------
# Public URLs. They must match the vhosts of the shared edge nginx # ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it.
# (Infrastructure's edge/conf.d), which is what serves them; the domains are ROOT_DOMAIN=example.com
# independent, they don't need to share a root. PUBLIC_WEB_URL=https://example.com
PUBLIC_WEB_URL=https://musify.example.com PUBLIC_API_URL=https://api.example.com
PUBLIC_API_URL=https://musifyapi.example.com PUBLIC_STREAM_URL=https://stream.example.com
PUBLIC_STREAM_URL=https://musifystream.example.com
PUBLIC_AUTH_URL=https://auth.example.com PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com S3_PUBLIC_URL=https://s3.example.com
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (shared by Musify and Zitadel) -------------------------------
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod.
POSTGRES_HOST=postgres
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC) ----------------------------------------------------------
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output # The masterkey MUST be exactly 32 characters and can never be rotated in place.
ZITADEL_MASTERKEY=CHANGE_ME_32_CHARACTERS_EXACTLY_
ZITADEL_EXTERNAL_DOMAIN=auth.example.com
ZITADEL_EXTERNAL_PORT=443
ZITADEL_EXTERNAL_SECURE=true
ZITADEL_DATABASE_PASSWORD=CHANGE_ME
# Console login: <username>@zitadel.<external domain>
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=CHANGE_ME
# Keep OIDC apps strict about https redirect URIs in production.
ZITADEL_DEV_MODE=false ZITADEL_DEV_MODE=false
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -44,26 +44,22 @@ AUTH_CLIENT_ID=
WEB_CLIENT_ID= WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (MassTransit) --------------------------------------------------
RABBITMQ_HOST=rabbitmq
RABBITMQ_DEFAULT_USER=musify RABBITMQ_DEFAULT_USER=musify
RABBITMQ_DEFAULT_PASS=CHANGE_ME RABBITMQ_DEFAULT_PASS=CHANGE_ME
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3) ----------------------------------------------------------
SEAWEEDFS_HOST=seaweedfs
S3_ACCESS_KEY=CHANGE_ME S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_INTERNAL_URL=http://seaweedfs:8333
S3_USE_HTTP=false S3_USE_HTTP=false
# --- Jaeger (shared instance, tracing) ---------------------------------------
JAEGER_HOST=jaeger
# --- Web player -------------------------------------------------------------- # --- Web player --------------------------------------------------------------
# Key material for the encrypted session cookie. Rotating it logs everyone out. # Key material for the encrypted session cookie. Rotating it logs everyone out.
SESSION_SECRET=CHANGE_ME SESSION_SECRET=CHANGE_ME
AUTH_ALLOW_INSECURE_HTTP=false AUTH_ALLOW_INSECURE_HTTP=false
# --- Misc -------------------------------------------------------------------- # --- Misc --------------------------------------------------------------------
AUTH_REQUIRE_HTTPS=true AUTH_REQUIRE_HTTPS=true
# Largest request body nginx accepts (audio uploads go through the web player).
MAX_UPLOAD_SIZE=1024m
+87 -121
View File
@@ -1,65 +1,51 @@
# Deploy # Deploy
Everything needed to run Musify itself, in development and in production, Everything needed to run Musify, in development and in production, from the
from the same set of compose files, no wrapper scripts, just `docker same set of compose files — no wrapper scripts, just `docker compose`.
compose`. Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are **not**
started here: they're shared infrastructure that lives in the separate
`Infrastructure` repository and has to already be running (see its own
README). `../../Infrastructure` below is just an example path, assuming
that repo happens to be checked out as a sibling of this one. Adjust it to
wherever you actually cloned it.
| File | Role | | File | Role |
|---|---| |---|---|
| `compose.yml` | Musify's own services, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. | | `compose.yml` | Every service, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. |
| `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. | | `compose.dev.yml` | Development overlay: publishes the ports to the host, adds pgAdmin. |
| `compose.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. | | `compose.prod.yml` | Production overlay: adds the nginx edge. |
| `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. |
The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind the
the `apps` compose profile, so a plain `up` starts only the one-shot `apps` compose profile, so a plain `up` starts infrastructure only — the usual
bootstrap jobs, the usual development setup, with the apps running from the development setup, with the apps running from the IDE.
IDE.
### The one-shot bootstrap jobs ### The one-shot bootstrap jobs
Four services in `compose.yml` run once and exit, so a plain `docker compose Three services in `compose.yml` run once and exit, so a plain `docker compose
up -d` leaves the stack fully ready with no manual steps: up -d` leaves the stack fully ready with no manual steps:
| Service | Does | | Service | Does |
|---|---| |---|---|
| `wait-for-postgres` | Waits for Infrastructure's Postgres to accept connections. Replaces an in-file health-check `depends_on`, which only works within the same compose file. |
| `keys-init` | Generates the RS256 stream-ticket key pair in `./keys`, if it is not there already. | | `keys-init` | Generates the RS256 stream-ticket key pair in `./keys`, if it is not there already. |
| `migrate` | Applies the EF Core migrations (built from `backend/Musify.Migrator.Dockerfile`). | | `migrate` | Applies the EF Core migrations (built from `backend/Musify.Migrator.Dockerfile`). |
| `seaweedfs-init` | Creates Musify's own bucket on the shared SeaweedFS, if it doesn't exist yet. | | `zitadel-init` | Creates the Musify project and its two OIDC apps in Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` into the env file. |
| `zitadel-init` | Creates the Musify project and its OIDC apps in the shared Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` / `NATIVE_CLIENT_ID` into the env file. |
`api` and `worker` wait for `migrate`; `api` waits for `keys-init` `api` and `worker` wait for `migrate`; `api` and `gateway` wait for
(`depends_on: condition: service_completed_successfully`). Nothing waits on `keys-init` (`depends_on: condition: service_completed_successfully`). They do
`zitadel-init`, `seaweedfs-init` or the shared infrastructure services **not** wait for `zitadel-init` — compose interpolates `${AUTH_CLIENT_ID}` once,
directly. Compose interpolates `${AUTH_CLIENT_ID}` once, when a command when a command starts, so a container that starts in the *same* `up` as
starts, so a container that started in the *same* `up` as `zitadel-init` `zitadel-init` would still get the empty value it had before provisioning ran.
would still get the empty value it had before provisioning ran. That's why That's why bringing up the apps is a second command: by the time you run it,
bringing up the apps is a second command: by the time you run it,
`zitadel-init` has already finished and the env file has real values. `zitadel-init` has already finished and the env file has real values.
--- ---
## Development ## Development
Start the separate `Infrastructure` repository first, from wherever you
cloned it (see its own README for the exact command). Then, from this
repository's root:
```sh ```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d 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 docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
``` ```
The first command starts Musify's own bootstrap jobs (Postgres wait, keys, The first command starts the infrastructure and runs the bootstrap jobs
migrations, the bucket, the Zitadel project/apps). The second builds and runs (keys, migrations, Zitadel project/apps). The second builds and runs `api`,
`api`, `worker`, `gateway` and `web-player` in Docker too. Skip it to run the `worker`, `gateway` and `web-player` in Docker too — skip it to run the apps
apps from the IDE instead (the usual dev loop): from the IDE instead (the usual dev loop):
```sh ```sh
dotnet run --project backend/Hosts/Musify.Api # :5111 dotnet run --project backend/Hosts/Musify.Api # :5111
@@ -69,117 +55,86 @@ npm --prefix web-player run dev # :5173
``` ```
When running the apps from the IDE, `Musify.Api` reads `Authentication:*` When running the apps from the IDE, `Musify.Api` reads `Authentication:*`
from user-secrets and `web-player` reads its own `web-player/.env`, neither from user-secrets and `web-player` reads its own `web-player/.env` — neither
looks at `deploy/.env`. Copy the four values `zitadel-init` wrote looks at `deploy/.env`. Copy the three values `zitadel-init` wrote
(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`, `NATIVE_CLIENT_ID`) (`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`) into
into `dotnet user-secrets set Authentication:ClientId <value> --project `dotnet user-secrets set Authentication:ClientId <value> --project
backend/Hosts/Musify.Api` and into `web-player/.env` once. They stay valid backend/Hosts/Musify.Api` and into `web-player/.env` once; they stay valid
until Infrastructure's Zitadel volume is wiped. until you wipe the Zitadel volume.
Add `--profile tools` to also start pgAdmin.
### Ports ### Ports
| Service | Port | Used by | | Service | Port | Used by |
|---|---|---| |---|---|---|
| Musify.Api | `5111` | `/scalar/v1`, `/openapi/v1.json` | | PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and Zitadel |
| Musify.StreamingGateway | `8081` | `/health`, `/media/...` | | Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` |
| web-player | `5173` (`npm run dev`) or `3000` (container) | | | RabbitMQ | `5672` / `15672` | MassTransit / management UI |
| SeaweedFS | `8333` / `8888` / `9333` | S3 / filer (→ gateway) / master |
| Jaeger | `16686` / `4317` / `4318` | traces UI / OTLP gRPC / OTLP HTTP |
| pgAdmin (`--profile tools`) | `5050` | Postgres UI |
| API, gateway, web player (`--profile apps`) | `5111` / `8081` / `3000` | |
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger ports come from Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg is only
Infrastructure. See its own README for that table. needed on the host if you run the Worker outside Docker — inside Docker its
image already has it.)
Host requirements: .NET 10 SDK, Node 22, Docker Desktop. ffmpeg is only
needed on the host if you run the Worker outside Docker; inside Docker its
image already has it.
### Why `host.docker.internal` ### Why `host.docker.internal`
`PUBLIC_AUTH_URL` and `S3_PUBLIC_URL` point at `host.docker.internal`, which `PUBLIC_AUTH_URL` and `S3_PUBLIC_URL` point at `host.docker.internal`, which
resolves to the same address from the browser, from apps running on the host resolves to the same address from the browser, from apps running on the host
and from inside the containers. That keeps the OIDC token issuer and the and from inside the containers. That keeps the OIDC token issuer and the
presigned S3 URLs valid whichever way the apps are running, and is the same presigned S3 URLs valid whichever way the apps are running. Zitadel routes by
address Infrastructure's own dev ports are published on. the `Host` header, so the domain has to be identical everywhere; changing it
requires wiping the Postgres/Zitadel volumes and starting over (`docker
compose -f compose.yml -f compose.dev.yml down -v`, then `up -d` again).
### Resetting ### Resetting
```sh ```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down # stop, keep data docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down # stop, keep data
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe Musify's own volumes docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe volumes
``` ```
This only touches Musify's own containers and volumes. Resetting Zitadel or
SeaweedFS is done from the `Infrastructure` repo instead.
--- ---
## Production ## Production
Musify doesn't run its own nginx: the shared edge in Infrastructure On a server with a domain, from the repository root:
(`edge/`) owns host ports 80/443, terminates TLS for every project's domains
and proxies to Musify's containers over `infra-net`. On a server with
domains:
1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled
in. It creates the `infra-net` network this stack joins.
2. Bring up its `edge/` (see Infrastructure's README) and add Musify's
vhosts and certificate there (`edge/conf.d/musify.conf`). The five
public hostnames (web, api, stream, auth, s3) are independent: they
don't need to share a root domain.
3. From this repository's root:
```sh ```sh
cp deploy/.env.prod.example deploy/.env.prod cp deploy/.env.prod.example deploy/.env.prod
``` ```
Replace every `*.example.com` with your domains (they must match the Fill it in:
edge's vhosts) and every `CHANGE_ME` with a random value
(`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
Then: 1. Replace every `example.com` with your domain and every `CHANGE_ME` with a
random value (`openssl rand -hex 24`; the Zitadel masterkey needs exactly
32 characters). `chmod 600 deploy/.env.prod`.
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`** — the origin
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
mode in front of it, so a Cloudflare Origin Certificate is enough; any
certificate valid for the five hostnames works.
3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server,
proxied/TLS-terminated the same way.
Then, same two commands as development, with the prod env file and overlay:
```sh ```sh
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 up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
``` ```
The first command starts Musify's bootstrap jobs (`zitadel-init` and The first command starts the infrastructure, the nginx edge and the bootstrap
`seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.` jobs; the second builds and starts the applications, already wired to the
URLs, so the edge has to be serving them by then); the second builds and OIDC client ids the first one produced. Both are idempotent — run them again
starts the applications, already wired to the OIDC client ids the first one to deploy a new version (`docker compose ... --profile apps up -d --build`
produced. Both are idempotent, so running them again deploys a new version alone is enough for an update once the stack already exists; it reuses the
(`... --profile apps up -d --build` alone is enough for an update once the infra containers and only rebuilds and restarts the apps).
stack already exists).
Nothing in this stack publishes a port; everything is reachable only on the Only nginx publishes ports (80/443); everything else is reachable only on the
internal `infra-net` network, through the edge. internal compose network.
### Continuous deployment
`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every
push/PR, and on a successful push to `master` deploys automatically. The
deploy job runs on `gitea-runner`, the self-hosted Gitea Actions runner on
the production host (`~/gitea/docker-compose.yml`), configured by
`~/gitea/runner-data/config.yaml` (`CONFIG_FILE`): job containers are pinned
to the `gitea_gitea` network so `actions/checkout` can resolve `gitea`,
`ubuntu-latest` is `node:22` (JS actions need Node inside the job
container), and a `production:host` label runs steps natively.
Native mode means the label's steps execute inside the runner container
itself rather than a fresh one. That container has `/var/run/docker.sock`
mounted, so `docker`/`docker compose` there still control the host's real
Docker daemon (sibling containers, "Docker outside of Docker"). Its compose
service also bind-mounts `/home/ubuntu/deploys` into itself at the *same*
path, which matters because compose's own relative bind mounts (`./keys`)
are resolved by the host daemon against that literal path; mounting it
anywhere else would silently bind the wrong (empty) directory.
`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`)
is a persistent clone on that host, reused instead of a fresh checkout each
run since `deploy/.env.prod` is untracked and only lives there.
`/home/ubuntu/deploys/SharedServices` is the sibling clone of the
`Infrastructure` repo (shared services + `edge/`), already running; its
`.env.prod` shares the Postgres/RabbitMQ/S3 credentials with Musify's own
`deploy/.env.prod` by design (see that repo's README). The edge is deployed
from that repo, not from this pipeline.
--- ---
@@ -187,12 +142,17 @@ from that repo, not from this pipeline.
``` ```
deploy/ deploy/
├─ compose.yml # Musify's own services: wait-for-postgres, keys-init, ├─ compose.yml # all services, incl. keys-init / migrate / zitadel-init
│ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player ├─ compose.dev.yml # dev overlay: host ports, pgAdmin
├─ compose.dev.yml # dev overlay: host ports ├─ compose.prod.yml # prod overlay: nginx edge
├─ compose.prod.yml # prod overlay: joins infra-net
├─ .env.example / .env.prod.example ├─ .env.example / .env.prod.example
└─ keys/ # RS256 stream-ticket key pair (generated) ├─ keys/ # RS256 stream-ticket key pair (generated)
├─ nginx/
│ ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst
│ ├─ snippets/proxy.conf # proxy headers shared by every vhost
│ └─ certs/ # origin.pem + origin.key (not versioned)
└─ zitadel/
└─ .output/ # PAT written by Zitadel on init (not versioned)
``` ```
The application images are built from `backend/Musify.*.Dockerfile` (build The application images are built from `backend/Musify.*.Dockerfile` (build
@@ -201,12 +161,18 @@ and `web-player/Dockerfile`.
## Notes ## Notes
- **S3 credentials, the Postgres admin user, the RabbitMQ user and the - **S3 credentials** are rendered into the SeaweedFS identity file from
Zitadel masterkey** all live in Infrastructure's own `.env`/`.env.prod` now. `S3_ACCESS_KEY` / `S3_SECRET_KEY` when the container starts, so they cannot
This project's env files only duplicate the values that have to match (see drift from the ones the apps are given.
the comments in `.env.example`).
- **Zitadel** runs `start-from-init`, which is idempotent: the same command - **Zitadel** runs `start-from-init`, which is idempotent: the same command
works on an empty volume and on an existing one. works on an empty volume and on an existing one.
- **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they - **`latest` Zitadel images** require the standalone Login UI v2 container,
reuse the existing project/apps/bucket by name, and `migrate` is a normal which this stack does not run. `zitadel-init` turns that requirement off so
`dotnet ef database update`, a no-op once the schema is current. the built-in `/ui/login` is used.
- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`;
on case-sensitive Linux the host looks for `appsettings*.json`, so their
Dockerfiles create lowercase copies at publish time.
- **Re-running `zitadel-init` or `migrate`** is safe: the former reuses the
existing project/apps by name (regenerating the web app's secret only if it
is missing from the env file), the latter is a normal `dotnet ef database
update`, a no-op once the schema is current.

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