Author SHA1 Message Date
Admin 505ae05c53 Refactor AuditableEntityInterceptor to use property access for UpdatedAt; update Database configuration to add interceptor and adjust tests for consistency
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m17s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 19:04:21 +02:00
Admin a7929c0ac9 Enhance upload functionality with genre selection and validation; refactor session cookie handling
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (push) Canceled after 39s
2026-09-23 18:33:21 +02:00
Admin 5efd59826b Use the shared edge nginx instead of Musify's own
CI/CD / BackEnd (push) Successful in 1m27s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m16s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
Remove deploy/nginx and the nginx service from compose.prod.yml: the
shared edge in Infrastructure owns ports 80/443 and serves every
domain. Make the deploy job fail when zitadel-init or seaweedfs-init
fail, and document the runner and edge setup.
2026-09-23 16:56:27 +02:00
Admin 8970f29410 Refactor CI/CD job names for clarity and consistency
CI/CD / BackEnd (push) Successful in 1m20s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 16:14:53 +02:00
Admin 523318fc06 Fix 2026-09-23 16:00:39 +02:00
Admin ba531b4f61 Add actions 2026-09-23 05:28:11 +02:00
Admin 3d51f08e0b Changes on header 2026-09-23 01:40:16 +02:00
Admin 2660c124d5 Simplify design 2026-09-23 01:33:25 +02:00
Admin d7199b4d1d Changes on the colors 2026-09-22 22:23:06 +02:00
Admin c60704080b Restore dark-mode accentL to its original 0.74
0.65 was still too dark per feedback. The original concern that made
us drop accentL from 0.74 (accent surfaces stacking too bright over
the darkened spotlight/hero cards) is already fixed architecturally by
the earlier color-mix() conversion, which makes bg-accent-tint/
bg-accent-btn a solid precomputed value that no longer stacks with
whatever's underneath. So there's no reason to keep accentL dimmed for
that reason anymore.
2026-09-22 20:40:23 +02:00
Admin 1e3e46cfdc Brighten dark-mode accentL back up slightly
0.55 (dropped from the original 0.74 to fix accent surfaces looking
too bright when stacked on the darkened spotlight/hero cards) made
bg-accent-tint on the plain page background barely visible. Split the
difference at 0.65, closer to how it looked originally.
2026-09-22 20:38:03 +02:00
Admin 93d4220f3a Make accent backgrounds solid so they don't stack on tinted surfaces
--mf-accent-soft-bg/--mf-accent-btn-bg/-hover were translucent alpha
overlays, so an accent-tinted element (e.g. the active-row highlight,
or the accent Button) rendered differently depending on what was
already underneath it: fine over the plain page background, but over
an already-tinted surface like the spotlight card, the two translucent
layers stacked and the combined result read brighter/more saturated
than intended.

Same fix as bg-btn earlier: replace the alpha overlay with a solid
color-mix() against --mf-bg, computed once, so the token always
renders identically regardless of what it's placed on top of. Verified
the active-row highlight and the accent button now compute to the
exact same color whether they sit on the plain background or on the
spotlight card.
2026-09-22 20:33:25 +02:00
Admin 8a748f2b3a Darken hero/spotlight cards further and adapt accent buttons to match
Per feedback, --mf-hero-bg/--mf-spotlight-bg in dark mode were still
too bright: drop their tc() base lightness from 42 to 38 and alpha
from 0.2 to 0.1, so the cards read as a subtle wash close to the page
background instead of a distinct bright block.

That made bg-accent-tint (Button's accent variant, ListRow/TrackList's
active-row highlight) look comparatively too light against the now
much darker cards, since accentL was still 0.74. Drop dark's accentL
to 0.55 so every accent-derived surface (rings, accent-tint, accent-btn
hover) darkens to match. Text (titleL, still 0.87) is untouched since
it needs to stay legible regardless of how dark the surface under it
gets.

Cleaned up the now-unused ACCENT_LIGHTNESS import/L constant.
2026-09-22 20:27:17 +02:00
Admin 2138a4a890 Shrink menu item text and drop the border on the account dropdown
MenuItem used text-sm (14px) for its label, which read as oversized
for a dropdown; drop to text-xs (12px). This is a shared component
(AccountMenu, ContextMenu, MobileHeader's menu), so all of them get
the more compact sizing.

Add a border prop to GlassMenu (default true, unchanged for
ContextMenu) and set it false specifically on AccountMenu's dropdown,
per feedback that its border didn't fit.
2026-09-22 20:19:58 +02:00
Admin 94a1981a4c Soften dark-mode accent colors (buttons and text)
Dark mode's accent chroma came from the ACCENT_CHROMA constant (0.145,
originally meant as a generic default), used unconditionally for every
accent-derived token regardless of the actual song's extracted vividness
- extractAccent() already clamps a song's real chroma to 0.045-0.11, but
buildThemeTokens() never used it, only the hue. That made every accent
button, ring and highlight uniformly vivid. Drop it to 0.09.

Also soften --mf-accent-title (the accent-colored track title text,
e.g. active rows) from 0.07 to 0.05 chroma, since that was called out
specifically as too strong.

