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
889 changed files with 19018 additions and 39456 deletions
-6
View File
@@ -14,12 +14,6 @@
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
"port": 5111
},
{
"name": "musify-worker",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Worker"],
"port": 5112
},
{
"name": "musify-streaming-gateway",
"runtimeExecutable": "dotnet",
-127
View File
@@ -1,127 +0,0 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+1 -3
View File
@@ -7,9 +7,6 @@ obj/
*.userosscache
.vs/
artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS
.idea/
@@ -28,4 +25,5 @@ Thumbs.db
# Deploy secrets and runtime state
deploy/keys/
deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/
+49 -47
View File
@@ -1,55 +1,57 @@
# Musify
A music streaming app. Upload your own tracks, organize them into playlists
and albums, and stream them back with a web player built around your
listening habits: auto-generated mixes, a home feed, search, likes, and an
Explore page for finding something new.
## What it does
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
- **Playlists**, likes, and a "recently played" history.
- **Mixes**, playlists generated automatically from what you actually listen to.
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
- **Accounts via OIDC login**, with an optional anonymous listening mode.
- Light and dark themes, and a layout that works on both desktop and mobile.
## How it's built
**Backend**: .NET 10, split into a REST API, a background worker, and a
dedicated streaming gateway.
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
- Zitadel for login (OIDC/OAuth2).
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
The session lives on the server, so the backend access token never reaches the browser.
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
shared infrastructure that lives in a separate `Infrastructure` git
repository, used by Musify and by other projects that need the same OIDC
provider and object storage.
## Project layout
Music streaming platform: upload your own tracks, process them asynchronously
(audio transcoded to `.m4a`, thumbnails generated) and stream them without the
bytes ever going through the API.
```
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
web-player/ the SvelteKit web client
deploy/ Docker Compose stacks for dev and production
docs/ architecture notes, one file per area
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
deploy/ Docker Compose stacks — self-contained, no wrapper scripts
docs/ What each piece does and why (in Spanish)
```
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
layout. They live in the separate `Infrastructure` repository instead.
```sh
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS)
```
## Digging deeper
## Run it
[docs/](docs/README.md) covers how each piece works and why: architecture,
authentication, storage, streaming, media processing, and the local dev
setup. [deploy/README.md](deploy/README.md) has the full instructions for
running the stack, in development or on a production server.
Development (Docker Desktop):
```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
```
Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC
provisioning — all as one-shot jobs baked into the compose file. Apps run from
the IDE by default; add `--profile apps up -d --build` as a second command to
run them in Docker instead.
Production, on a server with a domain:
```sh
cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
```
Idempotent — run the same commands again to deploy an update. See
[deploy/README.md](deploy/README.md) for the details, the port map and the
production prerequisites.
## Components
| Service | Port (dev) | Role |
|---|---|---|
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails |
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
More in [docs/](docs/README.md): [architecture](docs/architecture.md),
[authentication](docs/authentication.md), [storage](docs/storage.md),
[streaming](docs/streaming.md), [media processing](docs/media-processing.md),
[development](docs/development.md).
-79
View File
@@ -1,79 +0,0 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{csproj,props,targets,slnx,xml}]
indent_size = 2
[*.json]
indent_size = 4
[**/Migrations/**.cs]
generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = block_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_diagnostic.IDE0005.severity = warning
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first.
# Private fields are _camelCase whatever their modifiers (static, readonly, const).
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = underscore_camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_style.underscore_camel_case.required_prefix = _
# Non-private fields (constants, static readonly, etc.) are PascalCase.
dotnet_naming_rule.other_fields.symbols = other_fields
dotnet_naming_rule.other_fields.style = pascal_case
dotnet_naming_rule.other_fields.severity = warning
dotnet_naming_symbols.other_fields.applicable_kinds = field
dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_rule.interfaces.symbols = interfaces
dotnet_naming_rule.interfaces.style = interface_style
dotnet_naming_rule.interfaces.severity = warning
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.interface_style.capitalization = pascal_case
dotnet_naming_style.interface_style.required_prefix = I
dotnet_naming_rule.types_and_members.symbols = types_and_members
dotnet_naming_rule.types_and_members.style = pascal_case
dotnet_naming_rule.types_and_members.severity = warning
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
dotnet_naming_symbols.types_and_members.applicable_accessibilities = *
# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the
# EF-style IDatabase.Set<T>() and the Musify.Application.Shared namespace, whose names are deliberate.
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion
# The routing slip builders are stateless services resolved from DI; they stay instance members.
dotnet_diagnostic.CA1822.severity = suggestion
# Test names follow Method_Scenario_Expected.
[Tests/**.cs]
dotnet_diagnostic.CA1707.severity = none
# xUnit collection fixtures are conventionally named ...Collection.
dotnet_diagnostic.CA1711.severity = none
-58
View File
@@ -1,58 +0,0 @@
# Musify: backend
.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`.
## Commands
Run from `backend/`.
```sh
dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005)
dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers)
dotnet format Musify.slnx --verify-no-changes
```
- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit.
- EF migrations: `dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses.
- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean.
- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit.
## Style
- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it).
- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006).
- `ArgumentNullException.ThrowIfNull(x)` without `nameof`.
- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`).
- User-facing strings are in English (the web-player owns translation).
- Trim stored names; normalized columns go through `TextNormalizer.Normalize`.
- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last.
## Application layer
- One file per use case, `<Feature>/<UseCase>.cs`, holding the record and its handler; responses go in `Responses/`.
- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204.
- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows.
- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found).
- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow.
- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`.
## API layer
- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids.
- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise).
- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`.
- `.WithSummary(...)` is sentence case without a trailing period.
## Infrastructure and hosts
- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity<T>`, `DeleteEntityActivity<T>`, `UpdatePicturesActivity<T>`).
- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes.
- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's.
- The StreamingGateway references neither Application nor Infrastructure and keeps its own options.
## Tests
- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051).
- Test names are `Method_Scenario_Expected`.
- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness.
- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`.
@@ -0,0 +1,82 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.Conflict(description: "Track is already in the album.");
}
var albumTracksQuery = database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
var nextTrackNumber = hasTracks
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
: 1;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = request.TrackId,
TrackNumber = nextTrackNumber
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
request.TrackId, request.AlbumId, nextTrackNumber);
return new Success();
}
}
}
@@ -1,76 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToAlbumCommandHandler(
IDatabase database,
ILogger<AddTracksToAlbumCommandHandler> logger)
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId })
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var alreadyInAlbum = await database.AlbumHasTracks
.AsNoTracking()
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
var nextTrackNumber = (await database.AlbumHasTracks
.Where(link => link.AlbumId == request.AlbumId)
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
foreach (var trackId in toAdd)
{
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = trackId,
TrackNumber = nextTrackNumber++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
return Result.Success;
}
}
}
@@ -2,15 +2,14 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
@@ -19,9 +18,7 @@ namespace Musify.Application.Albums
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null)
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
@@ -29,7 +26,9 @@ namespace Musify.Application.Albums
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
@@ -38,67 +37,92 @@ namespace Musify.Application.Albums
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var trackIds = (request.TrackIds ?? []).Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
.ToListAsync(cancellationToken);
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
var missing = trackIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
var intent = pictureIntent.Value;
var title = request.Title.Trim();
var album = new Album
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = TextNormalizer.Normalize(title),
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Type = request.Type,
Pictures = picture.Pictures
Pictures = new AlbumPictures
{
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
};
await database.Albums.AddAsync(album, cancellationToken);
for (var index = 0; index < trackIds.Count; index++)
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{
await database.AlbumHasTracks.AddAsync(
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
}
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
}
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet();
var visibleTrackIds = trackIds.Where(published.Contains).ToList();
return AlbumApplicationResponse.FromEntity(
album,
visibleTrackIds.Count,
[.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
}
}
}
}
@@ -1,10 +1,8 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
@@ -13,25 +11,40 @@ namespace Musify.Application.Albums
public class DeleteAlbumCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
var album = found.Value;
album.LifeCycleStatus = LifeCycleStatus.Removing;
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
database.Albums.Remove(album);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
}
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return Result.Success;
return new Success();
}
}
}
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums
{
@@ -17,14 +16,23 @@ namespace Musify.Application.Albums
{
var album = await database.Albums
.AsNoTracking()
.Active()
.Where(a => a.Id == request.Id)
.SelectResponse()
.Select(a => new
{
Album = a,
TrackCount = a.AlbumTracks.Count,
CoverTrackIds = a.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
return album == null
? AppErrors.NotFound("Album", request.Id)
: album;
if (album == null)
return Error.NotFound();
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
}
}
}
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = albumConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -1,35 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId)
: IQuery<ErrorOr<AlbumTrackIdsResponse>>;
public class GetAlbumTrackIdsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTrackIdsQuery, ErrorOr<AlbumTrackIdsResponse>>
{
public async ValueTask<ErrorOr<AlbumTrackIdsResponse>> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var trackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId
&& albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
return new AlbumTrackIdsResponse(trackIds);
}
}
}
@@ -1,10 +1,11 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
@@ -21,19 +22,28 @@ namespace Musify.Application.Albums
{
var albumExists = await database.Albums
.AsNoTracking()
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return AppErrors.NotFound("Album", request.AlbumId);
return Error.NotFound();
return await database.AlbumHasTracks
var tracksQuery = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Include(albumTrack => albumTrack.Track.Owner)
.Include(albumTrack => albumTrack.Track.Tags)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -1,34 +1,60 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
: IQuery<ErrorOr<AlbumsSearchResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
{
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{
var albumsQuery = database.Albums
.AsNoTracking()
.Active();
.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Title))
if (!string.IsNullOrEmpty(request.Title))
{
var normalizedTitle = TextNormalizer.Normalize(request.Title);
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
}
return await albumsQuery
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
return new AlbumsSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
}
}
}
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
@@ -19,16 +21,33 @@ namespace Musify.Application.Albums
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
return await database.Albums
var albumsQuery = database.Albums
.AsNoTracking()
.Active()
.Where(album => album.OwnerUserId == request.UserId)
.Where(album => album.OwnerUserId == request.UserId);
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
}
}
}
@@ -1,3 +1,4 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
@@ -6,47 +7,54 @@ using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{
var listens = database.ListeningHistories
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
.Where(history => history.UserId == request.UserId);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join(
listens,
listenedTrackIds,
albumTrack => albumTrack.TrackId,
history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt)
.ThenBy(entry => entry.AlbumId)
.Take(request.Limit)
.ToListAsync(cancellationToken);
if (ranked.Count == 0)
return [];
return new List<AlbumApplicationResponse>();
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albumsById = await database.Albums
var albums = await database.Albums
.AsNoTracking()
.Where(album => albumIds.Contains(album.Id))
.SelectResponse()
.ToDictionaryAsync(album => album.Id, cancellationToken);
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
.Select(entry => albumsById[entry.AlbumId])
.Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList();
}
}
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums
{
@@ -17,9 +16,20 @@ namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
@@ -28,7 +38,10 @@ namespace Musify.Application.Albums
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
{
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
database.AlbumHasTracks.Remove(link);
@@ -40,7 +53,7 @@ namespace Musify.Application.Albums
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return Result.Success;
return new Success();
}
}
}
@@ -1,9 +1,13 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Pictures.Responses;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
@@ -12,14 +16,90 @@ namespace Musify.Application.Albums
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>;
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
public class RequestAlbumPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.AlbumPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new AlbumPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
}
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses
{
@@ -8,17 +9,16 @@ namespace Musify.Application.Albums.Responses
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds,
AlbumType Type)
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public const int CoverTrackCount = 4;
public static AlbumApplicationResponse FromEntity(
Album album,
@@ -37,8 +37,7 @@ namespace Musify.Application.Albums.Responses
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? [],
album.Type);
coverTrackIds ?? []);
}
}
}
@@ -1,6 +1,6 @@
namespace Musify.Application.Pictures.Responses
namespace Musify.Application.Albums.Responses
{
public record PictureUploadResponse(
public record AlbumPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
@@ -1,34 +0,0 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses
{
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
query.Select(album => new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList(),
album.Type));
}
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
}
}
@@ -1,4 +0,0 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
}
@@ -0,0 +1,12 @@
namespace Musify.Application.Albums.Responses
{
/// <summary>A page of the user's catalog albums (their own).</summary>
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
}
@@ -2,10 +2,14 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
@@ -15,38 +19,132 @@ namespace Musify.Application.Albums
string Title,
string? Description,
int? ReleaseYear,
AlbumType? Type = null)
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
ILogger<UpdateAlbumCommandHandler> logger)
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var album = found.Value;
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.NormalizedTitle = title.ToUpperInvariant();
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
album.Type = request.Type ?? album.Type;
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
album.Pictures = new AlbumPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
return await database.Albums
.AsNoTracking()
.Where(a => a.Id == album.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
}
}
}
}
@@ -1,59 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumPictureCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumPictureCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumPictureCommand request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = found.Value;
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
album.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated picture of album {AlbumId}", album.Id);
return await database.Albums
.AsNoTracking()
.Where(a => a.Id == album.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
}
}
}
@@ -19,6 +19,8 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
@@ -3,27 +3,25 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration;
using Musify.Application.Services;
namespace Musify.Application
namespace Musify.Application;
public static class ApplicationDependencyInjection
{
public static class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
{
services.AddMediator();
services.AddScoped<UploadIntentValidator>();
services.AddScoped<UploadIntentIssuer>();
services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
services.AddValidatedOptions<PlayListConfiguration>(configuration);
services.AddValidatedOptions<AlbumConfiguration>(configuration);
services.AddValidatedOptions<TrackConfiguration>(configuration);
services.AddValidatedOptions<MixConfiguration>(configuration);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
return services;
}
}
}
@@ -1,4 +1,4 @@
using Mediator;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -1,35 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Coauthors.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record GetCoauthorInvitationsQuery(long UserId, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<CoauthorInvitationResponse>>;
public class GetCoauthorInvitationsQueryHandler(IDatabase database)
: IQueryHandler<GetCoauthorInvitationsQuery, PaginatedResponse<CoauthorInvitationResponse>>
{
public async ValueTask<PaginatedResponse<CoauthorInvitationResponse>> Handle(GetCoauthorInvitationsQuery request, CancellationToken cancellationToken)
{
return await database.TrackCoauthors
.AsNoTracking()
.Where(coauthor => coauthor.UserId == request.UserId
&& coauthor.Status == CoauthorStatus.Pending
&& coauthor.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderByDescending(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.Id)
.Select(coauthor => new CoauthorInvitationResponse(
coauthor.Id,
coauthor.TrackId,
coauthor.Track.Title,
coauthor.Track.Owner.Name,
coauthor.Track.OwnerUserId,
coauthor.InvitedAt))
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -1,84 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record InviteCoauthorsCommand(long OwnerId, Guid TrackId, IReadOnlyList<long> UserIds)
: ICommand<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>;
public class InviteCoauthorsCommandHandler(
IDatabase database,
ILogger<InviteCoauthorsCommandHandler> logger)
: ICommandHandler<InviteCoauthorsCommand, ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>> Handle(InviteCoauthorsCommand request, CancellationToken cancellationToken)
{
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.OwnerId, cancellationToken);
if (found.IsError)
return found.Errors;
var userIds = request.UserIds.Distinct().ToList();
if (userIds.Contains(request.OwnerId))
return Error.Validation("Coauthor.Self", "You cannot invite yourself as a coauthor.");
var existingUserIds = await database.Users
.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.Select(user => user.Id)
.ToListAsync(cancellationToken);
var missing = userIds.Where(id => !existingUserIds.Contains(id)).Select(id => (long?)id).FirstOrDefault();
if (missing != null)
return AppErrors.NotFound("User", missing.Value);
var existing = await database.TrackCoauthors
.Where(coauthor => coauthor.TrackId == request.TrackId && userIds.Contains(coauthor.UserId))
.ToListAsync(cancellationToken);
var alreadyInvited = existing.FirstOrDefault(coauthor => coauthor.Status != CoauthorStatus.Rejected);
if (alreadyInvited != null)
return AppErrors.Conflict("Coauthor.AlreadyInvited", $"User {alreadyInvited.UserId} was already invited to this track.");
var now = DateTime.UtcNow;
foreach (var rejected in existing)
{
rejected.Status = CoauthorStatus.Pending;
rejected.InvitedAt = now;
rejected.RespondedAt = null;
}
var reinvitedIds = existing.Select(coauthor => coauthor.UserId).ToHashSet();
foreach (var userId in userIds.Where(id => !reinvitedIds.Contains(id)))
{
await database.TrackCoauthors.AddAsync(new TrackCoauthor
{
TrackId = request.TrackId,
UserId = userId,
InvitedAt = now
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Coauthor.AlreadyInvited", "A user was already invited to this track."), cancellationToken);
if (saved.IsError)
return saved.Errors;
logger.LogInformation("Owner {OwnerId} invited {Count} coauthors to track {TrackId}", request.OwnerId, userIds.Count, request.TrackId);
return await database.TrackCoauthors
.AsNoTracking()
.Where(coauthor => coauthor.TrackId == request.TrackId)
.OrderBy(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.UserId)
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
.ToListAsync(cancellationToken);
}
}
}
@@ -1,47 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record RemoveCoauthorCommand(long UserId, Guid TrackId, long CoauthorUserId)
: ICommand<ErrorOr<Success>>;
public class RemoveCoauthorCommandHandler(
IDatabase database,
ILogger<RemoveCoauthorCommandHandler> logger)
: ICommandHandler<RemoveCoauthorCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveCoauthorCommand request, CancellationToken cancellationToken)
{
var ownerUserId = await database.Tracks
.AsNoTracking()
.Where(track => track.Id == request.TrackId && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => (long?)track.OwnerUserId)
.SingleOrDefaultAsync(cancellationToken);
if (ownerUserId == null)
return AppErrors.NotFound("Track", request.TrackId);
if (ownerUserId != request.UserId && request.CoauthorUserId != request.UserId)
return AppErrors.Forbidden("Track", request.TrackId);
var coauthor = await database.TrackCoauthors
.SingleOrDefaultAsync(c => c.TrackId == request.TrackId && c.UserId == request.CoauthorUserId, cancellationToken);
if (coauthor == null)
return AppErrors.NotFound("Coauthor", request.CoauthorUserId);
database.TrackCoauthors.Remove(coauthor);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} removed coauthor {CoauthorUserId} from track {TrackId}",
request.UserId, request.CoauthorUserId, request.TrackId);
return Result.Success;
}
}
}
@@ -1,44 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record RespondToCoauthorInvitationCommand(long UserId, Guid InvitationId, bool Accept)
: ICommand<ErrorOr<Success>>;
public class RespondToCoauthorInvitationCommandHandler(
IDatabase database,
ILogger<RespondToCoauthorInvitationCommandHandler> logger)
: ICommandHandler<RespondToCoauthorInvitationCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RespondToCoauthorInvitationCommand request, CancellationToken cancellationToken)
{
var invitation = await database.TrackCoauthors
.Include(coauthor => coauthor.Track)
.SingleOrDefaultAsync(coauthor => coauthor.Id == request.InvitationId, cancellationToken);
if (invitation == null || invitation.Track.LifeCycleStatus != LifeCycleStatus.Active)
return AppErrors.NotFound("CoauthorInvitation", request.InvitationId);
if (invitation.UserId != request.UserId)
return AppErrors.Forbidden("CoauthorInvitation", request.InvitationId);
if (invitation.Status != CoauthorStatus.Pending)
return AppErrors.Conflict("CoauthorInvitation.NotPending", "The invitation was already answered.");
invitation.Status = request.Accept ? CoauthorStatus.Accepted : CoauthorStatus.Rejected;
invitation.RespondedAt = DateTime.UtcNow;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} coauthor invitation {InvitationId}",
request.UserId, request.Accept ? "accepted" : "rejected", request.InvitationId);
return Result.Success;
}
}
}
@@ -1,10 +0,0 @@
namespace Musify.Application.Coauthors.Responses
{
public record CoauthorInvitationResponse(
Guid Id,
Guid TrackId,
string TrackTitle,
string? Artist,
long OwnerUserId,
DateTime InvitedAt);
}
@@ -1,15 +1,79 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
public class AlbumConfiguration
{
public static string SectionName => "Album";
public const string SectionName = "Album";
[Required]
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
}
public class AlbumRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "Albums";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class AlbumPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class ApplicationStorageConfiguration : IConfigurationOptions
public class ApplicationStorageConfiguration
{
public static string SectionName => "ApplicationStorage";
public const string SectionName = "ApplicationStorage";
[Required]
[StringLength(128, MinimumLength = 1)]
@@ -2,14 +2,10 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Musify.Application.Configuration
{
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
where T : class, IConfigurationOptions =>
services.AddValidatedOptions<T>(configuration, T.SectionName);
namespace Musify.Application.Configuration;
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(
this IServiceCollection services,
IConfiguration configuration,
@@ -26,5 +22,4 @@ namespace Musify.Application.Configuration
return services;
}
}
}
@@ -3,7 +3,8 @@ using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration
{
/// <summary>Reads the parts of <see cref="PlaybackConfiguration"/> that are safe to expose to an
/// unauthenticated client, so it can decide whether to gate the app behind sign-in at all.</summary>
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
@@ -1,7 +0,0 @@
namespace Musify.Application.Configuration
{
public interface IConfigurationOptions
{
public static abstract string SectionName { get; }
}
}
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class MixConfiguration : IConfigurationOptions
public class MixConfiguration
{
public static string SectionName => "Mix";
public const string SectionName = "Mix";
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
@@ -1,91 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public enum PictureSize
{
Small,
Medium,
Large
}
public class PictureRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = string.Empty;
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
{
PictureSize.Small => SmallPicturesFolder,
PictureSize.Medium => MediumPicturesFolder,
PictureSize.Large => LargePicturesFolder,
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
});
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
}
public class PictureSizes
{
[Range(1, 1024)]
public int SmallWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargeWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargeHeight { get; set; } = 512;
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
}
public interface IPictureOwnerConfiguration
{
public PictureRoutes Routes { get; }
public PictureSizes PicturesSizes { get; }
}
public static class PictureSizeParser
{
public static PictureSize Parse(string? value) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size
: PictureSize.Medium;
}
}
@@ -1,15 +1,79 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
public class PlayListConfiguration
{
public static string SectionName => "PlayList";
public const string SectionName = "PlayList";
[Required]
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
}
public class PlayListRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "PlayLists";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class PlayListPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -2,16 +2,28 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public sealed class PlaybackConfiguration : IConfigurationOptions
/// <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 static 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; }
/// <summary>How many seconds of a track an anonymous listener may hear, enforced by the
/// Streaming Gateway clamping the byte range it will serve for that ticket. Zero (the default)
/// means no cap: anonymous listeners hear the full track, same as a signed-in user. Ignored
/// when <see cref="AllowAnonymousListening"/> is false.</summary>
[Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
/// <summary>Bytes per second of the transcoded audio, used to turn <see cref="AnonymousFragmentSeconds"/>
/// into a byte cap for the stream ticket. Defaults to the app's own transcoder output (128 kbps
/// AAC, <c>AudioTranscoder:Ffmpeg:AudioBitrate</c>) — update this alongside that setting if it
/// ever changes, since the two aren't wired together.</summary>
[Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
@@ -1,5 +1,7 @@
namespace Musify.Application.Configuration.Responses
{
/// <param name="AllowAnonymousListening">Whether listeners without a session can stream music at all.</param>
/// <param name="AnonymousFragmentSeconds">How many seconds of a track an anonymous listener may hear.
/// Zero means no cap. Meaningless when <paramref name="AllowAnonymousListening"/> is false.</param>
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
}
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class StreamGatewayConfiguration : IConfigurationOptions
public class StreamGatewayConfiguration
{
public static string SectionName => "StreamGateway";
public const string SectionName = "StreamGateway";
[Url]
[Required]
@@ -1,28 +1,38 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
public class TrackConfiguration
{
public static string SectionName => "Track";
public const string SectionName = "Track";
[Required]
public TrackRoutes Routes { get; set; } = new();
public TrackRoutes Routes { get; set; } = new TrackRoutes();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
}
public class TrackRoutes : PictureRoutes
public class TrackRoutes
{
public TrackRoutes()
{
ParentFolder = "Tracks";
}
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = "Tracks";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
[Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
@@ -40,7 +50,13 @@ namespace Musify.Application.Configuration
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required]
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
@@ -48,10 +64,53 @@ namespace Musify.Application.Configuration
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string BuildOriginalAudioPath(long userId, string audioName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
public string BuildProcessedAudioPath(string folderName) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
}
public class TrackPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -2,18 +2,18 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class UploadIntentConfiguration : IConfigurationOptions
public class UploadIntentConfiguration
{
public static string SectionName => "UploadIntent";
public const string SectionName = "UploadIntent";
[Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
@@ -31,12 +31,12 @@ namespace Musify.Application.Configuration
public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)]
public string TempRootPrefix { get; set; } = "temp";
public string TemporalRootPrefix { get; set; } = "temporal";
[Range(1, 30)]
public int TempUploadsRetentionDays { get; set; } = 3;
public int TemporalUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)]
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
}
}
@@ -2,6 +2,8 @@ namespace Musify.Application.Contracts
{
public interface IAudioTranscoderService
{
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
}
@@ -1,4 +1,3 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities;
@@ -6,40 +5,38 @@ namespace Musify.Application.Contracts
{
public interface IDatabase
{
public DbSet<User> Users { get; }
DbSet<User> Users { get; }
public DbSet<PlayList> PlayLists { get; }
DbSet<PlayList> PlayLists { get; }
public DbSet<Album> Albums { get; }
DbSet<Album> Albums { get; }
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
public DbSet<Track> Tracks { get; }
DbSet<Track> Tracks { get; }
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
DbSet<UserHasTrack> UserHasTracks { get; }
public DbSet<Mix> Mixes { get; }
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
public DbSet<MixItem> MixItems { get; }
DbSet<Mix> Mixes { get; }
public DbSet<UploadIntent> UploadIntents { get; }
DbSet<MixItem> MixItems { get; }
public DbSet<ListeningHistory> ListeningHistories { get; }
DbSet<Upload> Uploads { get; }
public DbSet<TrackLike> TrackLikes { get; }
DbSet<UploadIntent> UploadIntents { get; }
public DbSet<TrackTag> TrackTags { get; }
DbSet<ListeningHistory> ListeningHistories { get; }
public DbSet<UserFollow> UserFollows { get; }
DbSet<TrackLike> TrackLikes { get; }
public DbSet<TrackCoauthor> TrackCoauthors { get; }
DbSet<TrackTag> TrackTags { get; }
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
DbSet<UserFollow> UserFollows { get; }
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
}
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
{
public interface IDatabaseTransaction : IAsyncDisposable
{
public Task CommitAsync(CancellationToken cancellationToken = default);
Task CommitAsync(CancellationToken cancellationToken = default);
}
}
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
{
public interface IEventBus
{
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
}
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
{
public interface IPictureService
{
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
}
@@ -2,23 +2,27 @@ namespace Musify.Application.Contracts
{
public interface IStorageService
{
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken);
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
}
@@ -2,7 +2,12 @@ namespace Musify.Application.Contracts
{
public interface IStreamTicketService
{
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
/// <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);
}
public record StreamTicket(string Token, int ExpiresInSeconds);
@@ -8,5 +8,7 @@ namespace Musify.Application.Events
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -8,5 +8,7 @@ namespace Musify.Application.Events
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -4,13 +4,15 @@ namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid? PictureIntentId,
Guid PictureIntentId,
Guid AudioIntentId,
string Bucket,
string? PictureSourceKey,
string? PictureDestinationKey,
string PictureSourceKey,
string PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,6 +0,0 @@
namespace Musify.Application.Events
{
public record DeleteAlbumEvent(
Guid AlbumId,
long UserId);
}
@@ -6,5 +6,7 @@ namespace Musify.Application.Events
Guid AlbumId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -8,5 +8,7 @@ namespace Musify.Application.Events
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,10 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
namespace Musify.Application.Events
{
public record UpdatePlayListPictureEvent(
Guid PlayListId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -8,5 +8,7 @@ namespace Musify.Application.Events
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,4 +1,4 @@
namespace Musify.Application.Events
namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent(
Guid TrackId,
@@ -1,10 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
namespace Musify.Application.Events
{
public record UpdateTrackPictureEvent(
Guid TrackId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,25 +0,0 @@
using Mediator;
using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -1,30 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -1,6 +0,0 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses
{
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
}
@@ -1,6 +0,0 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses
{
public record GenreResponse(Genre Genre, int TrackCount);
}
@@ -1,9 +1,11 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Likes
{
@@ -11,21 +13,31 @@ namespace Musify.Application.Likes
long UserId,
int PageNumber,
int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
return await database.TrackLikes
var likesQuery = database.TrackLikes
.AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
.Include(like => like.Track.Owner)
.Include(like => like.Track.Tags)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id)
.Select(like => like.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -3,9 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes
{
@@ -21,9 +19,12 @@ namespace Musify.Application.Likes
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId);
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
@@ -44,10 +45,7 @@ namespace Musify.Application.Likes
nowLiked = true;
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
if (saved.IsError)
return true;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
@@ -1,3 +1,4 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -9,35 +10,40 @@ using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes
{
public record GenerateMixesForUserCommand(long UserId)
: ICommand;
: ICommand<ErrorOr<Success>>;
/// <summary>Builds each user's mixes purely from their own library: a "Descubrimiento" mix of
/// tracks they own but haven't listened to yet, and a "Tu mezcla diaria" mix blending recently
/// listened tracks with unheard ones. There's no artist/genre metadata for local uploads today,
/// so mixes can't be seeded by "similar artist" the way the old YouTube-backed discovery did —
/// once a tagging system exists, this is the place to seed mixes by tag instead.</summary>
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand>
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
{
var userId = request.UserId;
var libraryTrackIds = await database.Tracks
var libraryTrackIds = await database.UserHasTracks
.AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(userTrack => userTrack.TrackId)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
{
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return Unit.Value;
return new Success();
}
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Where(history => history.UserId == userId)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -50,27 +56,41 @@ namespace Musify.Application.Mixes
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones de tu biblioteca que todavía no has escuchado.",
discovery));
}
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(MixKind.Daily, daily));
drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
daily));
}
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return Unit.Value;
return new Success();
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return Unit.Value;
return new Success();
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
@@ -79,7 +99,7 @@ namespace Musify.Application.Mixes
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
{
var merged = new List<Guid>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
@@ -101,6 +121,12 @@ namespace Musify.Application.Mixes
if (previousMixes.Count > 0)
{
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
var previousItems = await database.MixItems
.Where(item => previousIds.Contains(item.MixId))
.ToListAsync(cancellationToken);
database.MixItems.RemoveRange(previousItems);
database.Mixes.RemoveRange(previousMixes);
}
@@ -110,7 +136,8 @@ namespace Musify.Application.Mixes
var mix = new Mix
{
UserId = userId,
Kind = draft.Kind,
Title = draft.Title,
Subtitle = draft.Subtitle,
Position = position
};
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes
{
@@ -21,12 +20,12 @@ namespace Musify.Application.Mixes
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null)
return AppErrors.NotFound("Mix", request.MixId);
return Error.NotFound(description: $"Mix {request.MixId} was not found");
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
}
}
}
@@ -1,3 +1,4 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
@@ -6,49 +7,28 @@ using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes
{
public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>;
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
{
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{
var mixes = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ThenBy(mix => mix.Id)
.ToListAsync(cancellationToken);
var orderedItemsByMix = mixes.ToDictionary(
mix => mix.Id,
mix => mix.Items.OrderBy(item => item.Position).ToList());
var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync(
database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken);
var visibleItemsByMix = orderedItemsByMix.ToDictionary(
entry => entry.Key,
entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList());
var coverItems = visibleItemsByMix.Values
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
.ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
.GroupBy(response => response.TrackId)
.ToDictionary(group => group.Key, group => group.First());
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId))
.Select(item => coverResponses[item.TrackId])
.ToList();
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var coverItems = await MixItemMapper.ToResponsesAsync(
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
}
return responses;
@@ -2,29 +2,14 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes
{
/// <summary>Resolves MixItem rows to their MixItemApplicationResponse shape via a single batched
/// track lookup, instead of an EF Include chain deep enough (Mix -&gt; Items -&gt; Track -&gt; Owner) to
/// force a correlated-subquery translation some providers (SQLite, notably) can't produce.</summary>
internal static class MixItemMapper
{
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
IDatabase database,
IEnumerable<MixItem> items,
CancellationToken cancellationToken)
{
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
var visible = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
return [.. visible];
}
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
@@ -35,8 +20,8 @@ namespace Musify.Application.Mixes
var tracksById = await database.Tracks
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
@@ -1,10 +1,9 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse(
Guid Id,
MixKind Kind,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{
@@ -1,101 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures
{
public enum CoverOwner
{
Track,
Album,
PlayList
}
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
_ => (await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
};
var entity = request.Owner.ToString();
if (source == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
var resizedName = request.Size switch
{
PictureSize.Small => source.Small,
PictureSize.Large => source.Large,
_ => source.Medium
};
var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original)
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
: (null, null);
if (name == null || key == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
}
private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new
{
Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName),
Album = t.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null)
.OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new CoverSource(
albumTrack.Album.OwnerUserId,
albumTrack.Album.Pictures!.OriginalName,
albumTrack.Album.Pictures.SmallName,
albumTrack.Album.Pictures.MediumName,
albumTrack.Album.Pictures.LargeName))
.FirstOrDefault()
})
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return (null, trackConfiguration.Routes);
return string.IsNullOrEmpty(track.Own.Original) && track.Album != null
? (track.Album, albumConfiguration.Routes)
: (track.Own, trackConfiguration.Routes);
}
}
}
@@ -1,35 +0,0 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures
{
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
CancellationToken cancellationToken)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
if (validation.IsError)
return validation.Errors;
var intent = validation.Value;
return new PreparedPicture(
intent,
EntityPictures.Pending(intent.ObjectName),
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes));
}
}
}
@@ -1,81 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
public class AddAlbumToPlayListCommandHandler(
IDatabase database,
ILogger<AddAlbumToPlayListCommandHandler> logger)
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
{
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var albumExists = await database.Albums
.AsNoTracking()
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return AppErrors.NotFound("Album", request.AlbumId);
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
var playListTracksQuery = database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId);
var existingTrackIds = (await playListTracksQuery
.Select(link => link.TrackId)
.ToListAsync(cancellationToken)).ToHashSet();
var newTrackIds = albumTrackIds
.Where(trackId => !existingTrackIds.Contains(trackId))
.ToList();
if (newTrackIds.Count == 0)
return new AddAlbumToPlayListResponse(0);
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in newTrackIds)
{
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved.Errors;
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
newTrackIds.Count, request.AlbumId, request.PlayListId);
return new AddAlbumToPlayListResponse(newTrackIds.Count);
}
}
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var alreadyAdded = await database.PlayListHasTracks
.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = request.TrackId,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
request.TrackId, request.PlayListId, nextPosition);
return new Success();
}
}
}
@@ -1,75 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToPlayListCommandHandler(
IDatabase database,
ILogger<AddTracksToPlayListCommandHandler> logger)
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var existingIds = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var alreadyInPlayList = await database.PlayListHasTracks
.AsNoTracking()
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
var playListTracksQuery = database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId);
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in toAdd)
{
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
return Result.Success;
}
}
}
@@ -3,14 +3,13 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
@@ -19,7 +18,7 @@ namespace Musify.Application.PlayLists
string Name,
string? Description,
Guid? PictureIntentId,
PlayListVisibility Visibility = PlayListVisibility.Private)
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
@@ -27,53 +26,121 @@ namespace Musify.Application.PlayLists
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null;
if (request.PictureIntentId.HasValue)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
picture = prepared.Value;
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var name = request.Name.Trim();
var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList
{
OwnerUserId = request.UserId,
Name = name,
NormalizedName = TextNormalizer.Normalize(name),
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = picture?.Pictures,
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (picture != null)
if (resolvedPictures.Intent != null)
{
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
}
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
CreatePlayListCommand request, CancellationToken cancellationToken)
{
if (!request.PictureIntentId.HasValue)
{
return new ResolvedPlayListPictures(null, null);
}
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = validation.Value.ObjectName,
SmallName = validation.Value.ObjectName,
MediumName = validation.Value.ObjectName,
LargeName = validation.Value.ObjectName
}, validation.Value);
}
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreatePlayListResourcesEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
}
}
}
@@ -1,9 +1,9 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
@@ -19,19 +19,44 @@ namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
}
var playList = found.Value;
playList.LifeCycleStatus = LifeCycleStatus.Removing;
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
try
{
await eventBus.PublishAsync(
new DeletePlayListEvent(playList.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
return Result.Success;
return new Success();
}
}
}
@@ -1,9 +1,9 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Mediator;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
@@ -15,17 +15,28 @@ namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
var entry = await database.PlayLists
.AsNoTracking()
.Active()
.VisibleTo(request.RequestingUserId)
.Where(p => p.Id == request.Id)
.SelectResponse()
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
return playList == null
? AppErrors.NotFound("PlayList", request.Id)
: playList;
if (entry == null)
return Error.NotFound();
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
}
}
}
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = playListConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -2,18 +2,17 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists
{
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize,
long? ViewerId = null)
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
@@ -21,22 +20,30 @@ namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{
var playListVisible = await database.PlayLists
var playListExists = await database.PlayLists
.AsNoTracking()
.Active()
.VisibleTo(request.ViewerId)
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
if (!playListVisible)
return AppErrors.NotFound("PlayList", request.PlayListId);
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
if (!playListExists)
return Error.NotFound();
return await database.PlayListHasTracks
var tracksQuery = database.PlayListHasTracks
.AsNoTracking()
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.Include(plt => plt.Track.Owner)
.Include(plt => plt.Track.Tags)
.Where(plt => plt.PlayListId == request.PlayListId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -0,0 +1,43 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.PlayLists.Responses;
using X.PagedList;
using X.PagedList.EF;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,8 +1,12 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists
{
@@ -11,31 +15,50 @@ namespace Musify.Application.PlayLists
string? Name,
int PageNumber,
int PageSize,
long? ViewerId = null)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{
var playListsQuery = database.PlayLists
.AsNoTracking()
.Active()
.VisibleTo(request.ViewerId)
.Where(playList => playList.OwnerUserId == request.UserId);
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrWhiteSpace(request.Name))
if (request.OnlyPublic)
{
var normalizedName = TextNormalizer.Normalize(request.Name);
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
return await playListsQuery
.OrderBy(playList => playList.CreatedAt)
.ThenBy(playList => playList.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
}
var totalCount = await playListsQuery.CountAsync(cancellationToken);
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists
{
@@ -17,30 +16,34 @@ namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
var playListTracks = await database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId)
.OrderBy(plt => plt.Position)
.ToListAsync(cancellationToken);
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (link == null)
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "Track is not in the playlist.");
}
database.PlayListHasTracks.Remove(link);
var position = 0;
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
remaining.Position = position++;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
return Result.Success;
return new Success();
}
}
}
@@ -1,9 +1,13 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Pictures.Responses;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
@@ -12,14 +16,90 @@ namespace Musify.Application.PlayLists
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>;
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.PlayListPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
}
@@ -1,4 +0,0 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -1,7 +1,6 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses
{
public record PlayListApplicationResponse(
@@ -11,21 +10,16 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
PlayListVisibility Visibility,
PlaylistVisibility Visibility,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds,
long OwnerUserId,
int TrackCount,
double DurationSeconds)
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public const int CoverTrackCount = 4;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null,
int trackCount = 0,
double durationSeconds = 0)
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
playList.Id,
@@ -37,10 +31,7 @@ namespace Musify.Application.PlayLists.Responses
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? [],
playList.OwnerUserId,
trackCount,
durationSeconds);
coverTrackIds ?? []);
}
}
}
@@ -0,0 +1,11 @@
namespace Musify.Application.PlayLists.Responses
{
public record PlayListPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -1,39 +0,0 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses
{
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
query.Select(playList => new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures!.SmallName,
playList.Pictures!.MediumName,
playList.Pictures!.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
}
@@ -2,48 +2,154 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string NewName,
string? NewName,
string? NewDescription,
PlayListVisibility NewVisibility)
Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
ILogger<UpdatePlayListCommandHandler> logger)
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playListEntity == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
var playList = found.Value;
if (playListEntity.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
playList.Name = request.NewName.Trim();
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
playList.Visibility = request.NewVisibility;
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
{
playListEntity.Description = request.NewDescription;
}
if (request.NewVisibility.HasValue)
{
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
playListEntity.Pictures = new PlayListPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
}
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
return await database.PlayLists
var coverTrackIds = await database.PlayListHasTracks
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
.Where(plt => plt.PlayListId == playListEntity.Id)
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
}
}
}
}
@@ -1,59 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListPictureCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListPictureCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListPictureCommand request, CancellationToken cancellationToken)
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = found.Value;
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
playList.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated picture of playlist {PlayListId}", playList.Id);
return await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
}
}
}
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Musify.Application.Serialization;
// Zitadel-issued user ids routinely exceed Number.MAX_SAFE_INTEGER (2^53-1).
// Serialized as a raw JSON number, the frontend's JSON.parse silently rounds
// them to the nearest representable double, corrupting the id (it becomes a
// different, sometimes valid, user id). Applied via [property: JsonConverter(...)]
// on the specific id-shaped `long`/`long?` response properties (not globally —
// unrelated `long` fields like byte sizes must stay numbers), this serializes
// them as a JSON string instead, which round-trips exactly.
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType == JsonTokenType.String
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return reader.TokenType == JsonTokenType.String
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
else
writer.WriteNullValue();
}
}
@@ -1,19 +1,28 @@
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Services
{
public sealed class TrackStreamIssuer(
namespace Musify.Application.Services;
public sealed class TrackStreamIssuer(
IDatabase database,
IStreamTicketService ticketService,
TrackConfiguration trackConfiguration,
StreamGatewayConfiguration streamGatewayConfiguration,
PlaybackConfiguration playbackConfiguration)
{
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
{
public async Task<TrackStreamResponse> IssueAsync(
Guid trackId,
string audioFolderName,
long? userId,
CancellationToken cancellationToken)
{
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
var keyPrefix = $"{folderPath}/";
var maxBytes = AnonymousFragmentBytes(userId);
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
var manifestUrl = string.Join('/',
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
@@ -21,7 +30,20 @@ namespace Musify.Application.Services
folderPath,
streamGatewayConfiguration.AudioFileName);
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
// Anonymous listeners have no User row to satisfy ListeningHistory's foreign key against, and
// their plays aren't "history" anyone can look back on anyway, so there's nothing to record.
if (userId != null)
{
var newListeningHistory = new ListeningHistory
{
UserId = userId.Value,
TrackId = trackId,
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
}
private long? AnonymousFragmentBytes(long? userId)
@@ -31,5 +53,4 @@ namespace Musify.Application.Services
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
}
}
}
@@ -1,138 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Pictures.Responses;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Services
{
public sealed record UploadRequest(
UploadIntentPurpose Purpose,
PictureRoutes Routes,
string FileType,
string ContentType,
long? ExpectedSizeBytes,
int? SharedKeyGroup = null);
public sealed record IssuedUpload(
Guid IntentId,
string Key,
string ObjectName,
string ContentType,
string UploadUrl);
public sealed class UploadIntentIssuer(
IDatabase database,
IStorageService storageService,
UploadIntentValidator uploadIntentValidator,
ApplicationStorageConfiguration storageConfiguration,
UploadIntentConfiguration uploadIntentConfiguration,
ILogger<UploadIntentIssuer> logger)
{
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
long userId,
IReadOnlyList<UploadRequest> requests,
CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == userId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", userId);
var sharedKeys = new Dictionary<int, string>();
var planned = requests
.Select(request =>
{
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
if (request.SharedKeyGroup is { } group)
key = sharedKeys.TryAdd(group, key) ? key : sharedKeys[group];
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
return (Request: request, ObjectName: objectName, Key: key, Size: size);
})
.ToList();
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quota = await uploadIntentValidator.CheckQuotaAsync(
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
if (quota.IsError)
return quota.Errors;
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
var expiresAt = DateTime.UtcNow + expiresIn;
var issued = new List<IssuedUpload>(planned.Count);
foreach (var item in planned)
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken);
var intent = new UploadIntent
{
UserId = userId,
Bucket = storageConfiguration.Bucket,
Key = item.Key,
ObjectName = item.ObjectName,
ContentType = item.Request.ContentType,
ExpectedSizeBytes = item.Size,
Purpose = item.Request.Purpose,
ExpiresAt = expiresAt,
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
}
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
return issued;
}
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
{
var issued = await IssueAsync(userId, [request], cancellationToken);
return issued.IsError ? issued.Errors : issued.Value[0];
}
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
string fileType,
string contentType,
long? expectedSizeBytes,
CancellationToken cancellationToken)
{
var issued = await IssueOneAsync(
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
if (issued.IsError)
return issued.Errors;
return new PictureUploadResponse(
issued.Value.IntentId,
storageConfiguration.Bucket,
issued.Value.Key,
issued.Value.ObjectName,
issued.Value.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
issued.Value.UploadUrl);
}
private long DefaultSize(UploadIntentPurpose purpose) =>
purpose == UploadIntentPurpose.TrackAudio
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
}
}
@@ -1,78 +1,63 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Services
{
public sealed class UploadIntentValidator(
namespace Musify.Application.Services;
public sealed class UploadIntentValidator(
IDatabase database,
IStorageService storageService,
UploadIntentConfiguration config)
{
IStorageService storageService)
{
public async Task<ErrorOr<Success>> CheckQuotaAsync(
UploadIntentConfiguration config,
long userId,
long requiredBytes,
int requiredIntentCount,
CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
var audioDefault = config.DefaultExpectedAudioSizeBytes;
var active = await database.UploadIntents
var activeIntents = await database.UploadIntents
.AsNoTracking()
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
.GroupBy(_ => 1)
.Select(group => new
{
Count = group.Count(),
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
})
.SingleOrDefaultAsync(cancellationToken);
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
.ToListAsync(cancellationToken);
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
return Result.Success;
return new Success();
}
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
UploadIntentConfiguration config,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
CancellationToken cancellationToken)
{
var intent = await database.UploadIntents
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
if (intent == null || intent.UserId != userId)
return AppErrors.NotFound("UploadIntent", intentId);
if (intent.Purpose != purpose)
return Error.Validation(description: "Upload intent was issued for a different purpose.");
return Error.NotFound(description: "Upload intent not found or not accessible.");
if (intent.IsConsumed)
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
return Error.Conflict(description: "Upload intent has already been consumed.");
if (intent.IsExpiredAt(DateTime.UtcNow))
if (intent.IsExpired)
return Error.Validation(description: "Upload intent has expired.");
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
if (metadata == null)
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
if (metadata.ContentLength > config.MaxUploadBytes)
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
return intent;
}
}
}

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