Author SHA1 Message Date
Admin 4f8ccddd08 Fix
CI/CD / Backend tests (.NET) (push) Successful in 1m47s
CI/CD / web-player (check + lint) (push) Successful in 1m23s
CI/CD / Deploy to production (push) Skipped
2026-09-23 16:00:39 +02:00
Admin 36560a8f98 Add actions
CI/CD / Backend tests (.NET) (push) Failing after 24s
CI/CD / web-player (check + lint) (push) Failing after 1m19s
CI/CD / Deploy to production (push) Skipped
2026-09-23 05:28:11 +02:00
Admin 76d3153ef8 Changes on header 2026-09-23 01:40:16 +02:00
Admin 78e3de9958 Simplify design 2026-09-23 01:33:25 +02:00
Admin ffaa2fe210 Changes on the colors 2026-09-22 22:23:06 +02:00
AdminandClaude Sonnet 5 6fb4da3edb 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:40:23 +02:00
AdminandClaude Sonnet 5 0e2eec9e90 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:38:03 +02:00
AdminandClaude Sonnet 5 9f5106aecf 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:33:25 +02:00
AdminandClaude Sonnet 5 29ab2e49d9 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:27:17 +02:00
AdminandClaude Sonnet 5 d4c1596e99 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:19:58 +02:00
AdminandClaude Sonnet 5 8ad358a0a2 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:14:46 +02:00
AdminandClaude Sonnet 5 e069c16ea2 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:05:03 +02:00
AdminandClaude Sonnet 5 062a643241 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:02:33 +02:00
AdminandClaude Sonnet 5 3d7dbf58ac 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:58:39 +02:00
AdminandClaude Sonnet 5 9e2e353054 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:53:21 +02:00
AdminandClaude Sonnet 5 5a77e05f3d 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:50:23 +02:00
AdminandClaude Sonnet 5 5bdf7cfb06 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:46:25 +02:00
AdminandClaude Sonnet 5 32d0a6811c 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:02:10 +02:00
AdminandClaude Sonnet 5 3d9e83c905 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:57:16 +02:00
AdminandClaude Sonnet 5 7536f239b2 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:46:17 +02:00
AdminandClaude Sonnet 5 ecdddaa15e 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:41:03 +02:00
AdminandClaude Sonnet 5 8706b8d775 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:08:58 +02:00
AdminandClaude Sonnet 5 f6c82669d9 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:06:11 +02:00
AdminandClaude Sonnet 5 9d68879b01 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:01:25 +02:00
AdminandClaude Sonnet 5 832dc66751 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:55:39 +02:00
AdminandClaude Sonnet 5 cd913a6d17 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:50:59 +02:00
AdminandClaude Sonnet 5 626283ba50 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:34:34 +02:00
AdminandClaude Sonnet 5 df70290631 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:31:22 +02:00
AdminandClaude Sonnet 5 28107ac09e 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:23:57 +02:00
AdminandClaude Sonnet 5 39f04f5d57 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:15:53 +02:00
Admin b5944d0404 Changes on the design 2026-09-22 17:10:26 +02:00
Admin 57bacabcc4 Revert "Make the glass button variant more transparent and soften accent text"
This reverts commit f52efa0f87.
2026-09-22 16:45:33 +02:00
AdminandClaude Sonnet 5 f52efa0f87 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:42:56 +02:00
AdminandClaude Sonnet 5 ab51d9256e 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:29:58 +02:00
AdminandClaude Sonnet 5 aa4b81b9a7 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:08:44 +02:00
AdminandClaude Sonnet 5 5c8c0ae318 Translate frontend-refactor-plan.md to English
Same content and structure, just in English now and reformatted with
prettier for consistent table alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:35:36 +02:00
AdminandClaude Sonnet 5 c50654534d 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:29:50 +02:00
AdminandClaude Sonnet 5 2a2754379f 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:24:07 +02:00
AdminandClaude Sonnet 5 6ca5482b97 Reflect completed auth/user route consolidation in docs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:21:00 +02:00
AdminandClaude Sonnet 5 eb47085e5b 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:20:46 +02:00
AdminandClaude Sonnet 5 b1a053a346 Update folder structure plan registro after F1-F4/F6
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:16:37 +02:00
AdminandClaude Sonnet 5 705f23c78f 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:16:25 +02:00
AdminandClaude Sonnet 5 8a76fbb78e Move app chrome components into components/layout
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:12:11 +02:00
AdminandClaude Sonnet 5 fc427f1b19 Move format helpers into lib/utils
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:11:30 +02:00
AdminandClaude Sonnet 5 b63e30d2f8 Move domain data helpers into lib/data
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:10:55 +02:00
AdminandClaude Sonnet 5 f1220a688a Remove leftover old paths from the lib/state move
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:10:15 +02:00
AdminandClaude Sonnet 5 9770580f7f Add folder structure reorganization plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:09:37 +02:00
AdminandClaude Sonnet 5 f98c47f5ed Move rune-based state modules into lib/state
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:09:36 +02:00
22 changed files with 294 additions and 286 deletions
+20 -19
View File
@@ -12,7 +12,7 @@ concurrency:
jobs: jobs:
backend-tests: backend-tests:
name: BackEnd name: Backend tests (.NET)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
defaults: defaults:
@@ -35,7 +35,7 @@ jobs:
run: dotnet test Musify.slnx --no-build -c Release run: dotnet test Musify.slnx --no-build -c Release
web-player-checks: web-player-checks:
name: WebPlayer name: web-player (check + lint)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
defaults: defaults:
@@ -52,10 +52,10 @@ jobs:
- run: npm ci - run: npm ci
- name: Type-check - name: Type-check (svelte-check)
run: npm run check run: npm run check
- name: Lint - name: Lint (prettier + eslint + design tokens)
run: npm run lint run: npm run lint
# Runs on the production host itself, through the existing self-hosted # Runs on the production host itself, through the existing self-hosted
@@ -69,7 +69,7 @@ jobs:
# deploy reuses it instead of a fresh checkout, since .env.prod is # deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there. # untracked and only lives there.
deploy: deploy:
name: Deploy name: Deploy to production
needs: [backend-tests, web-player-checks] needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push' if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production] runs-on: [production]
@@ -81,7 +81,6 @@ jobs:
- name: Pull latest master - name: Pull latest master
run: | run: |
set -euo pipefail set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
@@ -92,24 +91,26 @@ jobs:
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
$COMPOSE up -d wait-for-postgres keys-init migrate $COMPOSE up -d wait-for-postgres keys-init migrate
# Both jobs verify themselves through the public auth./s3. URLs, served # zitadel-init/seaweedfs-init verify themselves through the PUBLIC
# by the shared edge nginx (Infrastructure's edge/), and are idempotent. # auth./s3. URLs (see deploy/compose.yml), so they only succeed once a
- name: Create OIDC app and S3 bucket # real domain is registered and nginx is serving it (see the TODO
# below and deploy/README.md). Tolerated for now so a red step here
# doesn't block the rest of the deploy; drop the `|| true` once that's
# in place, so a real regression here fails the job again.
- name: Create OIDC app + S3 bucket (pending public domain)
run: | run: |
set -euo pipefail
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init $COMPOSE up -d seaweedfs-init zitadel-init || echo "expected to fail until the production domain + nginx edge exist"
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 - name: Build and start apps
run: | run: |
set -euo pipefail set -euo pipefail
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player $COMPOSE --profile apps up -d --build api worker gateway web-player
# TODO once a domain is registered and pointed at this server: merge
# Musify's vhosts (deploy/nginx/templates) into the existing
# gitea-nginx config instead of running the `nginx` service from
# compose.prod.yml, since gitea-nginx already owns host ports 80/443
# for gitea.ikerdemo.cat. See "Continuous deployment" in
# deploy/README.md.
+1
View File
@@ -28,4 +28,5 @@ Thumbs.db
# Deploy secrets and runtime state # Deploy secrets and runtime state
deploy/keys/ deploy/keys/
deploy/secrets/ deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/ deploy/zitadel/.output/
@@ -37,7 +37,7 @@ namespace Musify.Infrastructure.Persistence
} }
else if (entry.State == EntityState.Modified) else if (entry.State == EntityState.Modified)
{ {
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now; entry.Entity.UpdatedAt = now;
} }
} }
} }
@@ -13,13 +13,9 @@ namespace Musify.Infrastructure.Persistence
public class Database(DatabaseConfiguration configuration) public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase : DbContext, AppIDatabase
{ {
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -18,7 +18,10 @@ namespace Musify.Infrastructure.Persistence
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddDbContext<Database>(); serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>()); serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
@@ -44,11 +43,6 @@ namespace Musify.Infrastructure.Tests.Persistence
Assert.Equal(originalCreatedAt, user.CreatedAt); Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt, Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
await using var reader = fixture.CreateDatabase();
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
Assert.True(stored.UpdatedAt > stored.CreatedAt,
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
} }
} }
} }
+13 -6
View File
@@ -7,8 +7,8 @@
# subdomains), # subdomains),
# 2. replace every CHANGE_ME with a strong random value # 2. replace every CHANGE_ME with a strong random value
# (`openssl rand -hex 24`), # (`openssl rand -hex 24`),
# 3. point each domain below at the server and add its vhost to the shared # 3. point each *_DOMAIN below at the server, proxied/TLS-terminated the
# edge (Infrastructure's edge/conf.d). # same way.
# #
# Requires ../../../Infrastructure running first, on the same Docker host, # Requires ../../../Infrastructure running first, on the same Docker host,
# with its own `infra-net` network already created. See # with its own `infra-net` network already created. See
@@ -18,9 +18,14 @@
DOTNET_ENVIRONMENT=Production DOTNET_ENVIRONMENT=Production
# --- Domains ------------------------------------------------------------- # --- Domains -------------------------------------------------------------
# Public URLs. They must match the vhosts of the shared edge nginx # Each *_DOMAIN drives its own nginx vhost (deploy/nginx/templates); the
# (Infrastructure's edge/conf.d), which is what serves them; the domains are # PUBLIC_*_URL/S3_PUBLIC_URL below must agree with it (https://<domain>).
# independent, they don't need to share a root. WEB_DOMAIN=musify.example.com
API_DOMAIN=musifyapi.example.com
STREAM_DOMAIN=musifystream.example.com
AUTH_DOMAIN=auth.example.com
S3_DOMAIN=s3.example.com
PUBLIC_WEB_URL=https://musify.example.com PUBLIC_WEB_URL=https://musify.example.com
PUBLIC_API_URL=https://musifyapi.example.com PUBLIC_API_URL=https://musifyapi.example.com
PUBLIC_STREAM_URL=https://musifystream.example.com PUBLIC_STREAM_URL=https://musifystream.example.com
@@ -66,4 +71,6 @@ 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
+75 -43
View File
@@ -13,7 +13,7 @@ wherever you actually cloned it.
|---|---| |---|---|
| `compose.yml` | Musify's own services, 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 Musify's ports to the host. | | `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. |
| `compose.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. | | `compose.prod.yml` | Production overlay: adds the nginx edge and 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 four applications (`api`, `worker`, `gateway`, `web-player`) sit behind
@@ -113,26 +113,31 @@ SeaweedFS is done from the `Infrastructure` repo instead.
## Production ## Production
Musify doesn't run its own nginx: the shared edge in Infrastructure On a server with a domain:
(`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 1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled
in. It creates the `infra-net` network this stack joins. in. It creates the `infra-net` network this stack joins.
2. Bring up its `edge/` (see Infrastructure's README) and add Musify's 2. From this repository's root:
vhosts and certificate there (`edge/conf.d/musify.conf`). The five
public hostnames (web, api, stream, auth, s3) are independent: they
don't need to share a root domain.
3. From this repository's root:
```sh ```sh
cp deploy/.env.prod.example deploy/.env.prod cp deploy/.env.prod.example deploy/.env.prod
``` ```
Replace every `*.example.com` with your domains (they must match the Fill it in:
edge's vhosts) and every `CHANGE_ME` with a random value
(`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`. 1. Replace every `*.example.com` with your actual domains and every
`CHANGE_ME` with a random value (`openssl rand -hex 24`). Each of
`WEB_DOMAIN`/`API_DOMAIN`/`STREAM_DOMAIN`/`AUTH_DOMAIN`/`S3_DOMAIN` is
independent — they don't need to share a root domain (e.g.
`musify.example.com` + `musifyapi.example.com`, or sibling subdomains of
a domain whose DNS provider doesn't allow multi-level subdomains).
`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 the five `*_DOMAIN` values at the server, proxied and
TLS-terminated the same way.
Then: Then:
@@ -141,45 +146,68 @@ docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compo
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
``` ```
The first command starts Musify's bootstrap jobs (`zitadel-init` and The first command starts Musify's bootstrap jobs and the nginx edge; the
`seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.` second builds and starts the applications, already wired to the OIDC client
URLs, so the edge has to be serving them by then); the second builds and ids the first one produced. Both are idempotent, so running them again
starts the applications, already wired to the OIDC client ids the first one deploys a new version (`... --profile apps up -d --build` alone is enough for
produced. Both are idempotent, so running them again deploys a new version an update once the stack already exists).
(`... --profile apps up -d --build` alone is enough for an update once the
stack already exists).
Nothing in this stack publishes a port; everything is reachable only on the Only nginx publishes ports (80/443). Every other Musify service, and all of
internal `infra-net` network, through the edge. Infrastructure's, is reachable only on the internal `infra-net` network.
### Continuous deployment ### Continuous deployment
`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every `.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 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 deploy job runs on `gitea-runner`, the existing self-hosted Gitea Actions
the production host (`~/gitea/docker-compose.yml`), configured by runner on the production host itself (`gitea.ikerdemo.cat`, in
`~/gitea/runner-data/config.yaml` (`CONFIG_FILE`): job containers are pinned `~/gitea/docker-compose.yml`), which also carries a `production:host`
to the `gitea_gitea` network so `actions/checkout` can resolve `gitea`, label registered directly in `~/gitea/runner-data/.runner` (act_runner
`ubuntu-latest` is `node:22` (JS actions need Node inside the job reads `labels` from that file on every start, not from
container), and a `production:host` label runs steps natively. `GITEA_RUNNER_LABELS`, once a runner is already registered). Running in
native mode means the label's steps execute inside the runner container
Native mode means the label's steps execute inside the runner container itself rather than spinning up a fresh one — that container has
itself rather than a fresh one. That container has `/var/run/docker.sock` `/var/run/docker.sock` mounted, so `docker`/`docker compose` there still
mounted, so `docker`/`docker compose` there still control the host's real control the host's real Docker daemon (sibling containers, "Docker outside
Docker daemon (sibling containers, "Docker outside of Docker"). Its compose of Docker"). Its compose service also bind-mounts `/home/ubuntu/deploys`
service also bind-mounts `/home/ubuntu/deploys` into itself at the *same* into itself at the *same* path, which matters because compose's own
path, which matters because compose's own relative bind mounts (`./keys`) relative bind mounts (`./keys`, `./nginx/...`) are resolved by the host
are resolved by the host daemon against that literal path; mounting it daemon against that literal path — mounting it anywhere else would silently
anywhere else would silently bind the wrong (empty) directory. bind the wrong (empty) directory.
`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`) `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 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. run since `deploy/.env.prod` is untracked and only lives there.
`/home/ubuntu/deploys/SharedServices` is the sibling clone of the `/home/ubuntu/deploys/SharedServices` is the sibling clone of the
`Infrastructure` repo (shared services + `edge/`), already running; its `Infrastructure`/`SharedServices` repo, already running (`infra-net`
`.env.prod` shares the Postgres/RabbitMQ/S3 credentials with Musify's own created); its `.env.prod` shares the Postgres/RabbitMQ/S3 credentials with
`deploy/.env.prod` by design (see that repo's README). The edge is deployed Musify's own `deploy/.env.prod` by design (see that repo's README).
from that repo, not from this pipeline.
**Pending DNS on the registered domains:** `deploy/.env.prod`'s five
`*_DOMAIN` values are set (`musify.ikerdemo.cat`, `musifyapi.ikerdemo.cat`,
`musifystream.ikerdemo.cat`, `auth.ikerdemo.cat`, `s3.ikerdemo.cat` —
siblings, not `api.musify.ikerdemo.cat`, because the DNS provider here
doesn't allow multi-level subdomains), but no DNS record points any of them
at the server yet, so:
- `zitadel-init` and `seaweedfs-init` (they verify themselves through the
public `auth.`/`s3.` URLs) fail every deploy until those two resolve and
are actually reachable — the workflow tolerates this on purpose, see its
comments. Until then, `api`/`worker` build and start but can't do
anything OIDC- or storage-dependent (`AUTH_CLIENT_ID`/`WEB_CLIENT_ID`/
`WEB_CLIENT_SECRET` in `.env.prod` stay blank); `gateway`/`web-player`
are unaffected.
- The `nginx` service from `compose.prod.yml` is intentionally **not**
brought up by the deploy job: it would try to publish host ports 80/443,
which `gitea-nginx` already owns for `gitea.ikerdemo.cat` (certbot). Once
the five DNS records above exist, merge Musify's vhosts
(`deploy/nginx/templates`) into `~/gitea/nginx/conf.d` instead — same
nginx, additional `server_name` blocks proxying to Musify's containers
over `infra-net` — rather than running a second nginx that would conflict
on those ports. That also needs a certificate valid for the 5 hostnames
(the existing setup uses certbot/Let's Encrypt, not the Cloudflare Origin
cert `compose.prod.yml`'s nginx assumes — reconcile whichever way when
that's set up).
--- ---
@@ -190,9 +218,13 @@ deploy/
├─ compose.yml # Musify's own services: wait-for-postgres, keys-init, ├─ compose.yml # Musify's own services: wait-for-postgres, keys-init,
│ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player │ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player
├─ compose.dev.yml # dev overlay: host ports ├─ compose.dev.yml # dev overlay: host ports
├─ compose.prod.yml # prod overlay: joins infra-net ├─ compose.prod.yml # prod overlay: nginx edge, 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)
``` ```
The application images are built from `backend/Musify.*.Dockerfile` (build The application images are built from `backend/Musify.*.Dockerfile` (build
+38 -5
View File
@@ -1,10 +1,11 @@
name: musify-prod name: musify-prod
# Production overlay: joins Infrastructure's shared `infra-net` network so # Production overlay: adds the nginx edge and joins Infrastructure's shared
# this project's containers can reach Postgres, Zitadel, SeaweedFS, RabbitMQ # `infra-net` network so this project's containers can reach Postgres,
# and Jaeger by container name, and so the shared edge nginx (Infrastructure's # Zitadel, SeaweedFS, RabbitMQ and Jaeger by container name. None of them
# `edge/`, which owns host ports 80/443 and terminates TLS for every domain) # run as part of this compose project anymore (see
# can reach api, gateway and web-player. Nothing here publishes a port. # ../../../Infrastructure/README.md, which must already be up). Nothing but
# nginx publishes a port; every other service is reachable only on infra-net.
# #
# 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
@@ -19,3 +20,35 @@ services:
zitadel-init: zitadel-init:
volumes: volumes:
- ./.env.prod:/workspace/.env - ./.env.prod:/workspace/.env
# Every vhost proxies through `set $upstream ...; proxy_pass $upstream;`
# with the embedded Docker DNS resolver (see nginx/templates), so nginx
# does not need api/gateway/web-player to exist at its own startup, only
# at request time. That is what lets it start in the FIRST command
# (profile-gated services aren't part of that invocation yet) and still be
# reachable at the auth domain for zitadel-init to provision through.
nginx:
image: nginx:1.27-alpine
container_name: musify-nginx
environment:
# The entrypoint renders /etc/nginx/templates/*.template with envsubst.
# The filter keeps it from eating nginx's own $host / $remote_addr.
MUSIFY_WEB_DOMAIN: ${WEB_DOMAIN}
MUSIFY_API_DOMAIN: ${API_DOMAIN}
MUSIFY_STREAM_DOMAIN: ${STREAM_DOMAIN}
MUSIFY_AUTH_DOMAIN: ${AUTH_DOMAIN}
MUSIFY_S3_DOMAIN: ${S3_DOMAIN}
MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m}
NGINX_ENVSUBST_FILTER: ^MUSIFY_
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/templates:/etc/nginx/templates:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/certs:/etc/nginx/certs:ro
- nginx_cache:/var/cache/nginx
restart: unless-stopped
volumes:
nginx_cache:
-4
View File
@@ -381,10 +381,6 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
# adapter-node rejects request bodies over 512K by default; audio
# uploads are posted to /upload. Keep in line with the edge's
# client_max_body_size.
BODY_SIZE_LIMIT: 1G
ORIGIN: ${PUBLIC_WEB_URL} ORIGIN: ${PUBLIC_WEB_URL}
ZITADEL_ISSUER: ${PUBLIC_AUTH_URL} ZITADEL_ISSUER: ${PUBLIC_AUTH_URL}
ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID} ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID}
+9
View File
@@ -0,0 +1,9 @@
# Shared proxy settings for every Musify vhost.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
@@ -0,0 +1,109 @@
# Musify edge. The nginx entrypoint renders this with envsubst into
# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the
# MUSIFY_* variables set in compose.prod.yml. Each vhost has its own,
# independent domain (see deploy/.env.prod.example) — they don't need to
# share a root domain.
#
# Cloudflare (Full strict) terminates the public edge; this box only needs to
# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to
# a compose service by name through the embedded Docker DNS resolver, so nginx
# re-resolves it instead of caching the IP of a container that may be recreated.
resolver 127.0.0.11 valid=10s;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name ${MUSIFY_WEB_DOMAIN} ${MUSIFY_API_DOMAIN}
${MUSIFY_AUTH_DOMAIN} ${MUSIFY_STREAM_DOMAIN} ${MUSIFY_S3_DOMAIN};
return 301 https://$host$request_uri;
}
# Web player (SvelteKit, adapter-node). Uploads are posted to its own form
# actions, so it needs the large body size too.
server {
listen 443 ssl;
http2 on;
server_name ${MUSIFY_WEB_DOMAIN};
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://web-player:3000;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
server {
listen 443 ssl;
http2 on;
server_name ${MUSIFY_API_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://api:5111;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
# Audio bytes. Buffering off keeps range requests (seeking) responsive.
server {
listen 443 ssl;
http2 on;
server_name ${MUSIFY_STREAM_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://gateway:8081;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
proxy_buffering off;
}
}
server {
listen 443 ssl;
http2 on;
server_name ${MUSIFY_AUTH_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://zitadel:8080;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
grpc_set_header Host $host;
}
}
# Presigned S3 PUT/GET straight from the browser.
server {
listen 443 ssl;
http2 on;
server_name ${MUSIFY_S3_DOMAIN};
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://seaweedfs:8333;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
proxy_request_buffering off;
}
}
+9 -6
View File
@@ -1,16 +1,15 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { import {
clearSessionCookie,
decodeSession, decodeSession,
encodeSession, encodeSession,
readSessionCookie,
refreshSession, refreshSession,
writeSessionCookie sessionCookieOptions,
SESSION_COOKIE
} from '$lib/server/auth'; } from '$lib/server/auth';
import { authConfig } from '$lib/server/config'; import { authConfig } from '$lib/server/config';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
let session = await decodeSession(readSessionCookie(event.cookies)); let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
if ( if (
session && session &&
@@ -18,9 +17,13 @@ export const handle: Handle = async ({ event, resolve }) => {
) { ) {
session = await refreshSession(session); session = await refreshSession(session);
if (session) { if (session) {
writeSessionCookie(event.cookies, await encodeSession(session), event.url); event.cookies.set(
SESSION_COOKIE,
await encodeSession(session),
sessionCookieOptions(event.url)
);
} else { } else {
clearSessionCookie(event.cookies); event.cookies.delete(SESSION_COOKIE, { path: '/' });
} }
} }
-2
View File
@@ -714,9 +714,7 @@ export interface components {
pictureIntentId: string; pictureIntentId: string;
audioIntentId: string; audioIntentId: string;
tags: components["schemas"]["Genre"][];
}; };
Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi";
CreateUserRequest: { CreateUserRequest: {
id: number | string; id: number | string;
@@ -3,21 +3,20 @@
interface Props { interface Props {
selected?: boolean; selected?: boolean;
disabled?: boolean;
count?: number; count?: number;
href?: string; href?: string;
onclick?: () => void; onclick?: () => void;
children: Snippet; children: Snippet;
} }
let { selected = false, disabled = false, count, href, onclick, children }: Props = $props(); let { selected = false, count, href, onclick, children }: Props = $props();
const classes = $derived( const classes = $derived(
href href
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg' ? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${ : `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg' selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}` }`
); );
</script> </script>
@@ -31,5 +30,5 @@
{#if href} {#if href}
<a {href} class={classes}>{@render content()}</a> <a {href} class={classes}>{@render content()}</a>
{:else} {:else}
<button type="button" {onclick} {disabled} class={classes}>{@render content()}</button> <button type="button" {onclick} class={classes}>{@render content()}</button>
{/if} {/if}
-56
View File
@@ -1,56 +0,0 @@
export const TRACK_GENRES = [
{ value: 'Pop', label: 'Pop' },
{ value: 'Rock', label: 'Rock' },
{ value: 'HipHop', label: 'Hip-hop' },
{ value: 'RnB', label: 'R&B' },
{ value: 'Jazz', label: 'Jazz' },
{ value: 'Blues', label: 'Blues' },
{ value: 'Classical', label: 'Clásica' },
{ value: 'Electronic', label: 'Electrónica' },
{ value: 'House', label: 'House' },
{ value: 'Techno', label: 'Techno' },
{ value: 'Trance', label: 'Trance' },
{ value: 'Dubstep', label: 'Dubstep' },
{ value: 'DrumAndBass', label: 'Drum and bass' },
{ value: 'Metal', label: 'Metal' },
{ value: 'Punk', label: 'Punk' },
{ value: 'Reggae', label: 'Reggae' },
{ value: 'Reggaeton', label: 'Reggaetón' },
{ value: 'Country', label: 'Country' },
{ value: 'Folk', label: 'Folk' },
{ value: 'Indie', label: 'Indie' },
{ value: 'KPop', label: 'K-pop' },
{ value: 'Latin', label: 'Latina' },
{ value: 'Soul', label: 'Soul' },
{ value: 'Funk', label: 'Funk' },
{ value: 'Ambient', label: 'Ambient' },
{ value: 'Lofi', label: 'Lo-fi' }
] as const;
export type TrackGenre = (typeof TRACK_GENRES)[number]['value'];
const GENRE_VALUES: ReadonlySet<string> = new Set(TRACK_GENRES.map((genre) => genre.value));
const INTENSE: ReadonlySet<TrackGenre> = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']);
const MELLOW: ReadonlySet<TrackGenre> = new Set(['Classical', 'Ambient', 'Lofi']);
export function isTrackGenre(value: string): value is TrackGenre {
return GENRE_VALUES.has(value);
}
export function areCompatible(a: TrackGenre, b: TrackGenre): boolean {
return !((INTENSE.has(a) && MELLOW.has(b)) || (INTENSE.has(b) && MELLOW.has(a)));
}
export function genreLabel(value: TrackGenre): string {
return TRACK_GENRES.find((genre) => genre.value === value)?.label ?? value;
}
export function findConflict(tags: readonly TrackGenre[]): [TrackGenre, TrackGenre] | undefined {
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
if (!areCompatible(tags[i], tags[j])) return [tags[i], tags[j]];
}
}
return undefined;
}
+2 -20
View File
@@ -52,27 +52,9 @@ export function unwrapOrError<T>(
return result.data; return result.data;
} }
export function apiErrorDetail(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null) return undefined;
const { errors, detail, title } = error as {
errors?: Record<string, unknown>;
detail?: unknown;
title?: unknown;
};
if (errors && typeof errors === 'object') {
const messages = Object.values(errors)
.flat()
.filter((entry): entry is string => typeof entry === 'string');
if (messages.length > 0) return messages.join(' ');
}
if (typeof detail === 'string' && detail !== '') return detail;
if (typeof title === 'string' && title !== '') return title;
return undefined;
}
export function unwrapOrFail( export function unwrapOrFail(
result: { error?: unknown }, result: { error?: unknown },
message: string message: string
): ActionFailure<{ message: string; detail?: string }> | undefined { ): ActionFailure<{ message: string }> | undefined {
return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined; return result.error ? fail(502, { message }) : undefined;
} }
-39
View File
@@ -1,4 +1,3 @@
import type { Cookies } from '@sveltejs/kit';
import * as client from 'openid-client'; import * as client from 'openid-client';
import { EncryptJWT, jwtDecrypt } from 'jose'; import { EncryptJWT, jwtDecrypt } from 'jose';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
@@ -54,44 +53,6 @@ export function sessionCookieOptions(url: URL) {
}; };
} }
const SESSION_CHUNK_SIZE = 3600;
function sessionChunkName(index: number): string {
return index === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}.${index}`;
}
export function readSessionCookie(cookies: Cookies): string | undefined {
const parts: string[] = [];
for (let index = 0; ; index++) {
const part = cookies.get(sessionChunkName(index));
if (part === undefined) break;
parts.push(part);
}
return parts.length > 0 ? parts.join('') : undefined;
}
export function clearSessionCookie(cookies: Cookies, keep = 0): void {
for (const { name } of cookies.getAll()) {
const match =
name === SESSION_COOKIE
? 0
: name.startsWith(`${SESSION_COOKIE}.`)
? Number(name.slice(SESSION_COOKIE.length + 1))
: -1;
if (Number.isInteger(match) && match >= keep) cookies.delete(name, { path: '/' });
}
}
export function writeSessionCookie(cookies: Cookies, token: string, url: URL): void {
const options = sessionCookieOptions(url);
const count = Math.ceil(token.length / SESSION_CHUNK_SIZE);
for (let index = 0; index < count; index++) {
const chunk = token.slice(index * SESSION_CHUNK_SIZE, (index + 1) * SESSION_CHUNK_SIZE);
cookies.set(sessionChunkName(index), chunk, options);
}
clearSessionCookie(cookies, count);
}
export async function encodeSession(session: Session): Promise<string> { export async function encodeSession(session: Session): Promise<string> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return new EncryptJWT({ ...session }) return new EncryptJWT({ ...session })
@@ -7,7 +7,6 @@ import {
unwrapOrFail unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreLabel, isTrackGenre, type TrackGenre } from '$lib/data/trackGenres';
const MAX_TITLE = 100; const MAX_TITLE = 100;
@@ -24,7 +23,6 @@ export const actions: Actions = {
const title = String(form.get('title') ?? '').trim(); const title = String(form.get('title') ?? '').trim();
const audio = form.get('audio'); const audio = form.get('audio');
const cover = form.get('cover'); const cover = form.get('cover');
const rawTags = form.getAll('tags').map(String);
if (title === '' || title.length > MAX_TITLE) { if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
@@ -36,20 +34,6 @@ export const actions: Actions = {
return fail(400, { message: 'Selecciona una portada.' }); return fail(400, { message: 'Selecciona una portada.' });
} }
if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' });
}
if (!rawTags.every(isTrackGenre)) {
return fail(400, { message: 'Alguno de los géneros no es válido.' });
}
const tags: TrackGenre[] = [...new Set(rawTags)];
const conflict = findConflict(tags);
if (conflict) {
return fail(400, {
message: `Los géneros ${genreLabel(conflict[0])} y ${genreLabel(conflict[1])} no se pueden combinar.`
});
}
const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken }); const api = createApiClient({ fetch, accessToken });
@@ -89,8 +73,7 @@ export const actions: Actions = {
body: { body: {
title, title,
pictureIntentId: urls.pictureIntentId, pictureIntentId: urls.pictureIntentId,
audioIntentId: urls.audioIntentId, audioIntentId: urls.audioIntentId
tags
} }
}); });
@@ -13,8 +13,6 @@
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '$lib/components/ui/primitives/Input.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres';
type Status = 'idle' | 'uploading' | 'done' | 'error'; type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done'; type StepState = 'idle' | 'active' | 'done';
@@ -30,8 +28,6 @@
let status = $state<Status>('idle'); let status = $state<Status>('idle');
let acceptedTerms = $state(false); let acceptedTerms = $state(false);
let errorMsg = $state(''); let errorMsg = $state('');
let errorDetail = $state('');
let tags = $state<TrackGenre[]>([]);
let publishedTitle = $state(''); let publishedTitle = $state('');
let audioInput = $state<HTMLInputElement>(); let audioInput = $state<HTMLInputElement>();
@@ -44,7 +40,7 @@
const busy = $derived(status === 'uploading'); const busy = $derived(status === 'uploading');
const canPublish = $derived( const canPublish = $derived(
title.trim() !== '' && audioName !== '' && hasCover && tags.length > 0 && acceptedTerms && !busy title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy
); );
const stepStates = $derived.by<StepState[]>(() => { const stepStates = $derived.by<StepState[]>(() => {
@@ -85,14 +81,6 @@
pickAudio(file); pickAudio(file);
} }
function toggleTag(tag: TrackGenre) {
tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag];
}
function tagBlocked(tag: TrackGenre) {
return !tags.includes(tag) && tags.some((current) => !areCompatible(current, tag));
}
function reset() { function reset() {
title = ''; title = '';
audioName = ''; audioName = '';
@@ -102,8 +90,6 @@
status = 'idle'; status = 'idle';
acceptedTerms = false; acceptedTerms = false;
errorMsg = ''; errorMsg = '';
errorDetail = '';
tags = [];
} }
</script> </script>
@@ -167,7 +153,6 @@
use:enhance={() => { use:enhance={() => {
status = 'uploading'; status = 'uploading';
errorMsg = ''; errorMsg = '';
errorDetail = '';
return async ({ result }) => { return async ({ result }) => {
if (result.type === 'success') { if (result.type === 'success') {
publishedTitle = title; publishedTitle = title;
@@ -175,7 +160,6 @@
} else if (result.type === 'failure') { } else if (result.type === 'failure') {
status = 'error'; status = 'error';
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.'; errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
errorDetail = (result.data?.detail as string | undefined) ?? '';
} else { } else {
status = 'error'; status = 'error';
errorMsg = 'Error inesperado durante la subida.'; errorMsg = 'Error inesperado durante la subida.';
@@ -252,26 +236,6 @@
</Field> </Field>
</div> </div>
<Field label="Géneros">
<div class="flex flex-wrap gap-2">
{#each TRACK_GENRES as genre (genre.value)}
<Chip
selected={tags.includes(genre.value)}
disabled={tagBlocked(genre.value)}
onclick={() => toggleTag(genre.value)}
>
{genre.label}
</Chip>
{/each}
</div>
{#each tags as tag (tag)}
<input type="hidden" name="tags" value={tag} />
{/each}
{#snippet hint()}
<p>Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.</p>
{/snippet}
</Field>
<Checkbox bind:checked={acceptedTerms}> <Checkbox bind:checked={acceptedTerms}>
Acepto los Acepto los
<a <a
@@ -296,10 +260,7 @@
{/if} {/if}
{#if status === 'error'} {#if status === 'error'}
<Alert tone="danger"> <Alert tone="danger">{errorMsg}</Alert>
{errorMsg}
{#if errorDetail}<span class="mt-1 block text-xs opacity-80">{errorDetail}</span>{/if}
</Alert>
{/if} {/if}
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-4"> <div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-4">
@@ -4,9 +4,10 @@ import type { RequestHandler } from './$types';
import { import {
getOidcConfig, getOidcConfig,
encodeSession, encodeSession,
sessionCookieOptions,
toSessionUser, toSessionUser,
writeSessionCookie,
type Session, type Session,
SESSION_COOKIE,
STATE_COOKIE, STATE_COOKIE,
VERIFIER_COOKIE, VERIFIER_COOKIE,
NONCE_COOKIE, NONCE_COOKIE,
@@ -54,7 +55,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
}; };
writeSessionCookie(cookies, await encodeSession(session), url); cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url));
for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) {
cookies.delete(name, { path: '/' }); cookies.delete(name, { path: '/' });
+3 -8
View File
@@ -1,17 +1,12 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import * as client from 'openid-client'; import * as client from 'openid-client';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { import { getOidcConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth';
clearSessionCookie,
decodeSession,
getOidcConfig,
readSessionCookie
} from '$lib/server/auth';
import { authConfig } from '$lib/server/config'; import { authConfig } from '$lib/server/config';
const handleLogout: RequestHandler = async ({ cookies }) => { const handleLogout: RequestHandler = async ({ cookies }) => {
const session = await decodeSession(readSessionCookie(cookies)); const session = await decodeSession(cookies.get(SESSION_COOKIE));
clearSessionCookie(cookies); cookies.delete(SESSION_COOKIE, { path: '/' });
const config = await getOidcConfig(); const config = await getOidcConfig();
const endSessionUrl = client.buildEndSessionUrl(config, { const endSessionUrl = client.buildEndSessionUrl(config, {