Cleaned up the now-unused C/ACCENT_CHROMA import.
2026-09-22 20:14:46 +02:00
Admin 2272a401bb Dim the dark-mode primary button further
#e6e6e8 still read as too white per feedback. Use #c4c4c8, a clearly
gray tone instead of an off-white, while text-ink still contrasts
comfortably against it.
2026-09-22 20:05:03 +02:00
Admin 3ab43a2ef6 Dim the dark-mode primary button from pure white to a soft off-white
--mf-cta-strong-bg was #ffffff in dark mode, reading as too bright/
glaring against the dark surfaces around it. Use #e6e6e8 instead, a
still-white but slightly dimmed tone. Light mode's own value (#17171a,
already dark) is untouched.
2026-09-22 20:02:33 +02:00
Admin 79698f2265 Brighten secondary button text for dark-mode legibility
secondary used text-fg-2 (--mf-text-2), which in dark mode is a fairly
dim mid-gray (#8a8a8e) that read poorly against the dark bg-btn fill,
even though the same class looks great in light mode where --mf-text-2
is a dark, high-contrast gray. Use text-fg instead, which is tuned
bright in dark mode and dark in light mode, so both modes get good
contrast from the same class. Drop the now-redundant hover:text-fg
since resting state already uses it.
2026-09-22 19:58:39 +02:00
Admin 94f80c03c5 Make dark-mode hero/spotlight cards a solid color, not a fading gradient
--mf-hero-bg/--mf-spotlight-bg were multi-stop, directional gradients
in dark mode (fading from a vivid corner to transparent), unlike light
mode which uses the same color at both gradient stops so it reads as
flat. Match that: both now use a single tc() tone repeated at both
stops, so the hero and spotlight cards render as one solid tint in
dark mode too.
2026-09-22 19:53:21 +02:00
Admin d98d01ab20 Make the dark-mode ambient glow more subtle and spread further
The two radial glows behind app-backdrop were fairly small (52%/38%
and 46%/34% ellipses) and faded out by half their radius, so they only
covered the top portion of the viewport at a fairly strong 0.26/0.2
alpha. Enlarge both ellipses and push the fade-out point out to 88%,
so the tint reaches much further across the screen like light mode's
flat wash does, while lowering the alpha so it reads as subtle instead
of a bright patch in the corners.
2026-09-22 19:50:23 +02:00
Admin 6be5c43e6d Let sidebar and queue show the same ambient backdrop as the center
Sidebar and Queue painted their own opaque bg-bg, which sat in front of
the fixed, full-viewport app-backdrop div (z-index: -10) that carries
--mf-ambient's radial-gradient glow in dark mode. Since the main content
area has no background of its own, the glow only ever showed through
there — sidebar and queue blocked it with a flat, gradient-less fill of
the same base color.

Drop bg-bg from both so the same app-backdrop (color + ambient gradient)
shows through underneath them too. In light mode this is a no-op
(--mf-ambient is 'none' and app-backdrop's flat color already matches
what bg-bg painted), so only dark mode is visually affected.
2026-09-22 19:46:25 +02:00
Admin 8deb762a52 Revert chrome-bg: sidebar/queue should just be the tinted background
Per feedback, the solid color-mix bar was wrong — sidebar, queue and
the mobile header should stay on the plain --mf-bg (the same subtly
hue-shifting black used everywhere else), exactly like light mode
already does, not a separately-boosted colored panel. Drop --mf-chrome-bg/
--color-chrome and switch those three back to bg-bg.

The general dark-mode richness bump (tc(), --mf-bg/--mf-elevated/etc.)
and the glass-bar background-color fix from the previous commit stay;
only the separate chrome treatment is reverted.
2026-09-22 19:02:10 +02:00
Admin fe21c19810 Make dark mode more colorful and give chrome bars a full tint
Mirror the earlier light-mode pastel/colorful pass for dark mode:
boost tc()'s lightness and chroma, and richen --mf-bg/--mf-elevated/
--mf-panel-bg/--mf-bar-bg/--mf-hairline the same way, so dark mode
reads as colorful instead of near-grayscale.

Sidebar, Queue, and MobileHeader used bg-bg directly, so in dark mode
they stayed almost black regardless of hue. Add --mf-chrome-bg (an
alias to --mf-bg in light mode, a solid color-mix of --mf-hero-tint
into --mf-bg in dark mode) and switch those three to bg-chrome, so
they read as a full, solid colored bar in dark mode instead of a
fading gradient (per feedback: a whole bar, not a gradient).

Also fixes glass-bar (PlayerBar/TabsBar): it set
`background-image: var(--mf-bar-bg)`, but --mf-bar-bg is a plain
color, not a valid <image>, so the declaration was silently dropped
and these bars never actually showed their tint in either mode. Changed
to background-color.
2026-09-22 18:57:16 +02:00
Admin 6e86c3d2c5 Carry track duration through the queue pipeline
ApiTrackLike/QueueItem had no duration field, so toQueueItem(s) always
dropped it and toTrack() hardcoded a new track's duration to 0, relying
entirely on the <audio> element's durationchange/loadedmetadata events
to fill it in once that track actually became current. Upcoming queue
rows never reach that point, so "A continuación" always showed a blank
duration even though the backend already returns it.

Add duration to both types, thread it through toQueueItems/toQueueItem/
trackFromQueueItem, and seed toTrack() from it. The real-time audio
sync still overwrites it once a track actually plays, so this only
fills the gap for tracks that haven't played yet.

Also map MixItemApplicationResponse's durationSeconds through
mixItemTrack, since mixes use a differently-named field.
2026-09-22 18:46:17 +02:00
Admin aca4b73fac Match the accent button's background to the active-row highlight
Button's accent variant used bg-accent-btn (15% alpha) while the
active-track highlight in ListRow/TrackList uses bg-accent-tint (8%
alpha) with the same text-accent-soft title color, so a button like
"Reproducir" sat at a visibly different tint than the row it plays.
Use bg-accent-tint for accent's resting background too, so both read
as the same accent color; hover still steps up to bg-accent-btn-hover.
2026-09-22 18:41:03 +02:00
Admin 078f7ac6f6 Stop Button's generic transition from lagging its hue-driven background
Button used Tailwind's bare `transition` utility, whose default
property list includes background-color. Since the button's bg is a
CSS custom property recomputed every frame by the same 600ms hue
animation the main background uses, that extra transition smoothed it
on a different (~150ms) timing, so its color change didn't match the
page background's.

Add a btn-transition utility that mirrors Tailwind's default
transition list minus background-color, so the button's background
tracks the hue animation frame-for-frame like the rest of the app,
while hover/focus feedback (text color, ring, filter) still transitions
normally.
2026-09-22 18:08:58 +02:00
Admin b168df6ca6 Make the hero/spotlight surface tint more transparent
--mf-hero-bg/--mf-spotlight-bg's overlay tint was at 16% alpha, which
after the pastel lightness/chroma boost read as a fairly solid block
of color rather than a soft wash. Halve it to 8% so more of the plain
page background shows through underneath.

Keep the secondary button's color-mix ratio (in layout.css) in sync at
8% too, since it's built to replicate exactly what the surface behind
it looks like.
2026-09-22 18:06:11 +02:00
Admin 8d178884d4 Sync sidebar/queue/player bar color transitions with the main background
Sidebar, Queue, PlayerBar, TabsBar, and the hero/spotlight surfaces all
had an extra CSS `transition: background 0.5s ease-out` (theme-transition/
glass-bar) layered on top of backgrounds that are already animated
frame-by-frame over 600ms by the hue-interpolation loop in +layout.svelte.
That extra transition made them visibly lag behind and take a different
amount of time than the main app-backdrop, which has no such transition
and therefore tracks the JS animation exactly. Drop the redundant
transition so every hue-driven surface updates in lockstep.

Also fixes a dangling --color-btn-hover: var(--mf-btn-bg-hover) that
referenced an undefined custom property (theme.css never defined
--mf-btn-bg-hover after an earlier partial commit), which silently
broke the secondary button's hover background.
2026-09-22 18:01:25 +02:00
Admin cd2131ef6f Make the dynamic light-mode hue palette more pastel and colorful
The hue-driven light-mode tokens (page bg, hero/modal/spotlight
gradients, hairlines, logo/cover art gradients) leaned nearly
grayscale, with just a whisper of tint. Push tcLight()'s lightness
and chroma up together so every derived surface stays light and soft
but reads as an actual color instead of a faint gray wash, and bump
chroma on --mf-bg/--mf-elevated/--mf-panel-bg/--mf-bar-bg/--mf-hairline
and the logo/cover gradient stops the same way.

Scoped to light mode only: dark mode's accents are meant to read as
bright highlights against near-black surfaces, where a pastel wash
would just look washed out rather than colorful.
2026-09-22 17:55:39 +02:00
Admin d6bff15f11 Replace secondary button's backdrop-blur with an equivalent fixed color
backdrop-blur samples whatever is actually behind the element, so two
secondary buttons with identical classes could render differently
depending on nearby content (e.g. dark album art bleeding into the
blur near the spotlight card). Replace it with a fixed color-mix that
reproduces the same result mathematically instead of sampling pixels:
45% --mf-elevated over the hero/spotlight surface's own tint color
(16% --mf-hero-tint over --mf-bg, the same ratio hero-surface itself
uses), computed via CSS color-mix() rather than a backdrop-filter.

Verified against the previous blur version: both now compute to the
identical color(srgb 0.9329 0.9611 0.9332), matching what the blurred
button rendered against the same hero background, but fixed so every
secondary button is now guaranteed pixel-identical regardless of what
happens to be nearby.
2026-09-22 17:50:59 +02:00
Admin b82fca0bc2 Drop backdrop-blur on secondary for a consistent hue-tied tint
Sampling whatever's behind the button via backdrop-filter meant two
secondary buttons could render differently depending on what happened
to be nearby (e.g. dark album art bleeding into the blur), so
"Explorar musica" and "Aleatorio" ended up visibly different colors
despite being the same variant. Replace it with a fixed, hue-tied
translucent tint (bg-accent-tint, the same token family accent already
uses) so every secondary button computes to the identical color, ties
into the same dynamic accent hue as the rest of the app, and reads as
a softer sibling of the accent button instead of an unrelated gray.

Hover now steps up to bg-accent-btn (same hue family, stronger) instead
of a flat, hue-agnostic gray. Removed the now-unused --mf-btn-bg-hover/
--color-btn-hover tokens.
2026-09-22 17:34:34 +02:00
Admin 4fbd4fa81d Make secondary button translucent so it tracks whatever surface it's on
bg-elevated is fully opaque and only hue-matches --mf-bg (the plain
page background), so it stayed stark white on the hero/spotlight cards,
which use a separately-computed, more saturated gradient (--mf-hero-bg/
--mf-spotlight-bg). An opaque fill can never pick up that color no
matter what hue it's tied to.

Make it translucent (bg-elevated/45) with backdrop-blur-lg so it
samples and lightly tints whatever is actually behind it, hero card,
spotlight card, or plain background, instead of only matching one of
them.
2026-09-22 17:31:22 +02:00
Admin 68b5709d88 Tie secondary button color to the page background instead of a flat tint
secondary was fully bg-transparent (invisible at rest) or a flat neutral
overlay (clashed next to accent-tinted buttons and looked different
depending on what surface it sat on). Use bg-elevated instead, which is
already derived from the same hue as --mf-bg, so the button is always a
faint, minimal step above whatever page background it's on, and shifts
with it automatically.

Drop the now-unused --color-btn/--mf-btn-bg tokens.
2026-09-22 17:23:57 +02:00
Admin 434b2c1305 Make the secondary button adapt to shifting backgrounds
secondary used a flat, static tint that only worked over a plain
surface, so it looked out of place on the hero's hue-shifting gradient.
Add a backdrop blur (no border, per feedback) so it always reads
against whatever's actually behind it. Gave the button its own
background tokens instead of aliasing --mf-surface-2, and dropped the
now-unused --color-btn-glass tokens left over from the removed glass
variant.
2026-09-22 17:15:53 +02:00
Admin 9064ef687c Changes on the design 2026-09-22 17:10:26 +02:00
Admin c93f3d352c Revert "Make the glass button variant more transparent and soften accent text"
This reverts commit f52efa0f87.
2026-09-22 16:45:33 +02:00
Admin a9bfcbbe2f Make the glass button variant more transparent and soften accent text
The secondary/glass Button variant used a flat, fairly opaque black or
white tint with no blur, so it didn't read as glass and clashed with
the hue-shifting hero background. Add a backdrop blur and a hairline
border (matching the app's existing glass-bar/glass-panel treatment)
and lower the tint opacity.

Wire up the already-computed but unused --mf-accent-muted token as
--color-accent-muted, and use it for the accent Button variant's text
instead of the higher-chroma --color-accent-soft, so it reads softer.
2026-09-22 16:42:56 +02:00
Admin 717e939038 Fix permanent login failure after Zitadel restarts
getOidcConfig() memoized the discovery promise even when it rejected,
so a single failed OIDC discovery (e.g. Zitadel not ready yet when the
dev server starts) permanently broke /auth/login until the process was
restarted. Clear the cached promise on rejection so it retries.
2026-09-22 16:29:58 +02:00
Admin dd1cb8a5d1 Clean up backend and web-player leftovers
Remove dead scaffold/config cruft and stale references left over from
recent refactors: dotnet's WebApi.http template file, the AppSettings
PascalCase/Dockerfile workaround (renamed to appsettings.json to match
convention), empty directories left behind by the auth-route and
folder-structure moves, a stale ESLint ignore, and the now-fully-applied
frontend refactor plan doc.
2026-09-22 16:08:44 +02:00
Admin 7c9399a342 Translate frontend-refactor-plan.md to English
Same content and structure, just in English now and reformatted with
prettier for consistent table alignment.
2026-09-22 14:35:36 +02:00
Admin a3ec9d19d3 Translate paginas.md to English, clean up CLAUDE.md tone, drop finished plan
Rewrites CLAUDE.md without em dashes and with plainer phrasing, and
translates docs/paginas.md to English the same way. The folder
structure plan is done, so it gets removed instead of kept around as
a stale reference.
2026-09-22 14:29:50 +02:00
Admin 5f2b5bd5a2 Translate CLAUDE.md to English and document new folder structure
Fixes the stale /login reference (now /auth) and adds a Folder
structure section covering lib/state, lib/data, lib/utils,
components/layout and the ui/ category split from the recent
reorganization.
2026-09-22 14:24:07 +02:00
Admin 3bb4e66ad0 Reflect completed auth/user route consolidation in docs 2026-09-22 14:21:00 +02:00
Admin fbb102ffbd Consolidate auth routes under /auth and rename /u to /user
Moves the login screen from /login to /auth, logout from /logout to
/auth/logout (alongside the existing /auth/login and /auth/callback
endpoints), and the public profile route from /u/[id] to /user/[id].

AUTH_REDIRECT_URI and AUTH_POST_LOGOUT_URI already point at
/auth/callback and / respectively, so no Zitadel config change is
needed for the auth move.
2026-09-22 14:20:46 +02:00
Admin 76f7535ffa Update folder structure plan registro after F1-F4/F6 2026-09-22 14:16:37 +02:00
Admin 7fcf0645da Categorize components/ui into primitives, overlay, media, forms and layout
Splits the flat 38-component ui/ folder into primitives/ (generic
controls), overlay/ (menus/modals), media/ (music-domain display
components), forms/ (composite entity forms) and layout/ (page
scaffolding), and updates every import path accordingly.
2026-09-22 14:16:25 +02:00
Admin 50618865e7 Move app chrome components into components/layout 2026-09-22 14:12:11 +02:00
Admin fe2f6a785a Move format helpers into lib/utils 2026-09-22 14:11:30 +02:00
Admin 53fc24d4bc Move domain data helpers into lib/data 2026-09-22 14:10:55 +02:00
Admin ff5fa501af Remove leftover old paths from the lib/state move 2026-09-22 14:10:15 +02:00
Admin 6e9883018a Add folder structure reorganization plan 2026-09-22 14:09:37 +02:00
Admin a985ff4fb3 Move rune-based state modules into lib/state 2026-09-22 14:09:36 +02:00
Admin 63018a7c72 Move shared infrastructure to a separate Infrastructure repo
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger no longer run as part
of this stack. They're shared services (used by Musify, Ping and
docker-manager) that now live in a separate Infrastructure repository
and must already be running.

deploy/compose.yml keeps only what's specific to Musify: its own
database connection, its own OIDC app and storage bucket (created
against the shared Zitadel/SeaweedFS via zitadel-init/seaweedfs-init),
and the application containers. A new wait-for-postgres one-shot
replaces the in-file Postgres health check, which no longer works now
that Postgres lives in a different compose project.

compose.prod.yml joins Infrastructure's infra-net network instead of
running its own copies. Docs and READMEs updated to match.
2026-09-21 22:09:08 +02:00
Admin c5748e9765 Fix route 2026-09-21 22:08:53 +02:00
Admin 23e30d96fc Translate docs to English, rewrite README, drop design handoff folder
Translate all of docs/ from Spanish to English and rewrite the root
README as a short project overview instead of a technical run guide.
Remove web-player/design_handoff_musify_player, an old design-tool
export that was no longer needed.
2026-09-21 18:41:04 +02:00
NotS1mp 3f8d517ee3 Merge pull request 'Design Prot Merge' (#1) from design-prot into master
Reviewed-on: #1
2026-09-21 15:48:16 +00:00
Admin 2662abe460 Remove comments from backend and web-player source
Strip //, /* */ and XML doc comments from all backend .cs files, and
JS/TS/CSS/HTML comments from web-player source (.ts, .svelte, .css),
per the project's no-comments convention.
2026-09-21 17:12:18 +02:00
Admin 1b6d7ecb42 web-player: final sweep and guardrails
Add scripts/check-tokens.mjs (npm run lint:tokens, wired into lint) as
a guardrail: fails if src/**/*.svelte reintroduces arbitrary values
outside the B.4 allowlist, factory rounded-*, h-N w-N pairs (should
be size-N), quarter-step fractional spacing, or literal hex/rgba()
colors.

Cleans up the repo until the script reports 0 violations: ~40 h-N w-N
pairs -> size-N (mostly icons, via a one-off script), ~26 quarter-step
fractional spacings rounded to the nearest integer (delta <=1px, e.g.
py-2.75->py-3, gap-1.75->gap-2), stray factory radii (rounded-sm/lg,
bare rounded) migrated to the matching tokens, and two
tracking-[-0.02em] that matched the existing tracking-display token
exactly but had never been migrated. Removes the
stroke-thin/regular/bold recipes (added earlier, zero consumers) —
decision made, not blocking: strokeWidth={n} (13 values, across
almost every component) isn't migrated to CSS because there's no way
to verify without Docker that lucide-svelte honors stroke-width by
class instead of the prop, and a regression there would be silent
across the whole app; the script documents this gap explicitly
instead of pretending full coverage.

Orphan detector: 0 components with no importers. Removes
docs/design-system-refactor-plan.md (earlier plan, superseded by
frontend-refactor-plan.md). web-player/CLAUDE.md gains a "Styles"
section with the B.4 allowlist and the base components to reuse.
Recalculates the baseline table (A.4) and logs it: 61->47 components,
44->11 arbitrary values (the remaining 11 on the allowlist), 13->8
distinct radii, 0 lint:tokens violations.

Verified with npm run check (0 errors), npm run lint:tokens (0
violations) and npm run build. Smoke-tested /login with no console
errors attributable to this change.
2026-09-21 15:05:07 +02:00
Admin b60a87adb5 web-player: big pages (Explore, Home) and tracks.ts cleanup
This pass also tackles the task deferred earlier: removing
tracks.ts's TrackTarget wrapper and its 8 accessors
(targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/
targetListensCount/isTargetCurrent/queueItemForTarget/
targetForQueueItem). Explore, Home, Mix, menus.svelte.ts and mixes.ts
now use track objects directly, with
toQueueItem/trackFromQueueItem (new, in player.svelte.ts) as the only
converters. Along the way, TypeScript revealed that isExplicit
doesn't exist in the real /tracks schema: Explore's "explicit" badge
was already a no-op before this refactor (always undefined ??
false); the binding is removed instead of pretending support that
never existed.

Explore: GENRE_INFO/genreTiles are extracted to src/lib/genres.ts;
the search logic
(showGroup/capped/matchPlaylists/searchCounts/searchChips/findTopResult)
is extracted to src/lib/search.ts as pure functions parameterized by
filter/query instead of closing over component state. "Top result"
unifies the duplicated button/link with <svelte:element>. Decision
made without blocking: the 4 group blocks (Tracks/Albums/Users/
Playlists) are not collapsed into a single {#each groups} — each
group has genuinely different snippets, context menu and ListRow
props, and a generic config would have added more indirection than it
removed.

Home: uses Page instead of a hand-rolled page-x; the 3 stats become
an {#each}; the hero/spotlight backgrounds (inline style= with CSS
variables) become the hero-surface/spotlight-surface utilities, and
gap-11.5 is replaced by the --spacing-section token (2.875rem) via
gap-section.

Verified with npm run check (0 errors) and npm run build. /explore
without a session redirects to /login because +layout.server.ts
requires allowAnonymousListening from the backend (unreachable
without Docker this session) — pre-existing behavior, not a
regression. No authenticated visual QA; smoke-tested /login with no
console errors.
2026-09-21 14:51:43 +02:00
Admin 5fa901c2e6 web-player: app chrome (layout, navigation, player)
PlayerExtras is merged into PlayerBar, which now renders volume and
the queue button inline and uses the new Slider (a real range input
with thumb thickness controlled via CSS variables) for both the
progress bar and volume, replacing SeekBar and VolumeControl (grep
confirmed SeekBar's `compact` mode was dead code).

Sidebar and TabsBar migrate to appNavLinks(user, counts?), removing
the duplicated nav links between the two. Decision made without
blocking: Sidebar gains the gradient Logo mark it didn't have before,
for visual consistency with MobileHeader and the login screen.

PlayerDock and MobileHeader's z-index is fixed to z-(--z-sticky)
(they're fixed bars, not menus), and SegmentedControl's to
z-(--z-raised), per the plan's note B.1.5. glass-bar and
theme-transition are applied to PlayerBar (both variants), TabsBar,
Queue and Home (hero/spotlight), replacing duplicated
blur/transition styles.

Verified with npm run check (0 errors) and npm run build; smoke-tested
/login with no console errors. No authenticated visual QA (Docker
unavailable this session).
2026-09-21 14:39:20 +02:00
Admin 8975e43729 web-player: forms (CoverForm, SegmentedControl, Chip)
CoverForm.svelte is the shared skeleton (form + enhance + cover +
error message + buttons) for AlbumForm and PlaylistForm, which now
only keep their own fields via the `fields` snippet (next to the
cover) and `extra` (full width — not literally in the spec, but
needed: the album description and the playlist visibility picker
don't fit in the column next to the art).

ImageDropzone gains `size="hero"` (size-cover-hero + rounded-art-lg)
and an `onselect` callback; the upload-track page now uses this
component instead of reimplementing the cover picker by hand (wrapped
in {#key} so it can reset after publishing). Its empty-state icon
goes from 64px to icon-xl (36px), as the plan recommends.

SegmentedControl.svelte (generic, N options) replaces PlaylistForm's
Private/Public picker. Chip.svelte replaces Explore's filter chips
(selected/count) and the suggested-genre chips in "No results" (href,
outline style).

@utility field-control in layout.css unifies the repeated class
string shared by Input and Textarea.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-21 00:58:52 +02:00
Admin 1bdd7ec9f1 web-player: headers, dialogs and typography
PageHeader absorbs CollectionHeader
(title/eyebrow/description/meta/cover?/actions?/align?): without a
cover it behaves like the old simple PageHeader (inline actions),
with a cover like the old CollectionHeader (actions below). It sets
its own bottom margin (mb-7/8); the mt-* each page used to add
afterward are removed. Migrates the 9 places that used either one
(albums, library, playlists, upload, Explore x2, liked, mix,
profile). CollectionHeader is removed.

AlbumHeader and PlaylistHeader are inlined into their pages and
removed. Decision (recommended, not blocking): PlaylistHeader gains
icons on Edit/Delete/Play, matching what AlbumHeader already had.

New ConfirmDialog replaces the two delete dialogs (album and
playlist), which had different typography. Modal now uses
.glass-panel (instead of duplicating the inline style=) and
z-(--z-modal); its eyebrow and title use the
text-eyebrow/text-display-3 recipes.

SectionHeading gains `count?` (a "N results" line, replacing 4 copies
in Explore) and `href?`/`linkLabel?` ("See all", replacing Home's).
Applies typography recipes (text-display-2/3, text-eyebrow,
text-body) and width tokens (max-w-prose-sm, max-w-hero) across
EmptyState, +error, "No results", and the Home/Queue/Sidebar hero and
stats. The "You haven't listened to anything yet" empty state
(duplicated twice on Home) becomes a local snippet.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-21 00:48:23 +02:00
Admin 964dfc809f web-player: cards and grids (MediaCard)
MediaCard now builds its own art (src/trackIds via Artwork
size="fill", or a custom art snippet) and its own play button
(onPlay, with a scrim and a glass PlayButton on hover) instead of
relying on each wrapper passing it a snippet. Replaces AlbumCard,
PlaylistCard and MixTile, which are removed along with
MediaGrid.svelte (their grids move to the
grid-cards/grid-cards-lg/grid-tiles/grid-wide @utility recipes).

format.ts gains albumMeta() and playlistMeta() (previously ad-hoc
calculations inside each card). Explore's genre tile moves from an
inline style= gradient to the genre-tile @utility recipe +
--tile-hue. Along the way, fixes the "follower/s" pluralization on
the user profile that had been missed.

Delta documented: the art's hover-zoom and the scrim+PlayButton
(previously exclusive to MixTile) become MediaCard behavior gated on
the onPlay prop — they'll apply to any future card that uses it, for
consistency.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-21 00:30:30 +02:00
Admin 8cea298b2a web-player: track tables (TrackList)
Add TrackList.svelte, replacing TrackTable+TrackRow+TrackIndexCell+
TrackMeta and the 6 track tables (library, liked, mix, album,
playlist, album's library picker): declarative columns
(added/uploaded/plays) with fixed widths as module constants, an
index with an EQ indicator for the active track (or an overlay on the
art when index={false}, for the library picker), and a rowAction that
builds the form+IconButton (removeTrack/addTrack/deleteTrack) or a
direct onclick (Like).

AlbumTrackTable, PlaylistTrackTable and LibraryPicker are inlined
into their pages and removed, along with the 4 components TrackList
replaces.

Decision (recommended by the plan itself, not blocking): remove the
"Album" column, which in all 3 tables that had it always showed "—"
(a placeholder with no real data).

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-21 00:21:15 +02:00
Admin fb4a8ccc0e web-player: rows (ListRow, MediaIdentity, EqBars)
Add MediaIdentity.svelte (renames and generalizes TrackTitleCell,
purely presentational: art + title + subtitle, no click handling)
and ListRow.svelte on top of it (href|onclick, active, size xs/sm/lg,
variant row|card, leading/trailing/overlay). Also used by
player/TrackInfo... (TrackInfo itself is left for a later pass, which
already has an explicit task to touch that file).

Migrated to ListRow: SearchResultRow (Explore, 4 groups — component
removed), Home (Continue listening, Popular, spotlight, Your
playlists), Queue (Up next), Sidebar (playlists). The 6 track tables
(AlbumTrackTable, PlaylistTrackTable, library, liked, mixes,
LibraryPicker) migrate to MediaIdentity with their own local
use:pressable, since TrackList doesn't exist yet.

EqBars gains an `overlay` prop (scrim/45 background, on-art color)
replacing NowPlaying.svelte (removed). Along the way, fixes 6
plural() calls that a previous pass had left unmigrated (liked,
library, mixes, playlists, PlaylistHeader, Home).

Deltas documented in the plan's log: Sidebar's active row now uses
the accent tint (previously neutral gray); Home's "Your playlists"
card loses its redundant author line and its radius becomes
rounded-art; Queue rows lose the art's opacity-80.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-21 00:14:20 +02:00
Admin 14ee1dc45d web-player: unify the context menu (ContextMenu) + AccountMenu
Add src/lib/components/ui/ContextMenu.svelte, replacing
TrackContextMenu and AlbumContextMenu (109 and 119 lines, 90%
identical): same positioning function (contextMenuPosition, exported
from the module, using MENU_WIDTH/SUBMENU_WIDTH moved to config.ts),
same backdrop, same Escape handling, same generic "Add to playlist"
submenu via {items, playlistAction, playlists}. Uses MenuItem for
actions (previously each reimplemented its own classes by hand) and
Artwork size="xs" for playlist thumbnails.

menus.svelte.ts (createTrackMenu) adapts to the new
contextMenuPosition; Home, Explore (track and album menus) and Mix
are migrated.

AccountMenu: panelClass (free-form Tailwind classes) becomes prop
width: 'sm'|'md'; adds Escape-to-close (accessibility improvement, it
didn't close before); TopBar and MobileHeader no longer wrap it in a
duplicate div.relative (its own root already is one). The panel's
z-index becomes z-(--z-menu), matching ContextMenu (previously z-20
in TopBar / no explicit z in MobileHeader).

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-20 23:05:53 +02:00
Admin 1320b12699 web-player: TypeScript helpers (partial)
Add src/lib/actions/pressable.ts and clickOutside.ts (Svelte actions;
role/tabindex stay static in markup for SSR accessibility, the action
only adds click + Enter/Space) and src/lib/toggle.svelte.ts
(createToggle), used by playlists.svelte.ts and queuePanel.svelte.ts.

format.ts: adds plural(n, one, many); migrates the repo's 15 manual
pluralizations, unifying them to lowercase (visible diff in
Playlists, Home, liked, Explore).

player.svelte.ts: adds toQueueItem, isQueueCurrent, playAllOrToggle,
playShuffled; renames addToQueue -> playNextItem (the old name was
misleading: it inserts next, not at the end). Migrates the 5 places
that repeated the isCurrentQueue/playAll pattern (album, playlist,
liked, mix, Home). Simplifies liked.svelte.ts and recentlyPlayed.ts
with object spread instead of rebuilding every field by hand.

navigation.svelte.ts: adds searchHref (migrates the duplicated
buildHref from TopBar and Explore) and appNavLinks (added; its
adoption in Sidebar/TabsBar is left for a later pass, as noted
there).

menus.svelte.ts: createTrackMenu() replaces the manual context-menu
state (contextMenu + contextMenuStateFor + addToQueue) in Home,
Explore and Mix.

Deliberately left pending: removing tracks.ts's TrackTarget wrapper
and its 8 accessors is deferred — it's the riskiest change in the
plan, touches types across Home/Explore/Mix/menus, and couldn't be
visually verified without Docker this session.

Verified with npm run check/build.
2026-09-20 23:01:13 +02:00
Admin aa20e70671 web-player: buttons (Button, IconButton, PlayButton)
Button: subtle variants (primary/secondary, left unchanged per the
user's call), glass (formerly secondaryOnGlow, with its own
font-normal), accent, strong (cta-strong/ink, replacing the `!`
overrides on the Home hero), danger. Uses the --color-btn* tokens
instead of bg-[var(--mf-btn-...)]. Radius unified to rounded-control.

IconButton: adds shape (square/round), size (xs/sm/md/lg), tone
(muted/subtle/plain), pressed (aria-pressed + text-accent),
href/reload (links), surface (permanent background + hover to
surface-hover instead of the generic `hover` alias, which matched
surface-2 and gave no contrast).

New PlayButton: a single place for the play/pause SVGs, strong/glass
variants, as="span" for non-interactive overlays. Replaces
TransportControls' main button, the MixTile overlay, and the "Top
result" circle in Explore.

Migrated to IconButton: close queue, "Create playlist" (Sidebar),
Like (TrackInfo, with pressed), queue (PlayerExtras), back (TopBar),
login (MobileHeader), and the row actions in tables (remove/add, size
xs). BackLink uses size-icon-sm and drops a duplicate hover.

Decision (not blocking): remove TopBar's decorative "forward" button
and PlayerExtras' decorative AlignJustify icon (visually dead code).
TransportControls' buttons (shuffle/prev/next/repeat) are
deliberately left unmigrated: they currently have no background or
hit-box of their own, and IconButton always applies a fixed-size
box — migrating them would have added a visible hover pill that
couldn't be verified without Docker this session.

Verified with npm run check/build.
2026-09-20 22:48:08 +02:00
Admin a4ca1f32d3 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 58eb670fed web-player: unify Artwork and Avatar
Add src/lib/components/ui/Artwork.svelte and Avatar.svelte (B.3) and
migrate every use of Cover, PlaylistArt, MixArt and ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explore, mix, user profile, TopBar,
MobileHeader). Remove the 4 old components.

Artwork: size decides both dimension and radius automatically
(cover-*/radius tokens from the earlier token pass); fallback chain
is src -> mosaic (>=4 trackIds) -> first cover -> placeholder,
cascading if each step fails (previously split across 3 components
with slightly different logic each). Adds a 'fill' size (aspect-square
w-full), not in the spec, needed for fluid grid cards until MediaCard
lands later.

TrackTitleCell's `coverSize` prop (free-form Tailwind classes)
becomes `size: 'sm'|'lg'`, delegating to Artwork. SearchResultRow
loses its `kind` prop and the per-type radius (now decided by
Artwork/Avatar).

Product decision (not blocking): remove the name under the profile
avatar on /u/[id] since it duplicated the CollectionHeader title.

Small radius/size deltas are documented in the plan's README
(auto-radius by size replaces ad-hoc classes; see table B.1.1/B.1.2).

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
2026-09-20 22:39:05 +02:00
Admin 7b1b81c45a web-player: single source of truth for theme, no light-mode flash
Remove the duplication of the ~18 per-hue dynamic tokens between
theme.css and buildThemeTokens(): theme.css now only defines fixed
neutrals, surfaces, layout and z-index; hue/mode-dependent values
come solely from tokens.ts, injected via SSR as an inline <style> in
+layout.svelte (tokensToCss helper) and reapplied on every hue
change. app.html sets data-theme before first paint by reading
localStorage, removing the dark flash when light mode was saved.

The two inline-style backgrounds become @utility
app-backdrop/app-vignette; the main's bottom padding becomes
@utility app-main, driven by --mf-dock-h via [data-has-track].

Note: <style>{expr}</style> doesn't interpolate in Svelte (it
compiles as component-scoped CSS) — the SSR block is injected with
{@html `<style>${css}</style>`} since the content is fully generated
(no user input).

Verified in the browser: --mf-accent/--mf-bg resolve correctly during
SSR, data-theme='light' applies before the first render with no
flash.
2026-09-20 22:26:37 +02:00
Admin ea9bf07126 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 11789e78fe web-player: base design system tokens
Add, additively (no component migration yet), the tokens from
section B.1 of the refactor plan: radii (tag/thumb), cover and icon
sizes, container widths, typographic tracking/leading, button colors
(--color-btn*), typography @utility recipes (text-display-*,
text-eyebrow, text-body, text-count), icon stroke
(stroke-thin/regular/bold), surfaces/grids (glass-bar, grid-cards*,
theme-transition), and the .animate-enter stagger via --i. Add
layout variables (--mf-player-side-w, --mf-menu-w, --mf-submenu-w,
--mf-range-*) and semantic z-index in theme.css. Clean up unused
color utilities (--color-accent-dim/glow/hair/line/muted,
--color-on-accent) and remove --mf-sidebar-bg (no importers) from
theme.css and tokens.ts. Scrollbar moves to tokens with a light-mode
variant.

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

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