Author SHA1 Message Date
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
32 changed files with 268 additions and 562 deletions
-115
View File
@@ -1,115 +0,0 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Migrate database, generate keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d wait-for-postgres keys-init migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+1
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}");
} }
} }
} }
+12 -14
View File
@@ -1,14 +1,11 @@
# Production configuration for deploy/compose.yml + compose.prod.yml. # Production configuration for deploy/compose.yml + compose.prod.yml.
# Copy to deploy/.env.prod, then: # Copy to deploy/.env.prod, then:
# #
# 1. replace every `*.example.com` with your actual domains (each service # 1. replace every `example.com` with your domain,
# below has its own, independent domain — they don't need to share a
# root, e.g. for DNS providers that don't allow multi-level
# subdomains),
# 2. replace every CHANGE_ME with a strong random value # 2. replace every CHANGE_ME with a strong random value
# (`openssl rand -hex 24`), # (`openssl rand -hex 24`),
# 3. point each domain below at the server and add its vhost to the shared # 3. point these DNS records at the server, all proxied/TLS-terminated the
# edge (Infrastructure's edge/conf.d). # same way: example.com, www, api, auth, stream, s3.
# #
# Requires ../../../Infrastructure running first, on the same Docker host, # 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
@@ -17,13 +14,12 @@
DOTNET_ENVIRONMENT=Production DOTNET_ENVIRONMENT=Production
# --- Domains ------------------------------------------------------------- # --- Domain ------------------------------------------------------------------
# Public URLs. They must match the vhosts of the shared edge nginx # ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it.
# (Infrastructure's edge/conf.d), which is what serves them; the domains are ROOT_DOMAIN=example.com
# independent, they don't need to share a root. PUBLIC_WEB_URL=https://example.com
PUBLIC_WEB_URL=https://musify.example.com PUBLIC_API_URL=https://api.example.com
PUBLIC_API_URL=https://musifyapi.example.com PUBLIC_STREAM_URL=https://stream.example.com
PUBLIC_STREAM_URL=https://musifystream.example.com
PUBLIC_AUTH_URL=https://auth.example.com PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com S3_PUBLIC_URL=https://s3.example.com
@@ -66,4 +62,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
+26 -53
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,26 @@ 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 domain and every `CHANGE_ME` with a
random value (`openssl rand -hex 24`). `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 and TLS-terminated the same way.
Then: Then:
@@ -141,45 +141,14 @@ 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
`.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.
--- ---
@@ -190,9 +159,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
+34 -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,31 @@ 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_ROOT_DOMAIN: ${ROOT_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,119 @@
# 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.
#
# 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_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN}
api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN}
stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_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_ROOT_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 www.${MUSIFY_ROOT_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name api.${MUSIFY_ROOT_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 stream.${MUSIFY_ROOT_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 auth.${MUSIFY_ROOT_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 s3.${MUSIFY_ROOT_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;
}
}
+2 -2
View File
@@ -9,12 +9,12 @@ Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending.
## Home (app) `/` 🟡 ## Home (app) `/` 🟡
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue. - **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA. - **Quick access, meaning recently played** (`player.recentlyPlayed`, client side for now). Empty state with an icon and an "Explore" CTA.
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state. - **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session).
### Pending backend work (ASP.NET) for Home ### Pending backend work (ASP.NET) for Home
- **Recently played**: track plays per user (a history table with `userId`, `trackId`, `playedAt`) and expose `GET /me/recently-played?limit=` (or `/users/{id}/recently-played`), returning tracks ordered by `playedAt` descending, with no duplicates. The frontend would load this in a `load` function and pass it to the store instead of using `recentlyPlayed`.
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists. - **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists.
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color. - Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color.
+14 -55
View File
@@ -39,40 +39,6 @@
"vite": "^8.0.16" "vite": "^8.0.16"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1152,6 +1118,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/acorn-typescript": "^1.0.9",
@@ -1203,6 +1170,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -1440,27 +1408,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
@@ -1597,6 +1544,7 @@
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1658,6 +1606,7 @@
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0", "@typescript-eslint/types": "8.64.0",
@@ -1765,6 +1714,7 @@
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -1848,6 +1798,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2082,6 +2033,7 @@
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -3160,6 +3112,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3293,6 +3246,7 @@
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -3309,6 +3263,7 @@
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
@@ -3479,6 +3434,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -3613,6 +3569,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3824,6 +3781,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3885,6 +3843,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
+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 -50
View File
@@ -553,23 +553,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/users/{id}/last-listened-track": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["GetLastTrackListenedByUserId"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/listening-stats": { "/users/{id}/listening-stats": {
parameters: { parameters: {
query?: never; query?: never;
@@ -577,7 +560,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get: operations["GetListeningStats"]; get: operations["GetListeningStats"];
put?: never; put?: never;
post?: never; post?: never;
@@ -714,9 +697,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;
@@ -2383,7 +2364,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
200: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -2394,35 +2375,6 @@ export interface operations {
}; };
}; };
}; };
GetLastTrackListenedByUserId: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackApplicationResponse"];
};
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetUserProfile: { GetUserProfile: {
parameters: { parameters: {
query?: never; query?: never;
@@ -18,7 +18,7 @@
</script> </script>
<header <header
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between bg-transparent page-x backdrop-blur-md sm:hidden" class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
> >
<a href="/"> <a href="/">
<Logo size="sm" /> <Logo size="sm" />
@@ -7,7 +7,6 @@
import ListRow from '../ui/media/ListRow.svelte'; import ListRow from '../ui/media/ListRow.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Logo from '../ui/primitives/Logo.svelte';
interface SidebarPlaylist { interface SidebarPlaylist {
id: string; id: string;
@@ -34,10 +33,6 @@
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex" class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="mb-4">
<Logo size="sm" />
</a>
<nav class="flex flex-col gap-1"> <nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)} {#each navLinks as link (link.href)}
{@const active = isActive(link.href)} {@const active = isActive(link.href)}
@@ -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}
@@ -5,10 +5,18 @@
let { size = 'sm' }: Props = $props(); let { size = 'sm' }: Props = $props();
const MARK: Record<'sm' | 'lg', string> = {
sm: 'size-5.5 rounded-thumb',
lg: 'size-8 rounded-control'
};
const TEXT: Record<'sm' | 'lg', string> = { const TEXT: Record<'sm' | 'lg', string> = {
sm: 'font-display text-lg font-semibold tracking-tight text-fg', sm: 'font-display text-lg font-semibold tracking-tight text-fg',
lg: 'font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight' lg: 'font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight'
}; };
</script> </script>
<span class={TEXT[size]}>Musify</span> <span class="flex items-center gap-3">
<span class="{MARK[size]} bg-(image:--mf-logo-grad)"></span>
<span class={TEXT[size]}>Musify</span>
</span>
-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;
}
@@ -315,15 +315,6 @@ class PlayerState {
return `${url}${sep}t=${encodeURIComponent(ticket)}`; return `${url}${sep}t=${encodeURIComponent(ticket)}`;
} }
hydrate(item: ApiTrackLike) {
if (this.currentId !== null || this.tracks.length > 0) return;
const track = toTrack(toQueueItem(item));
this.tracks = [track];
this.currentId = track.id;
this.#applyAccent(track.id);
this.#syncMediaSessionMetadata(track);
}
playlistTracks(ids: (string | number)[]): PlayerTrack[] { playlistTracks(ids: (string | number)[]): PlayerTrack[] {
return ids return ids
.map((id) => this.tracks.find((t) => t.id === id)) .map((id) => this.tracks.find((t) => t.id === id))
@@ -393,10 +384,6 @@ class PlayerState {
toggle() { toggle() {
const audio = this.#audioEl(); const audio = this.#audioEl();
if (!audio || this.currentId === null) return; if (!audio || this.currentId === null) return;
if (!audio.src) {
this.#loadCurrent();
return;
}
if (audio.paused) audio.play().catch(() => {}); if (audio.paused) audio.play().catch(() => {});
else audio.pause(); else audio.pause();
} }
+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 })
+4 -9
View File
@@ -9,17 +9,12 @@ function positiveInt(value: string | undefined, fallback: number): number {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
} }
function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
export const authConfig = { export const authConfig = {
get issuer() { get issuer() {
return required('ZITADEL_ISSUER', env.ZITADEL_ISSUER); return env.ZITADEL_ISSUER;
}, },
get clientId() { get clientId() {
return required('ZITADEL_CLIENT_ID', env.ZITADEL_CLIENT_ID); return env.ZITADEL_CLIENT_ID;
}, },
get clientSecret(): string | undefined { get clientSecret(): string | undefined {
const secret = env.ZITADEL_CLIENT_SECRET; const secret = env.ZITADEL_CLIENT_SECRET;
@@ -29,10 +24,10 @@ export const authConfig = {
return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE; return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE;
}, },
get redirectUri() { get redirectUri() {
return required('AUTH_REDIRECT_URI', env.AUTH_REDIRECT_URI); return env.AUTH_REDIRECT_URI;
}, },
get postLogoutUri() { get postLogoutUri() {
return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI); return env.AUTH_POST_LOGOUT_URI;
}, },
get sessionSecret() { get sessionSecret() {
return env.SESSION_SECRET; return env.SESSION_SECRET;
+1
View File
@@ -63,6 +63,7 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
'--mf-spotlight-bg': light '--mf-spotlight-bg': light
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)`
: `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`, : `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`,
'--mf-logo-grad': `linear-gradient(${accent}, ${accent})`,
'--mf-cover-grad': light '--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))` ? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
: `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))` : `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))`
@@ -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">
+2 -21
View File
@@ -39,22 +39,6 @@ async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | nul
} }
} }
async function fetchLastPlayedTrack(
fetchFn: typeof fetch,
accessToken: string | null,
userId: string
) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/users/{id}/last-listened-track', {
params: { path: { id: userId } }
});
return data ?? null;
} catch {
return null;
}
}
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
const allowAnonymousListening = await getAllowAnonymousListening(fetch); const allowAnonymousListening = await getAllowAnonymousListening(fetch);
@@ -72,15 +56,13 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
allowAnonymousListening, allowAnonymousListening,
sidebarPlaylists: [], sidebarPlaylists: [],
likedTracks: [], likedTracks: [],
lastPlayedTrack: null,
accountUrl accountUrl
}; };
} }
const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ const [sidebarPlaylists, likedTracks] = await Promise.all([
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
fetchLikedTracks(fetch, locals.accessToken), fetchLikedTracks(fetch, locals.accessToken)
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub)
]); ]);
return { return {
@@ -88,7 +70,6 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
allowAnonymousListening, allowAnonymousListening,
sidebarPlaylists, sidebarPlaylists,
likedTracks, likedTracks,
lastPlayedTrack,
accountUrl accountUrl
}; };
}; };
+3 -7
View File
@@ -30,19 +30,15 @@
if (data.likedTracks?.length) liked.hydrate(data.likedTracks); if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
}); });
$effect(() => {
if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack);
});
const isAuthPage = $derived(page.url.pathname === '/auth'); const isAuthPage = $derived(page.url.pathname === '/auth');
const hasTrack = $derived(player.currentId !== null); const hasTrack = $derived(player.currentId !== null);
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
let wasPlaying = false; let hadTrack = false;
$effect(() => { $effect(() => {
if (player.playing && !wasPlaying) queuePanel.show(); if (hasTrack && !hadTrack) queuePanel.show();
wasPlaying = player.playing; hadTrack = hasTrack;
}); });
function isTypingTarget(target: EventTarget | null): boolean { function isTypingTarget(target: EventTarget | null): boolean {
@@ -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, {