92 Commits
Author SHA1 Message Date
NotS1mp 87c3f97529 Merge pull request 'Web player general changes' (#22) from web-player-general-changes into master
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 2m4s
Reviewed-on: #22
2026-09-23 22:37:30 +00:00
Admin 302dd1e0f8 Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
2026-09-24 00:32:03 +02:00
NotS1mp 421fc929e8 Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s
Reviewed-on: #20
2026-09-23 22:24:22 +00:00
NotS1mp c4ab5dc388 Merge branch 'master' into dynamic-genres
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m20s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 22:15:22 +00:00
NotS1mp 920dcc6779 Merge pull request 'Listening time accuracy' (#19) from listening-time-accuracy into master
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m40s
Reviewed-on: #19
2026-09-23 22:15:10 +00:00
Admin 430c711ff2 Refactor CI/CD workflow to improve database migration process and key generation
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m27s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 23:59:45 +02:00
Admin cfdc61a4b8 Scroll content inside the shell so the player shares the ambient backdrop
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The player dock used to sit fixed over the scrolling page, so it needed an
opaque background. The shell is now a fixed-height frame where only the
content column scrolls, letting the dock stay transparent like the sidebar
and queue. Scroll reset/restore on navigation moves to restoreScroll(), and
the stable scrollbar gutter moves from <html> to the content scroller.
2026-09-23 23:52:27 +02:00
Admin 0f28e19c1d Remove follow button from user rows
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 23:20:27 +02:00
Admin 7316e355ee Add a second greeting variant per time slot 2026-09-23 23:20:27 +02:00
Admin b39e55fd87 Restore master hero and spotlight gradients and sway them in place 2026-09-23 23:15:15 +02:00
Admin cb046bba9e Let users browse followers and following lists
Following lists are public; followers are visible to the user and to mutual followers.
Adds GET /users/{id}/followers and /following, exposes isFollowing and canViewFollowers
on the profile, and returns isFollowedByViewer from user search so the web player no
longer calls /is-following per user.
2026-09-23 22:55:14 +02:00
Admin b9a40186b1 Lighten active surface gradients in light mode
CI/CD / BackEnd (push) Successful in 1m15s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 22:44:42 +02:00
Admin fd0a39b879 IsExplicit 2026-09-23 22:42:28 +02:00
Admin cc7afe4326 Add explicit flag to tracks and mark it on upload 2026-09-23 22:41:57 +02:00
Admin 72cbc18a48 Add follow button next to each user in search results
Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
2026-09-23 22:34:56 +02:00
Admin 84408b842b Semibold 2026-09-23 22:25:04 +02:00
Admin 339fcb56ad Semibold 2026-09-23 22:24:57 +02:00
Admin 74cf534be5 Animate queue drag and drop reordering 2026-09-23 22:18:21 +02:00
Admin 89820c1377 Fix gradient background syntax in Artwork component 2026-09-23 22:10:46 +02:00
Admin 2fe26b15dd Allow reordering upcoming tracks in the queue by drag and drop 2026-09-23 22:10:21 +02:00
Admin 2cb560fda4 Keep light mode gradients visible while drifting 2026-09-23 22:07:50 +02:00
Admin d8530b9209 Animate menu close with shared pop transition 2026-09-23 22:01:51 +02:00
Admin 5049b0ba91 Update global icon stroke width to 1.5 in layout.css 2026-09-23 21:59:24 +02:00
Admin 1b98a75c0e Added shadow 2026-09-23 21:59:13 +02:00
Admin cbbde95773 Set global icon stroke width and remove per-icon strokeWidth props 2026-09-23 21:55:00 +02:00
Admin 26bb8a50a3 Solid player bar, gradient sliders and smooth progress 2026-09-23 21:48:39 +02:00
Admin bf19bb31f9 Make accent hue crossfade between songs last 2 to 4 seconds 2026-09-23 21:40:22 +02:00
Admin 55cc803708 Drift accent gradients on buttons, hero, spotlight, active rows and modals 2026-09-23 21:37:41 +02:00
Admin 805e521b1d Animate ambient background, add mute toggle and smooth queue panel 2026-09-23 21:36:01 +02:00
Admin f8d431a0d7 Implement volume control with mute functionality; update PlayerState to manage muted state 2026-09-23 21:28:17 +02:00
Admin 7b72db8a24 Add ambient drift animation to app backdrop; adjust inset and handle reduced motion 2026-09-23 21:28:07 +02:00
Admin b61ff1ebec Make Discover and upload genres dynamic
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles,
and the upload form kept its own copy of the genre list and compatibility
rules. Both now come from the backend.

Backend:
- GET /genres returns genres with at least one track and their counts.
- GET /genres/available returns every assignable genre with the genres
  it cannot be combined with, so the rules live only in
  GenreCompatibility.
- GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values).

web-player:
- Discover tiles are built from /genres; each opens /explore?genre=X, a
  song list filtered by that tag. Unknown genres fall back to a label
  derived from their name.
- The upload form loads /genres/available, disables incompatible genres
  as you pick, and the server validates before reserving upload URLs.
- lib/data/trackGenres.ts is removed; its helpers merge into
  lib/data/genres.ts.
- Regenerate the typed API client.
2026-09-23 20:05:30 +02:00
Admin 8870c65c6f Move ListeningRules to its own file and split listening tests by subject
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / BackEnd (pull_request) Canceled after 0s
CI/CD / WebPlayer (pull_request) Canceled after 0s
CI/CD / Deploy (pull_request) Canceled after 0s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The counted-listen tests lived in one catch-all UncountedListensTests
file. Each now sits next to the handler it covers, and the cleanup
handler gets its own test file.
2026-09-23 20:02:50 +02:00
Admin 5df5e635f3 Count real listening time instead of full track duration
"Listening time this week" summed the full duration of every track whose
stream ticket was issued, so pressing play and skipping counted the whole
song. Playback is now measured on the client and reported to the API.

Backend:
- ListeningHistory gains PlayedSeconds, LastProgressAt and IsCounted
  (migration TrackPlayedSeconds marks existing rows as counted).
- /tracks/{id}/stream returns a listenId; new
  PUT /tracks/listens/{listenId}/progress stores the accumulated seconds,
  capped by track duration and elapsed wall-clock time, monotonic and
  owner-only.
- A listen counts once it reaches min(30s, 50% of the track). Weekly
  stats, streak, recent history/albums, mixes and listensCount only use
  counted listens; seconds sum PlayedSeconds.
- ListeningHistoryCleanupJob (daily) deletes uncounted listens older
  than 8 days.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

compose.prod.yml joins Infrastructure's infra-net network instead of
running its own copies. Docs and READMEs updated to match.
2026-09-21 22:09:08 +02:00
Admin c5748e9765 Fix route 2026-09-21 22:08:53 +02:00
Admin 23e30d96fc Translate docs to English, rewrite README, drop design handoff folder
Translate all of docs/ from Spanish to English and rewrite the root
README as a short project overview instead of a technical run guide.
Remove web-player/design_handoff_musify_player, an old design-tool
export that was no longer needed.
2026-09-21 18:41:04 +02:00
NotS1mp 3f8d517ee3 Merge pull request 'Design Prot Merge' (#1) from design-prot into master
Reviewed-on: #1
2026-09-21 15:48:16 +00:00
225 changed files with 7121 additions and 6679 deletions
+127
View File
@@ -0,0 +1,127 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+3 -1
View File
@@ -7,6 +7,9 @@ obj/
*.userosscache *.userosscache
.vs/ .vs/
artifacts/ artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS # Rider / VS
.idea/ .idea/
@@ -25,5 +28,4 @@ Thumbs.db
# Deploy secrets and runtime state # Deploy secrets and runtime state
deploy/keys/ deploy/keys/
deploy/secrets/ deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/ deploy/zitadel/.output/
+47 -49
View File
@@ -1,57 +1,55 @@
# Musify # Musify
Music streaming platform: upload your own tracks, process them asynchronously A music streaming app. Upload your own tracks, organize them into playlists
(audio transcoded to `.m4a`, thumbnails generated) and stream them without the and albums, and stream them back with a web player built around your
bytes ever going through the API. listening habits: auto-generated mixes, a home feed, search, likes, and an
Explore page for finding something new.
## What it does
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
- **Playlists**, likes, and a "recently played" history.
- **Mixes**, playlists generated automatically from what you actually listen to.
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
- **Accounts via OIDC login**, with an optional anonymous listening mode.
- Light and dark themes, and a layout that works on both desktop and mobile.
## How it's built
**Backend**: .NET 10, split into a REST API, a background worker, and a
dedicated streaming gateway.
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
- Zitadel for login (OIDC/OAuth2).
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
The session lives on the server, so the backend access token never reaches the browser.
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
shared infrastructure that lives in a separate `Infrastructure` git
repository, used by Musify and by other projects that need the same OIDC
provider and object storage.
## Project layout
``` ```
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client web-player/ the SvelteKit web client
deploy/ Docker Compose stacks — self-contained, no wrapper scripts deploy/ Docker Compose stacks for dev and production
docs/ What each piece does and why (in Spanish) docs/ architecture notes, one file per area
``` ```
```sh Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS) layout. They live in the separate `Infrastructure` repository instead.
```
## Run it ## Digging deeper
Development (Docker Desktop): [docs/](docs/README.md) covers how each piece works and why: architecture,
authentication, storage, streaming, media processing, and the local dev
```sh setup. [deploy/README.md](deploy/README.md) has the full instructions for
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d running the stack, in development or on a production server.
```
Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC
provisioning — all as one-shot jobs baked into the compose file. Apps run from
the IDE by default; add `--profile apps up -d --build` as a second command to
run them in Docker instead.
Production, on a server with a domain:
```sh
cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
```
Idempotent — run the same commands again to deploy an update. See
[deploy/README.md](deploy/README.md) for the details, the port map and the
production prerequisites.
## Components
| Service | Port (dev) | Role |
|---|---|---|
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails |
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
More in [docs/](docs/README.md): [architecture](docs/architecture.md),
[authentication](docs/authentication.md), [storage](docs/storage.md),
[streaming](docs/streaming.md), [media processing](docs/media-processing.md),
[development](docs/development.md).
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count }) .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
{ {
var listenedTrackIds = database.ListeningHistories var listenedTrackIds = database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == request.UserId); .Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
@@ -0,0 +1,26 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -0,0 +1,31 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -30,7 +30,7 @@ namespace Musify.Application.Likes
var pagedEntities = await likesQuery var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt) .OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count }) .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -38,7 +38,7 @@ namespace Musify.Application.Mixes
var historyTrackIds = await database.ListeningHistories var historyTrackIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == userId) .Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId) .Select(history => history.TrackId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -19,7 +19,7 @@ namespace Musify.Application.Mixes
.AsNoTracking() .AsNoTracking()
.Include(track => track.Owner) .Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id)) .Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count }) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken); .ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems return orderedItems
@@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position) .OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count }) .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -30,6 +30,7 @@ public sealed class TrackStreamIssuer(
folderPath, folderPath,
streamGatewayConfiguration.AudioFileName); streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
if (userId != null) if (userId != null)
{ {
var newListeningHistory = new ListeningHistory var newListeningHistory = new ListeningHistory
@@ -39,9 +40,10 @@ public sealed class TrackStreamIssuer(
}; };
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
listenId = newListeningHistory.Id;
} }
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
} }
private long? AnonymousFragmentBytes(long? userId) private long? AnonymousFragmentBytes(long? userId)
@@ -18,7 +18,8 @@ namespace Musify.Application.Tracks
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>; : ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
@@ -83,6 +84,7 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(), NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id, OwnerUserId = user.Id,
Owner = user, Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent.ObjectName,
@@ -0,0 +1,33 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
database.ListeningHistories.RemoveRange(stale);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count;
}
}
}
@@ -19,7 +19,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Track.Tags) .Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId) .Where(t => t.UserId == query.UserId)
.OrderByDescending(t => t.ListenedAt) .OrderByDescending(t => t.ListenedAt)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count)) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
return track == null return track == null
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
{ {
var recentTrackIds = await database.ListeningHistories var recentTrackIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(l => l.UserId == query.UserId) .Where(l => l.UserId == query.UserId && l.IsCounted)
.GroupBy(l => l.TrackId) .GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt)) .OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key) .Select(g => g.Key)
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner) .Include(t => t.Owner)
.Include(t => t.Tags) .Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id)) .Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count }) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(t => t.Id, cancellationToken); .ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds return recentTrackIds
@@ -18,7 +18,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner) .Include(t => t.Owner)
.Include(t => t.Tags) .Include(t => t.Tags)
.Where(t => t.Id == request.TrackId) .Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
if (entity == null) if (entity == null)
@@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record GetTracksQuery(string? Name, int PageNumber, int PageSize) public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>; : IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database) public class GetTracksQueryHandler(IDatabase database)
@@ -28,12 +29,15 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
} }
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken); var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt) .OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id) .ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities var items = pagedEntities
@@ -40,7 +40,7 @@ namespace Musify.Application.Tracks
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt) .OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -0,0 +1,14 @@
namespace Musify.Application.Tracks
{
public static class ListeningRules
{
public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) =>
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
}
}
@@ -0,0 +1,44 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record RecordListeningProgressCommand(
long UserId,
Guid ListenId,
double PlayedSeconds)
: ICommand<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
{
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories
.Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
if (listen == null)
return Error.NotFound();
var now = DateTime.UtcNow;
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
listen.LastProgressAt = now;
listen.IsCounted = listen.IsCounted
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
await database.SaveChangesAsync(cancellationToken);
return Result.Success;
}
}
}
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt, track.CreatedAt,
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList()); track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
} }
} }
@@ -3,5 +3,6 @@ namespace Musify.Application.Tracks.Responses
public record TrackStreamResponse( public record TrackStreamResponse(
string ManifestUrl, string ManifestUrl,
string Ticket, string Ticket,
int ExpiresInSeconds); int ExpiresInSeconds,
Guid? ListenId = null);
} }
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public static class FollowVisibility
{
public static async Task<bool> CanViewFollowersAsync(
IDatabase database,
long? viewerId,
long userId,
CancellationToken cancellationToken)
{
if (viewerId is not { } viewer)
return false;
if (viewer == userId)
return true;
var mutualRows = await database.UserFollows
.AsNoTracking()
.CountAsync(f =>
(f.FollowerId == viewer && f.FollowedId == userId) ||
(f.FollowerId == userId && f.FollowedId == viewer),
cancellationToken);
return mutualRows == 2;
}
}
}
@@ -20,17 +20,18 @@ namespace Musify.Application.Users
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory var tracksThisWeek = await thisWeekHistory
.Where(l => l.IsCounted)
.Select(l => l.TrackId) .Select(l => l.TrackId)
.Distinct() .Distinct()
.CountAsync(cancellationToken); .CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds) .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
.SumAsync(cancellationToken); .SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories var listenedDates = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(l => l.UserId == request.UserId) .Where(l => l.UserId == request.UserId && l.IsCounted)
.Select(l => l.ListenedAt.Date) .Select(l => l.ListenedAt.Date)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -0,0 +1,46 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowersQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
return Error.Forbidden();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowedId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Follower.Id,
f.Follower.Name,
f.Follower.FirstName,
f.Follower.SecondName,
f.Follower.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -0,0 +1,43 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowingQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowerId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Followed.Id,
f.Followed.Name,
f.Followed.FirstName,
f.Followed.SecondName,
f.Followed.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -7,7 +7,7 @@ using Musify.Application.Serialization;
namespace Musify.Application.Users namespace Musify.Application.Users
{ {
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>; public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse( public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id, [property: JsonConverter(typeof(LongAsStringConverter))] long Id,
@@ -17,7 +17,9 @@ namespace Musify.Application.Users
string? ProfilePictureUrl, string? ProfilePictureUrl,
DateTime CreatedAt, DateTime CreatedAt,
int FollowersCount, int FollowersCount,
int FollowingCount); int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
public class GetUserProfileQueryHandler(IDatabase database) public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>> : IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
@@ -49,6 +51,14 @@ namespace Musify.Application.Users
.AsNoTracking() .AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken); .CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
database, request.ViewerId, request.UserId, cancellationToken);
return new UserProfileResponse( return new UserProfileResponse(
user.Id, user.Id,
user.Name, user.Name,
@@ -57,7 +67,9 @@ namespace Musify.Application.Users
user.ProfilePictureUrl, user.ProfilePictureUrl,
user.CreatedAt, user.CreatedAt,
followersCount, followersCount,
followingCount); followingCount,
isFollowing,
canViewFollowers);
} }
} }
} }
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
namespace Musify.Application.Users namespace Musify.Application.Users
{ {
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUsersQueryHandler(IDatabase database) public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>> : IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{ {
var usersQuery = database.Users var usersQuery = database.Users
.AsNoTracking() .AsNoTracking()
@@ -27,11 +27,19 @@ namespace Musify.Application.Users
} }
var totalCount = await usersQuery.CountAsync(cancellationToken); var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery var pagedUsers = await usersQuery
.Select(u => UserApplicationResponse.FromEntity(u)) .OrderBy(u => u.Name)
.Select(u => new UserSummaryResponse(
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers); return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
} }
} }
} }
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.Users.Responses
{
public record UserSummaryResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
bool IsFollowedByViewer);
}
@@ -19,6 +19,12 @@ namespace Musify.Domain.Entities
[Required] [Required]
public DateTime ListenedAt { get; set; } = DateTime.UtcNow; public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
public double? PlayedSeconds { get; set; }
public DateTime? LastProgressAt { get; set; }
public bool IsCounted { get; set; }
[ForeignKey(nameof(UserId) )] [ForeignKey(nameof(UserId) )]
public User User { get; set; } public User User { get; set; }
@@ -31,6 +31,9 @@ namespace Musify.Domain.Entities
[Required] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Jobs
{ {
var userIds = await database.ListeningHistories var userIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId) .Select(history => history.UserId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -0,0 +1,13 @@
using Mediator;
using Musify.Application.Tracks;
namespace Musify.Infrastructure.Jobs
{
public class ListeningHistoryCleanupJob(IMediator mediator)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
}
}
}
@@ -37,7 +37,7 @@ namespace Musify.Infrastructure.Persistence
} }
else if (entry.State == EntityState.Modified) else if (entry.State == EntityState.Modified)
{ {
entry.Entity.UpdatedAt = now; entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
} }
} }
} }
@@ -13,9 +13,13 @@ namespace Musify.Infrastructure.Persistence
public class Database(DatabaseConfiguration configuration) public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase : DbContext, AppIDatabase
{ {
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre")); optionsBuilder
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -18,10 +18,7 @@ namespace Musify.Infrastructure.Persistence
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddSingleton<AuditableEntityInterceptor>(); serviceDescriptors.AddDbContext<Database>();
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>()); serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPlayedSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCounted",
table: "ListeningHistory",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastProgressAt",
table: "ListeningHistory",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "PlayedSeconds",
table: "ListeningHistory",
type: "double precision",
nullable: true);
migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCounted",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "LastProgressAt",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "PlayedSeconds",
table: "ListeningHistory");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackIsExplicit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsExplicit",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsExplicit",
table: "Tracks");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Delivered"); b.HasIndex("Delivered");
b.ToTable("InboxState", (string)null); b.ToTable("InboxState");
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique(); .IsUnique();
b.ToTable("OutboxMessage", (string)null); b.ToTable("OutboxMessage");
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
@@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Created"); b.HasIndex("Created");
b.ToTable("OutboxState", (string)null); b.ToTable("OutboxState");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Album", b => modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
@@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Albums", (string)null); b.ToTable("Albums");
}); });
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
@@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("AlbumId", "TrackId") b.HasIndex("AlbumId", "TrackId")
.IsUnique(); .IsUnique();
b.ToTable("AlbumHasTrack", (string)null); b.ToTable("AlbumHasTrack");
}); });
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
@@ -268,9 +268,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<bool>("IsCounted")
.HasColumnType("boolean");
b.Property<DateTime?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ListenedAt") b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<double?>("PlayedSeconds")
.HasColumnType("double precision");
b.Property<Guid>("TrackId") b.Property<Guid>("TrackId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -283,7 +292,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("ListeningHistory", (string)null); b.ToTable("ListeningHistory");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Mix", b => modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
@@ -317,7 +326,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Position"); b.HasIndex("UserId", "Position");
b.ToTable("Mixes", (string)null); b.ToTable("Mixes");
}); });
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
@@ -341,7 +350,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("MixId", "Position"); b.HasIndex("MixId", "Position");
b.ToTable("MixItems", (string)null); b.ToTable("MixItems");
}); });
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
@@ -385,7 +394,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("PlayLists", (string)null); b.ToTable("PlayLists");
}); });
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
@@ -412,7 +421,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId"); b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack", (string)null); b.ToTable("PlayListHasTrack");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Track", b => modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
@@ -427,6 +436,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<double>("DurationSeconds") b.Property<double>("DurationSeconds")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -450,7 +462,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Tracks", (string)null); b.ToTable("Tracks");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
@@ -475,7 +487,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "TrackId") b.HasIndex("UserId", "TrackId")
.IsUnique(); .IsUnique();
b.ToTable("TrackLikes", (string)null); b.ToTable("TrackLikes");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
@@ -495,7 +507,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId", "Tag") b.HasIndex("TrackId", "Tag")
.IsUnique(); .IsUnique();
b.ToTable("TrackTags", (string)null); b.ToTable("TrackTags");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Upload", b => modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
@@ -524,7 +536,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Upload", (string)null); b.ToTable("Upload");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
@@ -575,7 +587,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UploadIntents", (string)null); b.ToTable("UploadIntents");
}); });
modelBuilder.Entity("Musify.Domain.Entities.User", b => modelBuilder.Entity("Musify.Domain.Entities.User", b =>
@@ -613,7 +625,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("User", (string)null); b.ToTable("User");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
@@ -638,7 +650,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("FollowerId", "FollowedId") b.HasIndex("FollowerId", "FollowedId")
.IsUnique(); .IsUnique();
b.ToTable("UserFollows", (string)null); b.ToTable("UserFollows");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
@@ -659,7 +671,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UserHasTrack", (string)null); b.ToTable("UserHasTrack");
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
@@ -686,7 +698,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("AlbumProcessingState", (string)null); b.ToTable("AlbumProcessingState");
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
@@ -713,7 +725,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState", (string)null); b.ToTable("PlayListProcessingState");
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
@@ -749,7 +761,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("TrackProcessingState", (string)null); b.ToTable("TrackProcessingState");
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -803,7 +815,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("AlbumId"); b1.HasKey("AlbumId");
b1.ToTable("Albums", (string)null); b1.ToTable("Albums");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("AlbumId"); .HasForeignKey("AlbumId");
@@ -896,32 +908,28 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("LargeName") b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("LargePictureName"); .HasColumnName("LargePictureName");
b1.Property<string>("MediumName") b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName"); .HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName") b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName"); .HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName") b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName"); .HasColumnName("SmallPictureName");
b1.HasKey("PlayListId"); b1.HasKey("PlayListId");
b1.ToTable("PlayLists", (string)null); b1.ToTable("PlayLists");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("PlayListId"); .HasForeignKey("PlayListId");
@@ -992,7 +1000,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId"); b1.HasKey("TrackId");
b1.ToTable("Tracks", (string)null); b1.ToTable("Tracks");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("TrackId"); .HasForeignKey("TrackId");
@@ -1029,7 +1037,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId"); b1.HasKey("TrackId");
b1.ToTable("Tracks", (string)null); b1.ToTable("Tracks");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("TrackId"); .HasForeignKey("TrackId");
@@ -74,6 +74,7 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddHangfireServer(); serviceDescriptors.AddHangfireServer();
serviceDescriptors.AddScoped<DailyMixGenerationJob>(); serviceDescriptors.AddScoped<DailyMixGenerationJob>();
serviceDescriptors.AddScoped<ListeningHistoryCleanupJob>();
return serviceDescriptors; return serviceDescriptors;
} }
@@ -6,4 +6,5 @@ public record CreateTrackRequest(
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags); IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false);
@@ -0,0 +1,3 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RecordListeningProgressRequest(double PlayedSeconds);
@@ -0,0 +1,39 @@
using Mediator;
using Musify.Application.Genres;
namespace Musify.Api.Endpoints;
public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/genres")
.WithTags("Genres");
group.MapGet("/", GetGenres)
.WithName("GetGenres")
.WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.")
.Produces<IReadOnlyList<GenreResponse>>();
group.MapGet("/available", GetAvailableGenres)
.WithName("GetAvailableGenres")
.WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.")
.Produces<IReadOnlyList<AvailableGenreResponse>>();
return app;
}
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
}
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetGenresQuery(), cancellationToken);
}
}
@@ -1,4 +1,5 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
@@ -19,8 +20,9 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>(); .Produces<TracksSearchResponse>()
.ProducesValidationProblem();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
@@ -41,6 +43,14 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapPut("/listens/{listenId}/progress", RecordListeningProgress)
.WithName("RecordListeningProgress")
.WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId) group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId") .WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.") .WithSummary("Get Paginated Tracks For A User.")
@@ -84,10 +94,25 @@ public static class TrackEndpoints
IMediator mediator, IMediator mediator,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? name, string? name,
string? genre,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) int pageSize = 10)
{ {
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); Genre? parsedGenre = null;
if (!string.IsNullOrWhiteSpace(genre))
{
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
});
}
parsedGenre = value;
}
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -128,6 +153,19 @@ public static class TrackEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RecordListeningProgress(
IMediator mediator,
CurrentUser currentUser,
Guid listenId,
RecordListeningProgressRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTracksByUserId( private static async Task<IResult> GetTracksByUserId(
IMediator mediator, IMediator mediator,
long userId, long userId,
@@ -147,7 +185,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
cancellationToken); cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}"); return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -21,7 +21,7 @@ public static class UserEndpoints
group.MapGet("/", GetUsers) group.MapGet("/", GetUsers)
.WithName("GetUsers") .WithName("GetUsers")
.WithSummary("Get Paginated Users.") .WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserApplicationResponse>>(); .Produces<PaginatedResponse<UserSummaryResponse>>();
group.MapGet("/{id}", GetUserById) group.MapGet("/{id}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
@@ -52,6 +52,19 @@ public static class UserEndpoints
.Produces<UserProfileResponse>() .Produces<UserProfileResponse>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/followers", GetFollowers)
.WithName("GetUserFollowers")
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/following", GetFollowing)
.WithName("GetUserFollowing")
.WithSummary("Get The Users A User Follows.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/is-following", IsFollowing) group.MapGet("/{id}/is-following", IsFollowing)
.WithName("IsFollowingUser") .WithName("IsFollowingUser")
.WithSummary("Check Whether The Current User Follows Another User.") .WithSummary("Check Whether The Current User Follows Another User.")
@@ -88,12 +101,13 @@ public static class UserEndpoints
private static async Task<IResult> GetUsers( private static async Task<IResult> GetUsers(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? usernameSearch, string? usernameSearch,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) int pageSize = 10)
{ {
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -133,10 +147,35 @@ public static class UserEndpoints
private static async Task<IResult> GetUserProfile( private static async Task<IResult> GetUserProfile(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
long id, long id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken); var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetFollowers(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
{
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetFollowing(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
{
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
+1
View File
@@ -40,6 +40,7 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapConfigEndpoints() .MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints() .MapLikeEndpoints()
.MapMixEndpoints() .MapMixEndpoints()
.MapPlayListEndpoints() .MapPlayListEndpoints()
-6
View File
@@ -1,6 +0,0 @@
@WebApi_HostAddress = http://localhost:5111
GET {{WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###
+5
View File
@@ -41,6 +41,11 @@ using (var scope = host.Services.CreateScope())
job => job.RunAsync(CancellationToken.None), job => job.RunAsync(CancellationToken.None),
Cron.Daily(), Cron.Daily(),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
"listening-history-cleanup",
job => job.RunAsync(CancellationToken.None),
Cron.Daily(3),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
} }
host.Run(); host.Run();
-4
View File
@@ -12,10 +12,6 @@ RUN dotnet restore Hosts/Musify.Api/Api.csproj
COPY . . COPY . .
RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore
# Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host
# looks for appsettings*.json. Add lowercase copies so they get loaded.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
-3
View File
@@ -11,9 +11,6 @@ RUN dotnet restore Hosts/Musify.Worker/Worker.csproj
COPY . . COPY . .
RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore
# AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Genres;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private async Task<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
var title = $"Genre track {Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
}
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
[Theory]
[InlineData("Nope")]
[InlineData("999")]
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
{
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
}
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
}
}
}
@@ -0,0 +1,109 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.DataTransferObjects.Users;
using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
using Xunit;
using Musify.Api.Tests.TestSupport;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
{
private async Task<long> CreateUserAsync()
{
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
return userId;
}
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken);
var track = new Track
{
Title = "Progress track",
NormalizedTitle = "PROGRESS TRACK",
OwnerUserId = owner.Id,
Owner = owner,
DurationSeconds = durationSeconds
};
var listen = new ListeningHistory
{
UserId = userId,
TrackId = track.Id,
ListenedAt = DateTime.UtcNow.AddMinutes(-10)
};
database.Tracks.Add(track);
database.ListeningHistories.Add(listen);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return listen.Id;
}
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
return await database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken);
}
[Fact]
public async Task PutProgress_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().PutAsJsonAsync(
$"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
var stored = await LoadListenAsync(listenId);
Assert.Equal(45, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task PutProgress_OtherUsersListen_ReturnsNotFound()
{
var ownerId = await CreateUserAsync();
var otherId = await CreateUserAsync();
var listenId = await SeedListenAsync(ownerId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds);
}
[Fact]
public async Task PutProgress_NegativeSeconds_ReturnsBadRequest()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
}
@@ -57,5 +57,25 @@ namespace Musify.Application.Tests.Albums
Assert.False(result.IsError); Assert.False(result.IsError);
Assert.Equal(2, result.Value.Count); Assert.Equal(2, result.Value.Count);
} }
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
var countedTrack = TestEntities.Track(owner, "In counted");
var skippedTrack = TestEntities.Track(owner, "In skipped");
await SeedAsync(
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
}
} }
} }
@@ -0,0 +1,32 @@
using Musify.Application.Genres;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetAvailableGenresQueryHandlerTests
{
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
}
[Fact]
public async Task Handle_ListsIncompatibleGenresSymmetrically()
{
var result = await RunAsync();
var metal = result.Single(r => r.Genre == Genre.Metal);
var lofi = result.Single(r => r.Genre == Genre.Lofi);
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
}
}
}
@@ -0,0 +1,69 @@
using Musify.Application.Genres;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
{
private GetGenresQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTracks_ReturnsEmpty()
{
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
[Fact]
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
{
var owner = TestEntities.User();
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
await SeedAsync(
owner, rockA, rockB, jazz,
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
result.Select(r => (r.Genre, r.TrackCount)));
}
[Fact]
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
{
var owner = TestEntities.User();
var other = TestEntities.User(id: 2, name: "other-user");
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
await SeedAsync(
owner, other, track,
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(1, Assert.Single(result).TrackCount);
}
[Fact]
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
}
}
@@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes
.ToListAsync(TestContext.Current.CancellationToken); .ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
} }
[Fact]
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
{
var user = TestEntities.User();
var skipped = TestEntities.Track(user, "Only skipped");
await SeedAsync(
user, skipped,
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
}
} }
} }
@@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); Assert.Equal(3, response.ListensCount);
Assert.False(response.IsExplicit);
}
[Fact]
public void FromEntity_CopiesExplicitFlag()
{
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.True(response.IsExplicit);
} }
} }
} }
@@ -56,7 +56,8 @@ namespace Musify.Application.Tests.TestSupport
TrackPictures? pictures = null, TrackPictures? pictures = null,
TrackAudio? audio = null, TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null) IEnumerable<Genre>? tags = null,
bool isExplicit = false)
{ {
var track = new Track var track = new Track
{ {
@@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus, LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
Pictures = pictures ?? ProcessedPictures(), Pictures = pictures ?? ProcessedPictures(),
@@ -177,12 +179,16 @@ namespace Musify.Application.Tests.TestSupport
public static ListeningHistory ListeningHistory( public static ListeningHistory ListeningHistory(
long userId, long userId,
Guid trackId, Guid trackId,
DateTime? listenedAt = null) => DateTime? listenedAt = null,
double? playedSeconds = null,
bool isCounted = true) =>
new() new()
{ {
UserId = userId, UserId = userId,
TrackId = trackId, TrackId = trackId,
ListenedAt = listenedAt ?? DateTime.UtcNow ListenedAt = listenedAt ?? DateTime.UtcNow,
PlayedSeconds = playedSeconds,
IsCounted = isCounted
}; };
} }
} }
@@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
} }
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Handle_StoresExplicitFlag(bool isExplicit)
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(isExplicit, result.Value.IsExplicit);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(isExplicit, stored.IsExplicit);
}
[Fact] [Fact]
public async Task Handle_NoTags_ReturnsValidationError() public async Task Handle_NoTags_ReturnsValidationError()
{ {
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
{
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
[Fact]
public async Task Handle_DeletesOnlyOldUncountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
Assert.Equal(1, deleted);
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(oldUncounted.Id, remaining);
Assert.Contains(oldCounted.Id, remaining);
Assert.Contains(recentUncounted.Id, remaining);
}
}
}
@@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks
Assert.Empty(result); Assert.Empty(result);
} }
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var counted = TestEntities.Track(owner, "Counted");
var skipped = TestEntities.Track(owner, "Skipped");
await SeedAsync(
owner, counted, skipped,
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["Counted"], result.Select(track => track.Title));
}
} }
} }
@@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
} }
[Fact]
public async Task Handle_ListensCount_OnlyCountsCountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(
owner, track,
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.ListensCount);
}
} }
} }
@@ -45,6 +45,9 @@ namespace Musify.Application.Tests.Tracks
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(owner.Id, history.UserId); Assert.Equal(owner.Id, history.UserId);
Assert.Equal(track.Id, history.TrackId); Assert.Equal(track.Id, history.TrackId);
Assert.Equal(history.Id, result.Value.ListenId);
Assert.False(history.IsCounted);
Assert.Null(history.PlayedSeconds);
} }
[Fact] [Fact]
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks namespace Musify.Application.Tests.Tracks
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
var item = Assert.Single(result.Value.Items); var item = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", item.Track.Title); Assert.Equal("Bohemian Rhapsody", item.Track.Title);
} }
[Fact]
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
{
var owner = TestEntities.User();
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
await SeedAsync(
owner, rock, jazz,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal("Loud", item.Track.Title);
}
} }
} }
@@ -0,0 +1,127 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase
{
private RecordListeningProgressCommandHandler CreateHandler() => new(Database);
private async Task<ListeningHistory> ReloadAsync(Guid id) =>
await Database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken);
private async Task<(User User, ListeningHistory Listen)> SeedListenAsync(
double durationSeconds, DateTime listenedAt)
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: durationSeconds);
var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false);
await SeedAsync(user, track, listen);
return (user, listen);
}
[Fact]
public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(10, stored.PlayedSeconds);
Assert.False(stored.IsCounted);
}
[Fact]
public async Task Handle_ReachesThirtySeconds_MarksAsCounted()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_ShortTrack_CountsAtHalfDuration()
{
var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_LowerValueThanStored_KeepsHighest()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var handler = CreateHandler();
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken);
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(40, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.InRange(stored.PlayedSeconds!.Value, 24, 27);
}
[Fact]
public async Task Handle_MoreThanTrackDuration_IsCappedToDuration()
{
var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken);
Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds);
}
[Fact]
public async Task Handle_OtherUsersListen_ReturnsNotFound()
{
var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True(result.IsError);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds)
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
}
}
@@ -40,6 +40,37 @@ namespace Musify.Application.Tests.Users
Assert.Equal(500, result.SecondsThisWeek); Assert.Equal(500, result.SecondsThisWeek);
} }
[Fact]
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: 200);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(212, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.StreakDays);
Assert.Equal(3, result.SecondsThisWeek);
}
[Fact] [Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{ {
@@ -0,0 +1,95 @@
using ErrorOr;
using Musify.Application.Shared;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Application.Users.Responses;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
{
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnProfile_ReturnsFollowers()
{
var user = TestEntities.User(1, "user");
var follower = TestEntities.User(2, "follower");
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
var result = await Send(user.Id, user.Id);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal(follower.Id, item.Id);
Assert.False(item.IsFollowedByViewer);
}
[Fact]
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var other = TestEntities.User(3, "other");
await SeedAsync(
viewer, user, other,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
var result = await Send(viewer.Id, user.Id);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_Anonymous_ReturnsForbidden()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await Send(null, 1);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await Send(1, 404);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
{
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Anonymous_ReturnsFollowedUsers()
{
var user = TestEntities.User(1, "user");
var followedA = TestEntities.User(2, "followedA");
var followedB = TestEntities.User(3, "followedB");
await SeedAsync(
user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var followedA = TestEntities.User(3, "followedA");
var followedB = TestEntities.User(4, "followedB");
await SeedAsync(
viewer, user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_Pagination_ReturnsRequestedPage()
{
await SeedAsync(
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
new UserFollow { FollowerId = 1, FollowedId = 2 },
new UserFollow { FollowerId = 1, FollowedId = 3 },
new UserFollow { FollowerId = 1, FollowedId = 4 });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(result.Value.Items);
Assert.Equal(3, result.Value.TotalItemCount);
Assert.False(result.Value.HasNextPage);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -30,6 +30,46 @@ namespace Musify.Application.Tests.Users
Assert.Equal(1, result.Value.FollowingCount); Assert.Equal(1, result.Value.FollowingCount);
} }
[Fact]
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(
viewer, user,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
Assert.True(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
Assert.True(result.Value.IsFollowing);
Assert.False(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
Assert.False(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
[Fact] [Fact]
public async Task Handle_UserMissing_ReturnsNotFound() public async Task Handle_UserMissing_ReturnsNotFound()
{ {
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users namespace Musify.Application.Tests.Users
@@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users
var user = Assert.Single(result.Value.Items); var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id); Assert.Equal(1, user.Id);
} }
[Fact]
public async Task Handle_Viewer_MarksFollowedUsers()
{
await SeedAsync(
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
new UserFollow { FollowerId = 1, FollowedId = 2 });
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
}
} }
} }
@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
@@ -43,6 +44,11 @@ namespace Musify.Infrastructure.Tests.Persistence
Assert.Equal(originalCreatedAt, user.CreatedAt); Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt, Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
await using var reader = fixture.CreateDatabase();
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
Assert.True(stored.UpdatedAt > stored.CreatedAt,
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
} }
} }
} }
+29 -24
View File
@@ -1,39 +1,39 @@
# Development configuration for deploy/compose.yml + compose.dev.yml. # Development configuration for deploy/compose.yml + compose.dev.yml.
# Copy to deploy/.env and adjust. Not versioned. # Copy to deploy/.env and adjust.
# #
# cp deploy/.env.example deploy/.env # cp deploy/.env.example deploy/.env
#
# Requires ../../../Infrastructure running first (Postgres, Zitadel,
# SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md.
# Everything marked "must match Infrastructure's .env" below has to be the
# same value as the one in that file.
DOTNET_ENVIRONMENT=Development DOTNET_ENVIRONMENT=Development
# --- Public URLs ------------------------------------------------------------- # --- Public URLs -------------------------------------------------------------
# How each service is reached from *outside* Docker. `host.docker.internal` # How each service is reached from *outside* Docker. `host.docker.internal`
# resolves to the same address from the browser, from apps running on the host # resolves to the same address from the browser, from apps running on the
# and from inside the containers, which is what makes the OIDC issuer and the # host and from inside the containers, which is what makes the OIDC issuer
# presigned S3 URLs valid in both run modes. # and the presigned S3 URLs valid in both run modes.
PUBLIC_WEB_URL=http://localhost:3000 PUBLIC_WEB_URL=http://localhost:3000
PUBLIC_API_URL=http://localhost:5111 PUBLIC_API_URL=http://localhost:5111
PUBLIC_STREAM_URL=http://localhost:8081 PUBLIC_STREAM_URL=http://localhost:8081
PUBLIC_AUTH_URL=http://host.docker.internal:8080 PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (shared by Musify and Zitadel) ------------------------------- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.
POSTGRES_HOST=host.docker.internal
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
POSTGRES_HOST_PORT=59000
# --- Zitadel (OIDC) ---------------------------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
# The masterkey MUST be exactly 32 characters. The external domain/port form # Path to the admin service-account PAT Infrastructure's Zitadel writes on
# the token issuer, so they have to match PUBLIC_AUTH_URL. Changing them needs # init. Adjust if your Infrastructure checkout lives somewhere else relative
# a re-init: docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # to this one.
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
ZITADEL_EXTERNAL_DOMAIN=host.docker.internal # Allows the OIDC apps this project creates to use plain-http redirect URIs
ZITADEL_EXTERNAL_PORT=8080 # (dev only).
ZITADEL_EXTERNAL_SECURE=false
ZITADEL_DATABASE_PASSWORD=zitadel
# Console login: <username>@zitadel.<external domain>
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=Password1!
# Allows the OIDC apps to use plain-http redirect URIs (dev only).
ZITADEL_DEV_MODE=true ZITADEL_DEV_MODE=true
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -42,17 +42,25 @@ AUTH_CLIENT_ID=
WEB_CLIENT_ID= WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (MassTransit) -------------------------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
# Must match Infrastructure's .env.
RABBITMQ_HOST=host.docker.internal
RABBITMQ_DEFAULT_USER=guest RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest RABBITMQ_DEFAULT_PASS=guest
# --- SeaweedFS (S3) ---------------------------------------------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
# S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env.
SEAWEEDFS_HOST=host.docker.internal
S3_ACCESS_KEY=admin_access_key S3_ACCESS_KEY=admin_access_key
S3_SECRET_KEY=admin_secret_key S3_SECRET_KEY=admin_secret_key
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_PUBLIC_URL=http://host.docker.internal:8333 S3_PUBLIC_URL=http://host.docker.internal:8333
S3_INTERNAL_URL=http://host.docker.internal:8333
S3_USE_HTTP=true S3_USE_HTTP=true
# --- Jaeger (shared instance, tracing) ---------------------------------------
JAEGER_HOST=host.docker.internal
# --- Web player -------------------------------------------------------------- # --- Web player --------------------------------------------------------------
# Key material for the encrypted session cookie: any long random string. # Key material for the encrypted session cookie: any long random string.
SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef
@@ -61,6 +69,3 @@ AUTH_ALLOW_INSECURE_HTTP=true
# --- Misc -------------------------------------------------------------------- # --- Misc --------------------------------------------------------------------
AUTH_REQUIRE_HTTPS=false AUTH_REQUIRE_HTTPS=false
PGADMIN_DEFAULT_EMAIL=admin@musify.dev
PGADMIN_DEFAULT_PASSWORD=admin
PGADMIN_HOST_PORT=5050
+31 -27
View File
@@ -1,41 +1,41 @@
# Production configuration for deploy/compose.yml + compose.prod.yml. # Production configuration for deploy/compose.yml + compose.prod.yml.
# Copy to deploy/.env.prod, then: # Copy to deploy/.env.prod, then:
# #
# 1. replace every `example.com` with your domain, # 1. replace every `*.example.com` with your actual domains (each service
# below has its own, independent domain — they don't need to share a
# root, e.g. for DNS providers that don't allow multi-level
# subdomains),
# 2. replace every CHANGE_ME with a strong random value # 2. replace every CHANGE_ME with a strong random value
# (`openssl rand -hex 24`; the Zitadel masterkey needs exactly 32 chars), # (`openssl rand -hex 24`),
# 3. point these DNS records at the server, all proxied/TLS-terminated the # 3. point each domain below at the server and add its vhost to the shared
# same way: example.com, www, api, auth, stream, s3. # edge (Infrastructure's edge/conf.d).
# #
# Not versioned. Keep it readable only by the deploy user (chmod 600). # Requires ../../../Infrastructure running first, on the same Docker host,
# with its own `infra-net` network already created. See
# ../../../Infrastructure/README.md. Not versioned. Keep it readable only by
# the deploy user (chmod 600).
DOTNET_ENVIRONMENT=Production DOTNET_ENVIRONMENT=Production
# --- Domain ------------------------------------------------------------------ # --- Domains -------------------------------------------------------------
# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it. # Public URLs. They must match the vhosts of the shared edge nginx
ROOT_DOMAIN=example.com # (Infrastructure's edge/conf.d), which is what serves them; the domains are
PUBLIC_WEB_URL=https://example.com # independent, they don't need to share a root.
PUBLIC_API_URL=https://api.example.com PUBLIC_WEB_URL=https://musify.example.com
PUBLIC_STREAM_URL=https://stream.example.com PUBLIC_API_URL=https://musifyapi.example.com
PUBLIC_STREAM_URL=https://musifystream.example.com
PUBLIC_AUTH_URL=https://auth.example.com PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com S3_PUBLIC_URL=https://s3.example.com
# --- PostgreSQL (shared by Musify and Zitadel) ------------------------------- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod.
POSTGRES_HOST=postgres
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC) ---------------------------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
# The masterkey MUST be exactly 32 characters and can never be rotated in place. ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
ZITADEL_MASTERKEY=CHANGE_ME_32_CHARACTERS_EXACTLY_
ZITADEL_EXTERNAL_DOMAIN=auth.example.com
ZITADEL_EXTERNAL_PORT=443
ZITADEL_EXTERNAL_SECURE=true
ZITADEL_DATABASE_PASSWORD=CHANGE_ME
# Console login: <username>@zitadel.<external domain>
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=CHANGE_ME
# Keep OIDC apps strict about https redirect URIs in production.
ZITADEL_DEV_MODE=false ZITADEL_DEV_MODE=false
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -44,16 +44,22 @@ AUTH_CLIENT_ID=
WEB_CLIENT_ID= WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (MassTransit) -------------------------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
RABBITMQ_HOST=rabbitmq
RABBITMQ_DEFAULT_USER=musify RABBITMQ_DEFAULT_USER=musify
RABBITMQ_DEFAULT_PASS=CHANGE_ME RABBITMQ_DEFAULT_PASS=CHANGE_ME
# --- SeaweedFS (S3) ---------------------------------------------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
SEAWEEDFS_HOST=seaweedfs
S3_ACCESS_KEY=CHANGE_ME S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_INTERNAL_URL=http://seaweedfs:8333
S3_USE_HTTP=false S3_USE_HTTP=false
# --- Jaeger (shared instance, tracing) ---------------------------------------
JAEGER_HOST=jaeger
# --- Web player -------------------------------------------------------------- # --- Web player --------------------------------------------------------------
# Key material for the encrypted session cookie. Rotating it logs everyone out. # Key material for the encrypted session cookie. Rotating it logs everyone out.
SESSION_SECRET=CHANGE_ME SESSION_SECRET=CHANGE_ME
@@ -61,5 +67,3 @@ AUTH_ALLOW_INSECURE_HTTP=false
# --- Misc -------------------------------------------------------------------- # --- Misc --------------------------------------------------------------------
AUTH_REQUIRE_HTTPS=true AUTH_REQUIRE_HTTPS=true
# Largest request body nginx accepts (audio uploads go through the web player).
MAX_UPLOAD_SIZE=1024m
+121 -87
View File
@@ -1,51 +1,65 @@
# Deploy # Deploy
Everything needed to run Musify, in development and in production, from the Everything needed to run Musify itself, in development and in production,
same set of compose files — no wrapper scripts, just `docker compose`. from the same set of compose files, no wrapper scripts, just `docker
compose`. Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are **not**
started here: they're shared infrastructure that lives in the separate
`Infrastructure` repository and has to already be running (see its own
README). `../../Infrastructure` below is just an example path, assuming
that repo happens to be checked out as a sibling of this one. Adjust it to
wherever you actually cloned it.
| File | Role | | File | Role |
|---|---| |---|---|
| `compose.yml` | Every service, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. | | `compose.yml` | Musify's own services, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. |
| `compose.dev.yml` | Development overlay: publishes the ports to the host, adds pgAdmin. | | `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. |
| `compose.prod.yml` | Production overlay: adds the nginx edge. | | `compose.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. |
| `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. |
The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind the The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind
`apps` compose profile, so a plain `up` starts infrastructure only — the usual the `apps` compose profile, so a plain `up` starts only the one-shot
development setup, with the apps running from the IDE. bootstrap jobs, the usual development setup, with the apps running from the
IDE.
### The one-shot bootstrap jobs ### The one-shot bootstrap jobs
Three services in `compose.yml` run once and exit, so a plain `docker compose Four services in `compose.yml` run once and exit, so a plain `docker compose
up -d` leaves the stack fully ready with no manual steps: up -d` leaves the stack fully ready with no manual steps:
| Service | Does | | Service | Does |
|---|---| |---|---|
| `wait-for-postgres` | Waits for Infrastructure's Postgres to accept connections. Replaces an in-file health-check `depends_on`, which only works within the same compose file. |
| `keys-init` | Generates the RS256 stream-ticket key pair in `./keys`, if it is not there already. | | `keys-init` | Generates the RS256 stream-ticket key pair in `./keys`, if it is not there already. |
| `migrate` | Applies the EF Core migrations (built from `backend/Musify.Migrator.Dockerfile`). | | `migrate` | Applies the EF Core migrations (built from `backend/Musify.Migrator.Dockerfile`). |
| `zitadel-init` | Creates the Musify project and its two OIDC apps in Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` into the env file. | | `seaweedfs-init` | Creates Musify's own bucket on the shared SeaweedFS, if it doesn't exist yet. |
| `zitadel-init` | Creates the Musify project and its OIDC apps in the shared Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` / `NATIVE_CLIENT_ID` into the env file. |
`api` and `worker` wait for `migrate`; `api` and `gateway` wait for `api` and `worker` wait for `migrate`; `api` waits for `keys-init`
`keys-init` (`depends_on: condition: service_completed_successfully`). They do (`depends_on: condition: service_completed_successfully`). Nothing waits on
**not** wait for `zitadel-init` — compose interpolates `${AUTH_CLIENT_ID}` once, `zitadel-init`, `seaweedfs-init` or the shared infrastructure services
when a command starts, so a container that starts in the *same* `up` as directly. Compose interpolates `${AUTH_CLIENT_ID}` once, when a command
`zitadel-init` would still get the empty value it had before provisioning ran. starts, so a container that started in the *same* `up` as `zitadel-init`
That's why bringing up the apps is a second command: by the time you run it, would still get the empty value it had before provisioning ran. That's why
bringing up the apps is a second command: by the time you run it,
`zitadel-init` has already finished and the env file has real values. `zitadel-init` has already finished and the env file has real values.
--- ---
## Development ## Development
Start the separate `Infrastructure` repository first, from wherever you
cloned it (see its own README for the exact command). Then, from this
repository's root:
```sh ```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
``` ```
The first command starts the infrastructure and runs the bootstrap jobs The first command starts Musify's own bootstrap jobs (Postgres wait, keys,
(keys, migrations, Zitadel project/apps). The second builds and runs `api`, migrations, the bucket, the Zitadel project/apps). The second builds and runs
`worker`, `gateway` and `web-player` in Docker too — skip it to run the apps `api`, `worker`, `gateway` and `web-player` in Docker too. Skip it to run the
from the IDE instead (the usual dev loop): apps from the IDE instead (the usual dev loop):
```sh ```sh
dotnet run --project backend/Hosts/Musify.Api # :5111 dotnet run --project backend/Hosts/Musify.Api # :5111
@@ -55,86 +69,117 @@ npm --prefix web-player run dev # :5173
``` ```
When running the apps from the IDE, `Musify.Api` reads `Authentication:*` When running the apps from the IDE, `Musify.Api` reads `Authentication:*`
from user-secrets and `web-player` reads its own `web-player/.env` — neither from user-secrets and `web-player` reads its own `web-player/.env`, neither
looks at `deploy/.env`. Copy the three values `zitadel-init` wrote looks at `deploy/.env`. Copy the four values `zitadel-init` wrote
(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`) into (`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`, `NATIVE_CLIENT_ID`)
`dotnet user-secrets set Authentication:ClientId <value> --project into `dotnet user-secrets set Authentication:ClientId <value> --project
backend/Hosts/Musify.Api` and into `web-player/.env` once; they stay valid backend/Hosts/Musify.Api` and into `web-player/.env` once. They stay valid
until you wipe the Zitadel volume. until Infrastructure's Zitadel volume is wiped.
Add `--profile tools` to also start pgAdmin.
### Ports ### Ports
| Service | Port | Used by | | Service | Port | Used by |
|---|---|---| |---|---|---|
| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and Zitadel | | Musify.Api | `5111` | `/scalar/v1`, `/openapi/v1.json` |
| Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` | | Musify.StreamingGateway | `8081` | `/health`, `/media/...` |
| RabbitMQ | `5672` / `15672` | MassTransit / management UI | | web-player | `5173` (`npm run dev`) or `3000` (container) | |
| SeaweedFS | `8333` / `8888` / `9333` | S3 / filer (→ gateway) / master |
| Jaeger | `16686` / `4317` / `4318` | traces UI / OTLP gRPC / OTLP HTTP |
| pgAdmin (`--profile tools`) | `5050` | Postgres UI |
| API, gateway, web player (`--profile apps`) | `5111` / `8081` / `3000` | |
Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg is only Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger ports come from
needed on the host if you run the Worker outside Docker — inside Docker its Infrastructure. See its own README for that table.
image already has it.)
Host requirements: .NET 10 SDK, Node 22, Docker Desktop. ffmpeg is only
needed on the host if you run the Worker outside Docker; inside Docker its
image already has it.
### Why `host.docker.internal` ### Why `host.docker.internal`
`PUBLIC_AUTH_URL` and `S3_PUBLIC_URL` point at `host.docker.internal`, which `PUBLIC_AUTH_URL` and `S3_PUBLIC_URL` point at `host.docker.internal`, which
resolves to the same address from the browser, from apps running on the host resolves to the same address from the browser, from apps running on the host
and from inside the containers. That keeps the OIDC token issuer and the and from inside the containers. That keeps the OIDC token issuer and the
presigned S3 URLs valid whichever way the apps are running. Zitadel routes by presigned S3 URLs valid whichever way the apps are running, and is the same
the `Host` header, so the domain has to be identical everywhere; changing it address Infrastructure's own dev ports are published on.
requires wiping the Postgres/Zitadel volumes and starting over (`docker
compose -f compose.yml -f compose.dev.yml down -v`, then `up -d` again).
### Resetting ### Resetting
```sh ```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down # stop, keep data docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down # stop, keep data
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe volumes docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe Musify's own volumes
``` ```
This only touches Musify's own containers and volumes. Resetting Zitadel or
SeaweedFS is done from the `Infrastructure` repo instead.
--- ---
## Production ## Production
On a server with a domain, from the repository root: Musify doesn't run its own nginx: the shared edge in Infrastructure
(`edge/`) owns host ports 80/443, terminates TLS for every project's domains
and proxies to Musify's containers over `infra-net`. On a server with
domains:
1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled
in. It creates the `infra-net` network this stack joins.
2. Bring up its `edge/` (see Infrastructure's README) and add Musify's
vhosts and certificate there (`edge/conf.d/musify.conf`). The five
public hostnames (web, api, stream, auth, s3) are independent: they
don't need to share a root domain.
3. From this repository's root:
```sh ```sh
cp deploy/.env.prod.example deploy/.env.prod cp deploy/.env.prod.example deploy/.env.prod
``` ```
Fill it in: Replace every `*.example.com` with your domains (they must match the
edge's vhosts) and every `CHANGE_ME` with a random value
(`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
1. Replace every `example.com` with your domain and every `CHANGE_ME` with a Then:
random value (`openssl rand -hex 24`; the Zitadel masterkey needs exactly
32 characters). `chmod 600 deploy/.env.prod`.
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`** — the origin
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
mode in front of it, so a Cloudflare Origin Certificate is enough; any
certificate valid for the five hostnames works.
3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server,
proxied/TLS-terminated the same way.
Then, same two commands as development, with the prod env file and overlay:
```sh ```sh
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
``` ```
The first command starts the infrastructure, the nginx edge and the bootstrap The first command starts Musify's bootstrap jobs (`zitadel-init` and
jobs; the second builds and starts the applications, already wired to the `seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.`
OIDC client ids the first one produced. Both are idempotent — run them again URLs, so the edge has to be serving them by then); the second builds and
to deploy a new version (`docker compose ... --profile apps up -d --build` starts the applications, already wired to the OIDC client ids the first one
alone is enough for an update once the stack already exists; it reuses the produced. Both are idempotent, so running them again deploys a new version
infra containers and only rebuilds and restarts the apps). (`... --profile apps up -d --build` alone is enough for an update once the
stack already exists).
Only nginx publishes ports (80/443); everything else is reachable only on the Nothing in this stack publishes a port; everything is reachable only on the
internal compose network. internal `infra-net` network, through the edge.
### Continuous deployment
`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every
push/PR, and on a successful push to `master` deploys automatically. The
deploy job runs on `gitea-runner`, the self-hosted Gitea Actions runner on
the production host (`~/gitea/docker-compose.yml`), configured by
`~/gitea/runner-data/config.yaml` (`CONFIG_FILE`): job containers are pinned
to the `gitea_gitea` network so `actions/checkout` can resolve `gitea`,
`ubuntu-latest` is `node:22` (JS actions need Node inside the job
container), and a `production:host` label runs steps natively.
Native mode means the label's steps execute inside the runner container
itself rather than a fresh one. That container has `/var/run/docker.sock`
mounted, so `docker`/`docker compose` there still control the host's real
Docker daemon (sibling containers, "Docker outside of Docker"). Its compose
service also bind-mounts `/home/ubuntu/deploys` into itself at the *same*
path, which matters because compose's own relative bind mounts (`./keys`)
are resolved by the host daemon against that literal path; mounting it
anywhere else would silently bind the wrong (empty) directory.
`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`)
is a persistent clone on that host, reused instead of a fresh checkout each
run since `deploy/.env.prod` is untracked and only lives there.
`/home/ubuntu/deploys/SharedServices` is the sibling clone of the
`Infrastructure` repo (shared services + `edge/`), already running; its
`.env.prod` shares the Postgres/RabbitMQ/S3 credentials with Musify's own
`deploy/.env.prod` by design (see that repo's README). The edge is deployed
from that repo, not from this pipeline.
--- ---
@@ -142,17 +187,12 @@ internal compose network.
``` ```
deploy/ deploy/
├─ compose.yml # all services, incl. keys-init / migrate / zitadel-init ├─ compose.yml # Musify's own services: wait-for-postgres, keys-init,
├─ compose.dev.yml # dev overlay: host ports, pgAdmin │ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player
├─ compose.prod.yml # prod overlay: nginx edge ├─ compose.dev.yml # dev overlay: host ports
├─ compose.prod.yml # prod overlay: joins infra-net
├─ .env.example / .env.prod.example ├─ .env.example / .env.prod.example
├─ keys/ # RS256 stream-ticket key pair (generated) └─ keys/ # RS256 stream-ticket key pair (generated)
├─ nginx/
│ ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst
│ ├─ snippets/proxy.conf # proxy headers shared by every vhost
│ └─ certs/ # origin.pem + origin.key (not versioned)
└─ zitadel/
└─ .output/ # PAT written by Zitadel on init (not versioned)
``` ```
The application images are built from `backend/Musify.*.Dockerfile` (build The application images are built from `backend/Musify.*.Dockerfile` (build
@@ -161,18 +201,12 @@ and `web-player/Dockerfile`.
## Notes ## Notes
- **S3 credentials** are rendered into the SeaweedFS identity file from - **S3 credentials, the Postgres admin user, the RabbitMQ user and the
`S3_ACCESS_KEY` / `S3_SECRET_KEY` when the container starts, so they cannot Zitadel masterkey** all live in Infrastructure's own `.env`/`.env.prod` now.
drift from the ones the apps are given. This project's env files only duplicate the values that have to match (see
the comments in `.env.example`).
- **Zitadel** runs `start-from-init`, which is idempotent: the same command - **Zitadel** runs `start-from-init`, which is idempotent: the same command
works on an empty volume and on an existing one. works on an empty volume and on an existing one.
- **`latest` Zitadel images** require the standalone Login UI v2 container, - **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they
which this stack does not run. `zitadel-init` turns that requirement off so reuse the existing project/apps/bucket by name, and `migrate` is a normal
the built-in `/ui/login` is used. `dotnet ef database update`, a no-op once the schema is current.
- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`;
on case-sensitive Linux the host looks for `appsettings*.json`, so their
Dockerfiles create lowercase copies at publish time.
- **Re-running `zitadel-init` or `migrate`** is safe: the former reuses the
existing project/apps by name (regenerating the web app's secret only if it
is missing from the env file), the latter is a normal `dotnet ef database
update`, a no-op once the schema is current.
+16 -50
View File
@@ -1,67 +1,36 @@
name: musify-dev name: musify-dev
# Development overlay: publishes the infrastructure ports to the host so the # Development overlay: publishes Musify's own ports to the host so the .NET
# .NET apps and the web player can run from the IDE, and adds pgAdmin. # apps and the web player can run from the IDE.
# #
# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d # docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build # docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
# #
# `host.docker.internal` (see ZITADEL_EXTERNAL_DOMAIN / *_PUBLIC_URL in .env) # Postgres/Zitadel/SeaweedFS/RabbitMQ/Jaeger ports come from Infrastructure's
# resolves to the same address from the browser, from apps on the host and from # own compose.dev.yml, not from here. See ../../../Infrastructure/README.md.
# inside the containers, so the OIDC issuer and the presigned S3 URLs are valid # `host.docker.internal` resolves to the same address from the browser, from
# in both modes. `extra_hosts` gives Linux containers that same name. # apps on the host and from inside these containers, which is what makes the
# OIDC issuer and the presigned S3 URLs valid in both run modes. `extra_hosts`
# gives Linux containers that same name.
x-host-alias: &host-alias x-host-alias: &host-alias
- "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway" - "host.docker.internal:host-gateway"
services: services:
postgres: wait-for-postgres:
ports: extra_hosts: *host-alias
- "${POSTGRES_HOST_PORT}:5432"
zitadel: seaweedfs-init:
ports: extra_hosts: *host-alias
- "${ZITADEL_EXTERNAL_PORT}:8080"
rabbitmq:
ports:
- "5672:5672" # AMQP -> MassTransit
- "15672:15672" # management UI
seaweedfs:
ports:
- "9333:9333" # master / admin UI
- "8888:8888" # filer -> StreamingGateway
- "8333:8333" # S3 API -> API, Worker and presigned uploads
jaeger:
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
# zitadel-init (compose.yml) edits whichever env file is mounted here. # zitadel-init (compose.yml) edits whichever env file is mounted here.
zitadel-init: zitadel-init:
extra_hosts: *host-alias
volumes: volumes:
- ./.env:/workspace/.env - ./.env:/workspace/.env
# Optional Postgres UI: docker compose ... --profile tools up -d migrate:
pgadmin: extra_hosts: *host-alias
profiles: [tools]
image: dpage/pgadmin4:latest
container_name: musify-pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
PGADMIN_CONFIG_SERVER_MODE: "False"
depends_on:
postgres:
condition: service_healthy
ports:
- "${PGADMIN_HOST_PORT}:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
restart: unless-stopped
api: api:
ports: ports:
@@ -80,6 +49,3 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
extra_hosts: *host-alias extra_hosts: *host-alias
volumes:
pgadmin_data:
+10 -39
View File
@@ -1,50 +1,21 @@
name: musify-prod name: musify-prod
# Production overlay: adds the nginx edge and the host-mounted secrets. # Production overlay: joins Infrastructure's shared `infra-net` network so
# Nothing but nginx publishes a port; every other service is reachable only on # this project's containers can reach Postgres, Zitadel, SeaweedFS, RabbitMQ
# the internal `musify-prod_default` network. # and Jaeger by container name, and so the shared edge nginx (Infrastructure's
# `edge/`, which owns host ports 80/443 and terminates TLS for every domain)
# can reach api, gateway and web-player. Nothing here publishes a port.
# #
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
#
# The first command starts the infrastructure, the nginx edge and the networks:
# one-shot bootstrap jobs (stream keys, migrations, Zitadel project/apps); the default:
# second builds and starts the applications, picking up the OIDC client ids name: infra-net
# the first command just wrote into deploy/.env.prod. See deploy/README.md. external: true
services: services:
# zitadel-init (compose.yml) edits whichever env file is mounted here. # zitadel-init (compose.yml) edits whichever env file is mounted here.
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
depends_on:
zitadel:
condition: service_started
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:
+65 -169
View File
@@ -1,33 +1,32 @@
# Musify — base stack, shared by development and production. # Musify: base stack, shared by development and production.
# #
# This file publishes no host ports and hardcodes no domain: everything that # Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are NOT part of this
# differs between environments comes from the .env file (see .env.example) or # file. They're shared infrastructure that lives in ../../../Infrastructure
# from the overlay that is merged on top of it. # and must already be running (see Infrastructure/README.md). This file only
# has what's specific to Musify: its own database, its own OIDC apps and
# storage bucket (created against the shared instances below), and its own
# apps.
# #
# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d # dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
# prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d # prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
# #
# The four application services sit behind the `apps` profile, so a plain `up` # The four application services sit behind the `apps` profile, so a plain
# starts the infrastructure plus the one-shot bootstrap jobs (stream-ticket # `up` runs only the one-shot bootstrap jobs (stream-ticket keys, database
# keys, database migrations, Zitadel project/apps) and stops there — the usual # migrations, Zitadel project/apps, the storage bucket) and stops there. That's
# dev setup, with the apps running from the IDE. Add `--profile apps up -d # the usual dev setup, with the apps running from the IDE. Add `--profile apps
# --build` as a SECOND command to also build and run them in Docker: by then # up -d --build` as a SECOND command to also build and run them in Docker: by
# zitadel-init has already written the OIDC client ids into the env file, so # then zitadel-init has already written the OIDC client ids into the env
# the apps start with real values. See deploy/README.md. # file, so the apps start with real values. See deploy/README.md.
x-app-depends: &app-depends
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
seaweedfs:
condition: service_healthy
# Backend settings that are identical for the API and the Worker. ASP.NET Core # Backend settings that are identical for the API and the Worker. ASP.NET Core
# maps `__` to `:`, so these override the matching AppSettings.json sections. # maps `__` to `:`, so these override the matching appsettings.json sections.
# POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments
# (host.docker.internal in dev, the container name over infra-net in prod,
# see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL
# already do.
x-backend-env: &backend-env x-backend-env: &backend-env
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
MassTransit__Address: "rabbitmq://rabbitmq:5672" MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672"
MassTransit__Username: ${RABBITMQ_DEFAULT_USER} MassTransit__Username: ${RABBITMQ_DEFAULT_USER}
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} MassTransit__Password: ${RABBITMQ_DEFAULT_PASS}
# The public S3 URL, not the internal one: the same address is used to sign # The public S3 URL, not the internal one: the same address is used to sign
@@ -38,128 +37,35 @@ x-backend-env: &backend-env
InfrastructureStorage__ForcePathStyle: "true" InfrastructureStorage__ForcePathStyle: "true"
InfrastructureStorage__UseHttp: ${S3_USE_HTTP} InfrastructureStorage__UseHttp: ${S3_USE_HTTP}
ApplicationStorage__Bucket: ${S3_BUCKET} ApplicationStorage__Bucket: ${S3_BUCKET}
OpenTelemetry__OtlpEndpoint: "http://jaeger:4317" OpenTelemetry__OtlpEndpoint: "http://${JAEGER_HOST}:4317"
services: services:
# --- Infrastructure ------------------------------------------------------- # --- Shared-infrastructure bootstrap ---------------------------------------
postgres: # Waits for Infrastructure's Postgres to accept connections before `migrate`
# runs. This replaces a `depends_on: postgres: condition: service_healthy`:
# that only works within the same compose file, and Postgres now lives in a
# separate one (../../../Infrastructure).
wait-for-postgres:
image: postgres:17-alpine image: postgres:17-alpine
container_name: musify-postgres container_name: musify-wait-for-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# OIDC provider. `start-from-init` runs init + setup (both idempotent) and
# then starts, so the same command works on a cold and on a warm volume.
# It reuses the Postgres above: the admin user creates the `zitadel` database
# and role on first start.
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: musify-zitadel
command: start-from-init --masterkeyFromEnv --tlsMode disabled
environment:
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY}
# The external domain/port forms the token issuer and must be identical
# for the browser, the apps and the containers (Zitadel routes by Host).
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNAL_SECURE}
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT}
ZITADEL_TLS_ENABLED: "false"
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DATABASE_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# Initial console admin. Login: <username>@zitadel.<external domain>
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
# Service account + PAT written on init; the provisioning script uses it
# to create the Musify project and its OIDC apps.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat
depends_on:
postgres:
condition: service_healthy
volumes:
- ./zitadel/.output:/output
restart: unless-stopped
rabbitmq:
image: rabbitmq:4-management-alpine
container_name: musify-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# All-in-one object store: master + volume + filer + S3 gateway.
# The S3 identity file is rendered from the environment on start so the
# credentials can never drift from the ones handed to the apps.
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: musify-seaweedfs
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
cat > /tmp/s3.conf <<'JSON' set -e
{ deadline=$$(( $$(date +%s) + 60 ))
"identities": [ until pg_isready -h ${POSTGRES_HOST} -p 5432 -U ${POSTGRES_USER} >/dev/null 2>&1; do
{ [ "$$(date +%s)" -lt "$$deadline" ] || { echo "postgres did not respond at ${POSTGRES_HOST}:5432. Is Infrastructure running?" >&2; exit 1; }
"name": "musify", sleep 2
"credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }], done
"actions": ["Admin", "Read", "Write"] restart: "no"
}
]
}
JSON
exec weed server \
-ip.bind=0.0.0.0 \
-dir=/data \
-master.volumeSizeLimitMB=1024 \
-volume.max=0 \
-filer \
-s3 \
-s3.config=/tmp/s3.conf
volumes:
- seaweedfs_data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# One-shot: SeaweedFS does not auto-create buckets. # One-shot: SeaweedFS does not auto-create buckets. `aws s3 ls` doubles as
# the readiness probe, since seaweedfs-init no longer has an in-file
# `depends_on: seaweedfs: condition: service_healthy` to wait for.
seaweedfs-init: seaweedfs-init:
image: amazon/aws-cli:latest image: amazon/aws-cli:latest
container_name: musify-seaweedfs-init container_name: musify-seaweedfs-init
depends_on:
seaweedfs:
condition: service_healthy
environment: environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
@@ -167,13 +73,19 @@ services:
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true set -e
aws --endpoint-url http://seaweedfs:8333 s3 ls deadline=$$(( $$(date +%s) + 60 ))
until aws --endpoint-url ${S3_PUBLIC_URL} s3 ls >/dev/null 2>&1; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "seaweedfs did not respond at ${S3_PUBLIC_URL}. Is Infrastructure running?" >&2; exit 1; }
sleep 2
done
aws --endpoint-url ${S3_PUBLIC_URL} s3 mb s3://${S3_BUCKET} 2>/dev/null || true
aws --endpoint-url ${S3_PUBLIC_URL} s3 ls
restart: "no" restart: "no"
# One-shot: generates the RS256 key pair used to sign (API) and verify # One-shot: generates the RS256 key pair used to sign (API) and verify
# (Gateway) stream tickets, if it is not already in ./keys. No host tools # (Gateway) stream tickets, if it is not already in ./keys. No host tools
# required — openssl runs inside the container. # required, since openssl runs inside the container.
keys-init: keys-init:
image: alpine:3.20 image: alpine:3.20
container_name: musify-keys-init container_name: musify-keys-init
@@ -203,30 +115,27 @@ services:
dockerfile: Musify.Migrator.Dockerfile dockerfile: Musify.Migrator.Dockerfile
container_name: musify-migrate container_name: musify-migrate
depends_on: depends_on:
postgres: wait-for-postgres:
condition: service_healthy condition: service_completed_successfully
environment: environment:
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
restart: "no" restart: "no"
# One-shot: creates the Musify project and its two OIDC apps in Zitadel (or # One-shot: creates the Musify project and its two OIDC apps in the shared
# reuses them if they already exist), then writes AUTH_CLIENT_ID, # Zitadel (or reuses them if they already exist), then writes
# WEB_CLIENT_ID and WEB_CLIENT_SECRET back into the env file mounted at # AUTH_CLIENT_ID, WEB_CLIENT_ID and WEB_CLIENT_SECRET back into the env file
# /workspace/.env (see compose.dev.yml / compose.prod.yml) — so a later # mounted at /workspace/.env (see compose.dev.yml / compose.prod.yml), so a
# `--profile apps up` picks up real values instead of empty ones. # later `--profile apps up` picks up real values instead of empty ones.
zitadel-init: zitadel-init:
image: alpine:3.20 image: alpine:3.20
container_name: musify-zitadel-init container_name: musify-zitadel-init
depends_on:
zitadel:
condition: service_started
environment: environment:
PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL} PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL}
PUBLIC_API_URL: ${PUBLIC_API_URL} PUBLIC_API_URL: ${PUBLIC_API_URL}
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
volumes: volumes:
- ./zitadel/.output:/output:ro - ${ZITADEL_ADMIN_PAT_DIR}:/output:ro
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
@@ -240,7 +149,7 @@ services:
fi fi
# The env file may have CRLF line endings (edited on Windows), so # The env file may have CRLF line endings (edited on Windows), so
# strip \r on read and drop it on write — otherwise a "blank" value # strip \r on read and drop it on write, otherwise a "blank" value
# is actually a lone \r, and `[ -z ... ]` lies. # is actually a lone \r, and `[ -z ... ]` lies.
env_get() { grep -E "^$$2=" "$$1" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '\r'; } env_get() { grep -E "^$$2=" "$$1" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '\r'; }
env_set() { env_set() {
@@ -257,7 +166,7 @@ services:
echo "==> waiting for the Zitadel service-account PAT" echo "==> waiting for the Zitadel service-account PAT"
deadline=$$(( $$(date +%s) + 180 )) deadline=$$(( $$(date +%s) + 180 ))
while [ ! -f /output/admin-sa.pat ]; do while [ ! -f /output/admin-sa.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found, check 'docker logs musify-zitadel'" >&2; exit 1; } [ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found under /output. Is Infrastructure's Zitadel running? Check 'docker logs infra-zitadel'." >&2; exit 1; }
sleep 3 sleep 3
done done
PAT=$$(tr -d '\r\n' </output/admin-sa.pat) PAT=$$(tr -d '\r\n' </output/admin-sa.pat)
@@ -387,14 +296,6 @@ services:
echo "==> wired into $$ENV_FILE" echo "==> wired into $$ENV_FILE"
restart: "no" restart: "no"
# OTLP collector + trace UI (MassTransit / EF Core / HTTP spans).
jaeger:
image: jaegertracing/all-in-one:latest
container_name: musify-jaeger
environment:
COLLECTOR_OTLP_ENABLED: "true"
restart: unless-stopped
# --- Applications (profile: apps) ----------------------------------------- # --- Applications (profile: apps) -----------------------------------------
api: api:
@@ -404,7 +305,6 @@ services:
dockerfile: Musify.Api.Dockerfile dockerfile: Musify.Api.Dockerfile
container_name: musify-api container_name: musify-api
depends_on: depends_on:
<<: *app-depends
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
keys-init: keys-init:
@@ -439,7 +339,6 @@ services:
dockerfile: Musify.Worker.Dockerfile dockerfile: Musify.Worker.Dockerfile
container_name: musify-worker container_name: musify-worker
depends_on: depends_on:
<<: *app-depends
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
environment: environment:
@@ -458,15 +357,13 @@ services:
dockerfile: Musify.StreamingGateway.Dockerfile dockerfile: Musify.StreamingGateway.Dockerfile
container_name: musify-gateway container_name: musify-gateway
depends_on: depends_on:
seaweedfs:
condition: service_healthy
keys-init: keys-init:
condition: service_completed_successfully condition: service_completed_successfully
environment: environment:
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
ASPNETCORE_URLS: http://+:8081 ASPNETCORE_URLS: http://+:8081
StreamTicket__PublicKeyPath: /keys/stream_public.pem StreamTicket__PublicKeyPath: /keys/stream_public.pem
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/" ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://${SEAWEEDFS_HOST}:8888/"
ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}" ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}"
Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL} Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL}
volumes: volumes:
@@ -484,6 +381,10 @@ 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}
@@ -495,10 +396,5 @@ services:
API_BASE_URL: http://api:5111 API_BASE_URL: http://api:5111
# Presigned PUTs are signed for the public S3 host; the container sends # Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge. # them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: http://seaweedfs:8333 S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
restart: unless-stopped restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
seaweedfs_data:
-9
View File
@@ -1,9 +0,0 @@
# 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;
@@ -1,119 +0,0 @@
# 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;
}
}
+39 -28
View File
@@ -1,47 +1,58 @@
# Musify — Documentación # Musify documentation
Musify es un backend de streaming de música: subir pistas, procesarlas (transcodificar el audio a `.m4a` y generar miniaturas), organizarlas en playlists y reproducirlas en streaming sin que los bytes pasen por la API. Musify is a music streaming backend: upload tracks, process them (transcode
the audio to `.m4a`, generate thumbnails), organize them into playlists, and
stream them without the bytes ever passing through the API.
Esta carpeta explica **qué hace cada pieza, su propósito y por qué está diseñada así**. This folder explains **what each piece does, its purpose, and why it's built
this way**.
## Índice ## Index
- [architecture.md](architecture.md) — Visión global: componentes, cómo encajan y los flujos principales. - [architecture.md](architecture.md): overview, components, how they fit together, and the main flows.
- [projects.md](projects.md) — Qué es y para qué sirve cada proyecto/solución. - [projects.md](projects.md): what each project/solution is and what it's for.
- [authentication.md](authentication.md) — Zitadel (OIDC), validación de JWT, provisioning/sync de usuarios. - [authentication.md](authentication.md): Zitadel (OIDC), JWT validation, user provisioning/sync.
- [storage.md](storage.md) — SeaweedFS (S3), estructura de claves, URLs prefirmadas, qué es público y qué privado. - [storage.md](storage.md): SeaweedFS (S3), key layout, presigned URLs, what's public vs. private.
- [streaming.md](streaming.md) — Streaming del audio (`.m4a` por HTTP range) con stream-ticket (RS256) y el reverse proxy (StreamingGateway). - [streaming.md](streaming.md): audio streaming (`.m4a` over HTTP range) with stream tickets (RS256) and the reverse proxy (StreamingGateway).
- [media-processing.md](media-processing.md) — El worker y los workflows de MassTransit (transcode de audio, miniaturas). - [media-processing.md](media-processing.md): the worker and its MassTransit workflows (audio transcoding, thumbnails).
- [development.md](development.md) — Cómo levantar todo en local: contenedores, puertos y comandos. - [development.md](development.md): running everything locally, containers, ports, commands.
- [../deploy/README.md](../deploy/README.md) — El deploy en detalle: compose, scripts y producción. - [../deploy/README.md](../deploy/README.md): deployment in detail, compose, scripts, and production.
## Mapa de componentes (alto nivel) ## Component map (high level)
``` ```
┌─────────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐
Cliente ───► │ Musify.Api (:5111) API REST + emite tickets │ Client ───► │ Musify.Api (:5111) REST API + issues tickets│
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
│ EF Core │ presigned (subidas) │ MassTransit (eventos) │ EF Core │ presigned (uploads) │ MassTransit (events)
▼ ▼ ▼ ▼ ▼ ▼
PostgreSQL SeaweedFS S3 (:8333) RabbitMQ ──► Worker (procesa) PostgreSQL SeaweedFS S3 (:8333) RabbitMQ ──► Worker (processes)
(metadatos) (objetos) audio→.m4a, imágenes (metadata) (objects) audio→.m4a, images
▲ ▲
Cliente ───► StreamingGateway (:8081) ┘ sirve audio .m4a (valida ticket, proxy al filer) Client ───► StreamingGateway (:8081) ┘ serves .m4a audio (validates ticket, proxies to the filer)
Login: Cliente ◄──► Zitadel (:8080, OIDC) → Musify.Api valida el JWT Login: Client ◄──► Zitadel (:8080, OIDC) → Musify.Api validates the JWT
``` ```
PostgreSQL, Zitadel, SeaweedFS and RabbitMQ are not run by this repository.
They're shared infrastructure, provided by the separate `Infrastructure`
repository, that this stack connects to the same way any other project
does.
## Stack ## Stack
- **.NET 10**, C#. Minimal APIs (`Musify.Api`), Worker Service (`Musify.Worker`), ASP.NET + YARP (`Musify.StreamingGateway`). - **.NET 10**, C#. Minimal APIs (`Musify.Api`), Worker Service (`Musify.Worker`), ASP.NET + YARP (`Musify.StreamingGateway`).
- **CQRS** con [Mediator] (source-generator) y resultados con **ErrorOr**. - **CQRS** with [Mediator] (source generator) and results via **ErrorOr**.
- **EF Core 10** + **PostgreSQL** (metadatos). - **EF Core 10** + **PostgreSQL** (metadata).
- **MassTransit 8** + **RabbitMQ** (procesado asíncrono con routing slips/sagas). - **MassTransit 8** + **RabbitMQ** (async processing with routing slips/sagas).
- **SeaweedFS** (almacenamiento de objetos compatible con S3, vía AWSSDK.S3). - **SeaweedFS** (S3-compatible object storage, via AWSSDK.S3).
- **ffmpeg** (transcode de audio a AAC/`.m4a`), **SixLabors.ImageSharp** (miniaturas). - **ffmpeg** (audio transcoding to AAC/`.m4a`), **SixLabors.ImageSharp** (thumbnails).
- **Zitadel** (OIDC/OAuth2) para identidad. - **Zitadel** (OIDC/OAuth2) for identity.
## Estructura del repositorio ## Repository layout
Un solo repo: `backend/` (los seis proyectos .NET), `web-player/` (cliente web A single repo: `backend/` (the six .NET projects), `web-player/` (the
SvelteKit), `deploy/` (stacks de Docker Compose y scripts) y `docs/`. SvelteKit web client), `deploy/` (Docker Compose stacks and scripts), and
`docs/`. The shared infrastructure (Postgres, Zitadel, SeaweedFS, RabbitMQ,
Jaeger) lives in the separate `Infrastructure` repository, a sibling of this
one, and is not part of this layout.
+41 -41
View File
@@ -1,55 +1,55 @@
# Arquitectura # Architecture
## Principios ## Principles
- **Clean architecture / capas** en `backend/`: el dominio y la aplicación no dependen de infraestructura; la infraestructura implementa las interfaces (`Application/Contracts`). - **Clean architecture / layers** in `backend/`: the domain and application layers don't depend on infrastructure; infrastructure implements their interfaces (`Application/Contracts`).
- **CQRS**: cada operación es un `Command` o `Query` con su handler (Mediator). Los handlers devuelven `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), que la API traduce a códigos HTTP. - **CQRS**: every operation is a `Command` or `Query` with its own handler (Mediator). Handlers return `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), which the API translates into HTTP status codes.
- **Los bytes pesados no pasan por la API**: subidas con URLs prefirmadas (cliente → S3) y reproducción vía un reverse proxy dedicado (gateway → S3). La API solo mueve metadatos y emite permisos. - **Heavy bytes never touch the API**: uploads use presigned URLs (client → S3) and playback goes through a dedicated reverse proxy (gateway → S3). The API only moves metadata and issues permissions.
- **Procesado asíncrono**: crear una pista no transcodifica en caliente; publica un evento y un worker hace el trabajo pesado con compensación (sagas/routing slips de MassTransit). - **Async processing**: creating a track doesn't transcode it on the spot. It publishes an event, and a worker does the heavy lifting, with compensation (MassTransit sagas/routing slips).
## Componentes ## Components
| Componente | Responsabilidad | Por qué separado | | Component | Responsibility | Why it's separate |
|---|---|---| |---|---|---|
| **Musify.Api** | API REST (usuarios, pistas, playlists), autoriza, emite presigned URLs y stream-tickets | Punto de entrada fino; no procesa medios ni sirve bytes | | **Musify.Api** | REST API (users, tracks, playlists), authorizes requests, issues presigned URLs and stream tickets | A thin entry point, it doesn't process media or serve bytes |
| **Domain/Application/Infrastructure** | Dominio + casos de uso + infraestructura compartida (EF, S3, MassTransit, ffmpeg) | Lógica reutilizada por la API y el Worker | | **Domain/Application/Infrastructure** | Domain + use cases + shared infrastructure (EF, S3, MassTransit, ffmpeg) | Logic shared between the API and the Worker |
| **StreamingGateway** | Reverse proxy (YARP) que valida el ticket y sirve el audio (`.m4a`) desde SeaweedFS | Escala con el tráfico de bytes, independiente de la API; SeaweedFS queda privado detrás | | **StreamingGateway** | Reverse proxy (YARP) that validates the ticket and serves the audio (`.m4a`) from SeaweedFS | Scales with byte traffic independently of the API, and keeps SeaweedFS private behind it |
| **Worker** | Consume eventos de MassTransit: transcodifica audio a `.m4a` y genera miniaturas | Trabajo CPU/IO intensivo fuera del request HTTP | | **Worker** | Consumes MassTransit events: transcodes audio to `.m4a` and generates thumbnails | Keeps CPU/IO-heavy work off the HTTP request path |
| **PostgreSQL** | Metadatos (usuarios, pistas, playlists, upload intents) | — | | **PostgreSQL** *(shared, runs in the separate `Infrastructure` repo)* | Metadata (users, tracks, playlists, upload intents) | n/a |
| **RabbitMQ** | Cola de eventos + outbox transaccional | Desacopla creación de procesado | | **RabbitMQ** *(shared, runs in Infrastructure)* | Event queue + transactional outbox | Decouples creation from processing |
| **SeaweedFS** | Almacenamiento de objetos (S3 + filer) | Guarda originales y derivados (audio `.m4a`, miniaturas) | | **SeaweedFS** *(shared, runs in Infrastructure)* | Object storage (S3 + filer) | Stores originals and derivatives (`.m4a` audio, thumbnails) |
| **Zitadel** | Identidad (OIDC/OAuth2) | Login y emisión de JWT | | **Zitadel** *(shared, runs in Infrastructure)* | Identity (OIDC/OAuth2) | Login and JWT issuance |
## Flujos principales ## Main flows
### 1. Subida de una pista ### 1. Uploading a track
1. Cliente pide `POST /tracks/upload-urls` → la API crea **UploadIntents** (reservas) y devuelve **URLs prefirmadas PUT** (imagen + audio). 1. The client requests `POST /tracks/upload-urls`. The API creates **UploadIntents** (reservations) and returns **presigned PUT URLs** (image + audio).
2. Cliente sube los ficheros **directo a SeaweedFS** con esas URLs (no pasan por la API). 2. The client uploads the files **directly to SeaweedFS** using those URLs (never through the API).
3. Cliente llama `POST /tracks` con los `intentId`s → la API valida los intents, crea el `Track` (+ `UserHasTrack`) y publica `CreateTrackResourcesEvent`. 3. The client calls `POST /tracks` with the `intentId`s. The API validates the intents, creates the `Track` (+ `UserHasTrack`), and publishes `CreateTrackResourcesEvent`.
4. El **Worker** consume el evento: mueve los originales a su sitio, genera miniaturas y transcodifica el audio a `.m4a`, y actualiza el estado del track. 4. The **Worker** consumes the event: moves the originals into place, generates thumbnails, transcodes the audio to `.m4a`, and updates the track's status.
Ver detalle en [media-processing.md](media-processing.md) y [storage.md](storage.md). See [media-processing.md](media-processing.md) and [storage.md](storage.md) for details.
### 2. Reproducción (streaming) ### 2. Playback (streaming)
1. Cliente pide `GET /tracks/{id}/stream` (autenticado) → la API autoriza y devuelve `{ manifestUrl, ticket }` (URL al `.m4a` + ticket RS256 acotado a la carpeta de esa pista). 1. The client requests `GET /tracks/{id}/stream` (authenticated). The API authorizes it and returns `{ manifestUrl, ticket }` (a URL to the `.m4a` plus an RS256 ticket scoped to that track's folder).
2. El reproductor abre la URL del audio añadiendo `?t=<ticket>` (o cabecera `X-Stream-Ticket`) y descarga por HTTP range. 2. The player opens the audio URL with `?t=<ticket>` appended (or an `X-Stream-Ticket` header) and downloads it via HTTP range requests.
3. El **StreamingGateway** valida el ticket y reenvía al filer de SeaweedFS; los bytes van storage → cliente. 3. The **StreamingGateway** validates the ticket and forwards the request to the SeaweedFS filer. The bytes flow storage → client.
Ver [streaming.md](streaming.md). See [streaming.md](streaming.md).
### 3. Autenticación ### 3. Authentication
1. Cliente hace login OIDC contra **Zitadel** y obtiene un access token (JWT). 1. The client logs in via OIDC against **Zitadel** and gets an access token (JWT).
2. La Musify.Api valida la firma/iss/aud del JWT. 2. Musify.Api validates the JWT's signature, issuer and audience.
3. En cada token validado, se **sincroniza** el usuario local (crea/actualiza nombre y foto) — provisioning just-in-time. 3. On every validated token, the local user is **synced** (name and picture created or updated). This is just-in-time provisioning.
Ver [authentication.md](authentication.md). See [authentication.md](authentication.md).
## Modelo de datos (entidades) ## Data model (entities)
- **User** — `Id` es el `sub` numérico de Zitadel (`long`), más nombre y `ProfilePictureUrl`. - **User**: `Id` is Zitadel's numeric `sub` (`long`), plus a name and `ProfilePictureUrl`.
- **Track** — pista: nombres de originales/derivados, `AudioFolderName` (carpeta del `.m4a` transcodificado), estados de procesado (`ProcessingStatus`), `LifeCycleStatus`. - **Track**: original/derivative file names, `AudioFolderName` (the transcoded `.m4a`'s folder), processing state (`ProcessingStatus`), `LifeCycleStatus`.
- **PlayList** — lista del usuario (`UserId`). - **PlayList**: a user's playlist (`UserId`).
- **PlayListHasTrack** — pistas dentro de una playlist (con `Position`). - **PlayListHasTrack**: the tracks inside a playlist (with `Position`).
- **UserHasTrack** — propiedad: qué usuario posee qué pista. - **UserHasTrack**: ownership, which user owns which track.
- **UploadIntent** — reserva de subida (clave, bucket, tamaño esperado, estado, expiración) para validar y aplicar cuota. - **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota.
- **Upload** — registro de objetos subidos/transferidos al storage. - **Upload**: a record of objects uploaded or transferred to storage.
+26 -26
View File
@@ -1,40 +1,40 @@
# Autenticación e identidad # Authentication and identity
## Proveedor: Zitadel (OIDC/OAuth2) ## Provider: Zitadel (OIDC/OAuth2)
El login y la emisión de tokens los hace **Zitadel** (no la API). La API es un **resource server**: solo **valida** el access token (JWT) que le llega en `Authorization: Bearer`. Login and token issuance are handled by **Zitadel**, not the API. The API is a **resource server**: it only **validates** the access token (JWT) it receives in `Authorization: Bearer`.
- Config en la sección `Authentication` (`AuthenticationConfiguration`): metadata address, issuer, audience, client id, endpoints de authorize/token, scopes. - Configured under the `Authentication` section (`AuthenticationConfiguration`): metadata address, issuer, audience, client id, authorize/token endpoints, scopes.
- `JwtBearerOptionsSetup` configura la validación (issuer, audience, claves vía metadata OIDC). - `JwtBearerOptionsSetup` sets up validation (issuer, audience, keys via OIDC metadata).
- En dev, `RequireHttpsMetadata = false`. - In dev, `RequireHttpsMetadata = false`.
### Requisito clave en la app de Zitadel ### Key requirement in the Zitadel app
- **Auth Token Type = JWT** (si no, Zitadel emite un token **opaco** sin puntos y `JwtBearer` no lo puede validar → 401 en todo lo protegido). - **Auth Token Type = JWT** (otherwise Zitadel issues an **opaque** token with no dots, which `JwtBearer` can't validate → 401 on everything protected).
- **User Info inside Token** activado si quieres que el access token traiga claims de perfil (`name`, `email`, `picture`). Por defecto el access token es mínimo (`sub`, `aud`, `iss`, `exp`…), sin perfil. - **User Info inside Token** enabled if you want the access token to carry profile claims (`name`, `email`, `picture`). By default the access token is minimal (`sub`, `aud`, `iss`, `exp`…), with no profile info.
## El `sub` es numérico → `User.Id` es `long` ## `sub` is numeric → `User.Id` is `long`
El `sub` de Zitadel es un entero de 64 bits en string (p. ej. `371953080444977155`), **no un GUID**. Por eso: Zitadel's `sub` is a 64-bit integer encoded as a string (e.g. `371953080444977155`), **not a GUID**. Because of that:
- `User.Id` y todas las FKs de usuario (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) son **`long`**. - `User.Id` and every user-related FK (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) are **`long`**.
- `User.Id` lleva `[DatabaseGenerated(None)]` para que EF **no** lo autogenere: se asigna el `sub`. - `User.Id` has `[DatabaseGenerated(None)]` so EF doesn't autogenerate it: it's assigned from the `sub`.
- `CurrentUser.Id` es `long?` (parsea con `long.TryParse`); `RequiredId` lanza si falta. - `CurrentUser.Id` is `long?` (parsed with `long.TryParse`); `RequiredId` throws if it's missing.
**Por qué**: el código antiguo hacía `Guid.Parse(sub)` y reventaba (FormatException) → 401 en cada endpoint protegido. Se decidió guardar el `sub` tal cual como número. **Why**: the original code did `Guid.Parse(sub)`, which threw a `FormatException` → 401 on every protected endpoint. The fix was to store the `sub` as-is, as a number.
## Provisioning / sincronización de usuarios ## User provisioning / sync
En cada token validado, `JwtBearerEventsHandler.TokenValidated`: On every validated token, `JwtBearerEventsHandler.TokenValidated`:
1. Lee `sub` (id), `name`/`preferred_username`/`email` (con fallback) y el claim OIDC `picture`. 1. Reads `sub` (id), `name`/`preferred_username`/`email` (with fallback), and the OIDC `picture` claim.
2. Envía un `SyncUserCommand`. 2. Sends a `SyncUserCommand`.
`SyncUserCommandHandler` hace un **upsert idempotente**: `SyncUserCommandHandler` does an **idempotent upsert**:
- Si el usuario no existe → lo crea. - If the user doesn't exist yet → creates it.
- Si existe → actualiza nombre/apellidos/`ProfilePictureUrl` **solo si cambiaron** (escribe en DB únicamente cuando hay cambios). - If it exists → updates the name and `ProfilePictureUrl` **only if they changed** (a DB write only happens when something is different).
**Por qué upsert con "escribe solo si cambió"**: `TokenValidated` se ejecuta en **cada request**; sin ese guardado condicional estaríamos escribiendo en cada petición. Y debe ser resiliente: si el provisioning falla, se loguea pero **no** tumba la autenticación. **Why "write only if changed"**: `TokenValidated` runs on **every request**; without that guard we'd be writing to the DB on every single call. It also needs to be resilient: if provisioning fails, it's logged but authentication **doesn't** fail because of it.
> Nota: `CreateUserCommand` (endpoint manual `POST /users`) sigue siendo create-only; el sync del login usa `SyncUserCommand`. > Note: `CreateUserCommand` (the manual `POST /users` endpoint) is still create-only; login sync uses `SyncUserCommand`.
## Foto de perfil ## Profile picture
`User.ProfilePictureUrl` se rellena desde el claim `picture` del token. Si Zitadel no mete la info de perfil en el access token (ver arriba), llega `null` — el código es correcto, pero no hay nada que sincronizar hasta activar esa opción. `User.ProfilePictureUrl` is filled from the token's `picture` claim. If Zitadel doesn't include profile info in the access token (see above), it arrives as `null`. The code is correct, there's just nothing to sync until that option is turned on.
+92 -88
View File
@@ -1,21 +1,25 @@
# Entorno de desarrollo # Development environment
Cómo levantar todo en local y probarlo. El detalle completo del deploy (y el de How to run everything locally and test it. The full deployment details (dev
producción) está en [deploy/README.md](../deploy/README.md). and prod) live in [deploy/README.md](../deploy/README.md).
## Arranque ## Startup
Bring up the separate `Infrastructure` repository first (Postgres, Zitadel,
SeaweedFS, RabbitMQ, Jaeger, see its own README for the exact command), then:
```sh ```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
``` ```
Ese único comando levanta la infraestructura y corre los jobs *one-shot* de That runs Musify's own one-shot bootstrap jobs: it generates the RS256
bootstrap: genera las claves RS256 de stream-ticket en `deploy/keys/`, aplica stream-ticket keys in `deploy/keys/`, applies the EF Core migrations, creates
las migraciones EF Core y provisiona Zitadel (proyecto + apps OIDC), dejando Musify's storage bucket, and provisions Zitadel (project + OIDC apps),
los client ids en `deploy/.env`. Detalle completo, incluido cómo arrancar las writing the client ids into `deploy/.env`. Full details, including how to run
apps en Docker con `--profile apps`, en [deploy/README.md](../deploy/README.md). the apps in Docker with `--profile apps`, are in
[deploy/README.md](../deploy/README.md).
## Aplicaciones ## Applications
```powershell ```powershell
dotnet run --project backend/Hosts/Musify.Api # :5111 dotnet run --project backend/Hosts/Musify.Api # :5111
@@ -24,45 +28,45 @@ dotnet run --project backend/Hosts/Musify.Worker # background
npm --prefix web-player run dev # :5173 npm --prefix web-player run dev # :5173
``` ```
## Mapa de puertos ## Port map
| Servicio | Puerto | | Service | Port |
|---|---| |---|---|
| Musify.Api | 5111 (`/scalar/v1`, `/openapi/v1.json`) | | Musify.Api | 5111 (`/scalar/v1`, `/openapi/v1.json`) |
| Musify.StreamingGateway | 8081 (`/health`, `/media/...`) | | Musify.StreamingGateway | 8081 (`/health`, `/media/...`) |
| web-player | 5173 (`npm run dev`) o 3000 (contenedor) | | web-player | 5173 (`npm run dev`) or 3000 (container) |
| SeaweedFS S3 / filer / master | 8333 / 8888 / 9333 |
| Zitadel | 8080 |
| PostgreSQL | 59000 (→ 5432 en el contenedor) |
| RabbitMQ AMQP / management | 5672 / 15672 |
| Jaeger UI / OTLP | 16686 / 4317 |
| pgAdmin (`-Tools`) | 5050 |
DB: `musify_db`, usuario `postgres`/`postgres`. Bucket S3: `webapi-storage` Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not run by this repo.
(credenciales `admin_access_key`/`admin_secret_key`). Todos los valores salen de Their ports are documented in the separate `Infrastructure` repository's own
`deploy/.env`. README.
## Migraciones DB: `musify_db`, user `postgres`/`postgres` on Infrastructure's shared
Postgres. S3 bucket: `webapi-storage` (credentials
`admin_access_key`/`admin_secret_key`, also Infrastructure's). All of these
come from `deploy/.env`.
El servicio `migrate` de `deploy/compose.yml` las aplica en cada `docker ## Migrations
compose up -d` (no-op si el esquema ya está al día). A mano, tras cambios de
esquema, contra la base local: The `migrate` service in `deploy/compose.yml` applies them on every `docker
compose up -d` (a no-op if the schema is already current). To run them by
hand after a schema change, against the local database:
```powershell ```powershell
dotnet ef database update --project backend/Core/Musify.Infrastructure --startup-project backend/Core/Musify.Infrastructure --context Database dotnet ef database update --project backend/Core/Musify.Infrastructure --startup-project backend/Core/Musify.Infrastructure --context Database
``` ```
`Musify.Infrastructure` es a la vez el proyecto de migraciones y el de arranque: `Musify.Infrastructure` is both the migrations project and the startup
tiene el `IDesignTimeDbContextFactory` y el paquete `EFCore.Design`. La cadena project: it has the `IDesignTimeDbContextFactory` and the `EFCore.Design`
de conexión se lee de sus user-secrets, o de la variable de entorno package. The connection string is read from its user-secrets, or from the
`Database__ConnectionString` si no hay user-secrets (así es como la usa el `Database__ConnectionString` environment variable if there are no
contenedor `migrate`, ver `backend/Musify.Migrator.Dockerfile`). user-secrets (which is how the `migrate` container uses it, see
`backend/Musify.Migrator.Dockerfile`).
## Stream-tickets (claves RS256) ## Stream tickets (RS256 keys)
La API firma los stream-tickets con la clave privada y el gateway los valida con The API signs stream tickets with the private key, and the gateway validates
la pública. El servicio `keys-init` de `deploy/compose.yml` las genera en them with the public one. The `keys-init` service in `deploy/compose.yml`
`deploy/keys/` si no existen; a mano: generates them in `deploy/keys/` if they don't exist yet; to do it by hand:
```powershell ```powershell
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/stream_private.pem openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/stream_private.pem
@@ -78,74 +82,74 @@ openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_p
dotnet test backend/Musify.slnx dotnet test backend/Musify.slnx
``` ```
`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene cinco proyectos xUnit: `backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects:
| Proyecto | Cubre | Docker | | Project | Covers | Docker |
|---|---|---| |---|---|---|
| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | No | | `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No |
| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No | | `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No |
| `Musify.Infrastructure.Tests` | `Database`/migraciones, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Sí** | | `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Yes** |
| `Musify.Api.Tests` | Endpoints HTTP reales (routing, auth, `ValidationFilter`, mapeo `ErrorOr`→HTTP) vía `WebApplicationFactory`, más `ErrorOrHttpExtensions`/`CurrentUser`/validators FluentValidation en aislado. | **Sí** | | `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** |
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) y `TicketValidationMiddleware` (traversal, límites de prefijo, extracción de ticket). | No | | `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No |
`Musify.Worker` no tiene proyecto de test: su `Program.cs` es puro *wiring* de `Musify.Worker` has no test project: its `Program.cs` is pure DI/host wiring
DI/host (Hangfire, MassTransit, jobs) — la lógica real que ejecuta vive en (Hangfire, MassTransit, jobs). The actual logic it runs lives in
`Musify.Infrastructure`, ya cubierta ahí. `Musify.Infrastructure`, already covered there.
### Application.Tests: por qué SQLite en memoria y no mocks ### Application.Tests: why in-memory SQLite instead of mocks
`IDatabase` expone `DbSet<T>` directamente, así que los handlers arman LINQ `IDatabase` exposes `DbSet<T>` directly, so handlers build real LINQ
real (`Where`, `Include`, `Select`...) contra él — no se puede mockear eso con (`Where`, `Include`, `Select`...) against it. That can't be mocked with
NSubstitute, no hay `IQueryable` detrás de un mock. En su lugar, NSubstitute, since there's no `IQueryable` behind a mock. Instead,
`TestSupport/TestDatabase.cs` es un `DbContext` real sobre SQLite en memoria `TestSupport/TestDatabase.cs` is a real `DbContext` on top of in-memory
(una conexión `:memory:` nueva por test, con foreign keys reales — a diferencia SQLite (a fresh `:memory:` connection per test, with real foreign keys,
del proveedor InMemory de EF Core, SQLite además soporta transacciones, que dos unlike EF Core's InMemory provider, SQLite also supports transactions,
handlers usan). `TestSupport/TestEntities.cs` y `TestConfigurations.cs` which two handlers rely on). `TestSupport/TestEntities.cs` and
construyen entidades y configuración con valores por defecto sensatos. `TestConfigurations.cs` build entities and configuration with sensible
`IStorageService`, `IEventBus`, etc. sí se mockean con NSubstitute, al ser defaults. `IStorageService`, `IEventBus`, etc. are mocked with NSubstitute,
interfaces normales. since those are plain interfaces.
### Infrastructure.Tests y Api.Tests: Testcontainers ### Infrastructure.Tests and Api.Tests: Testcontainers
Estos dos sí necesitan Docker corriendo. Levantan un Postgres real These two need Docker running. They spin up a real Postgres
(`Testcontainers.PostgreSql`) y, en `Infrastructure.Tests`, también un (`Testcontainers.PostgreSql`) and, in `Infrastructure.Tests`, a real
SeaweedFS real (mismo `chrislusf/seaweedfs:latest` e imagen/comando que SeaweedFS too (the same `chrislusf/seaweedfs:latest` image/command as
`deploy/compose.yml`, no MinIO) — así `StorageService` se prueba contra el `deploy/compose.yml`, not MinIO), so `StorageService` is tested against the
mismo backend S3-compatible que usa producción, no contra AWS S3. Los same S3-compatible backend production uses, not against AWS S3. Containers
contenedores se levantan una vez por corrida (`ICollectionFixture`), con are started once per run (`ICollectionFixture`), with real EF Core
migraciones EF Core reales aplicadas contra el Postgres efímero. migrations applied against the ephemeral Postgres.
`Api.Tests` usa `WebApplicationFactory<Program>` contra ese mismo Postgres — `Api.Tests` uses `WebApplicationFactory<Program>` against that same
routing, auth y validación reales — pero sustituye por fakes los servicios que Postgres, with real routing, auth, and validation, but replaces the services
hablan por red (`IStorageService`, `IEventBus`, `IStreamTicketService`) y el that talk over the network with fakes (`IStorageService`, `IEventBus`,
esquema de autenticación JWT real por un handler `IStreamTicketService`), and swaps the real JWT auth scheme for a test
de prueba (ver `TestSupport/FakeAuthenticationHandler.cs`) que autentica según handler (see `TestSupport/FakeAuthenticationHandler.cs`) that authenticates
un header, sin necesitar un Zitadel real. based on a header, with no real Zitadel needed.
Escribir estos tests contra el backend real encontró y arregló dos bugs reales Writing these tests against the real backend found and fixed two actual
(no simulados): `StorageService.RemoveFolderAsync` reventaba con bugs (not staged ones): `StorageService.RemoveFolderAsync` threw a
`NullReferenceException` contra SeaweedFS (su respuesta `DeleteObjects` omite `NullReferenceException` against SeaweedFS (its `DeleteObjects` response
la lista `<Error>` cuando no hay errores, a diferencia de AWS S3), y omits the `<Error>` list when there are no errors, unlike AWS S3), and
`AuditableEntityInterceptor` nunca actualizaba `UpdatedAt` en una actualización `AuditableEntityInterceptor` never updated `UpdatedAt` on a normal update
normal (carga → modifica → guarda) porque corría antes de que EF detectara los (load → modify → save) because it ran before EF had detected the pending
cambios pendientes. changes.
## Config (AppSettings) ## Config (appsettings)
Convención: `AppSettings.json` es la plantilla con valores vacíos o neutros; Convention: `appsettings.json` is the template, with empty or neutral
`AppSettings.Development.json` tiene los valores reales de dev; en Docker todo values; `appsettings.Development.json` has the real dev values; in Docker
se sobreescribe con variables de entorno (`Seccion__Clave`) desde everything is overridden with environment variables (`Section__Key`) from
`deploy/compose.yml`. Para correr `Musify.Api` desde el IDE, los valores de `deploy/compose.yml`. To run `Musify.Api` from the IDE, the `Authentication`
`Authentication` van en user-secrets (no en el repo) — cópialos de values go into user-secrets (not the repo). Copy them from `deploy/.env`
`deploy/.env` una vez que `zitadel-init` los haya generado (ver once `zitadel-init` has generated them (see
[deploy/README.md](../deploy/README.md)). [deploy/README.md](../deploy/README.md)).
Secciones clave: `Authentication` (Zitadel), `Database`, `MassTransit`, Key sections: `Authentication` (Zitadel), `Database`, `MassTransit`,
`InfrastructureStorage` (S3), `ApplicationStorage` (bucket), `Track`, `PlayList`, `InfrastructureStorage` (S3), `ApplicationStorage` (bucket), `Track`, `PlayList`,
`UploadIntent`, `AudioTranscoder`, `Workers`, `StreamGateway` / `StreamTicket`. `UploadIntent`, `AudioTranscoder`, `Workers`, `StreamGateway` / `StreamTicket`.
## Requisitos del host ## Host requirements
- .NET 10 SDK y Node 22. - .NET 10 SDK and Node 22.
- **ffmpeg** en el PATH (transcode de audio del Worker). - **ffmpeg** on the PATH (for the Worker's audio transcoding).
- Docker Desktop. - Docker Desktop.
+25 -25
View File
@@ -1,46 +1,46 @@
# Procesado de medios (Worker + MassTransit) # Media processing (Worker + MassTransit)
El procesado pesado (transcode de audio, miniaturas, transferencias en el bucket) ocurre **fuera del request HTTP**, en el **Worker**, orquestado con **MassTransit** sobre RabbitMQ. Heavy processing (audio transcoding, thumbnails, bucket transfers) happens **outside the HTTP request**, in the **Worker**, orchestrated with **MassTransit** on top of RabbitMQ.
## Por qué asíncrono y con routing slips ## Why async, and why routing slips
- Transcodificar audio y redimensionar imágenes es CPU/IO intensivo: no debe bloquear una llamada a la API. - Transcoding audio and resizing images is CPU/IO-intensive: it shouldn't block an API call.
- Es un proceso **multi-paso con efectos secundarios** (descargar de S3 → transcodificar → subir resultados → actualizar DB). Si un paso falla, hay que **deshacer** los anteriores. Para eso se usan **routing slips** (patrón Saga de MassTransit): cada paso es una *activity* con su **compensación**. - It's a **multi-step process with side effects** (download from S3 → transcode → upload results → update the DB). If a step fails, the previous ones need to be **undone**. That's what **routing slips** are for (MassTransit's Saga pattern): each step is an *activity* with its own **compensation**.
- **Outbox transaccional** (EF + MassTransit): el evento se publica de forma consistente con el cambio en la DB (no se pierde ni se duplica). - **Transactional outbox** (EF + MassTransit): the event is published consistently with the DB change (never lost, never duplicated).
## Disparo ## Trigger
Al crear/actualizar una pista o playlist, el handler publica un evento (p. ej. `CreateTrackResourcesEvent`). Un **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, …) construye un **routing slip** con la secuencia de activities y lo lanza. When a track or playlist is created or updated, the handler publishes an event (e.g. `CreateTrackResourcesEvent`). A **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, …) builds a **routing slip** with the sequence of activities and executes it.
## Builders de routing slip ## Routing slip builders
- `CreateTrackRoutingSlipBuilder` — alta de pista (consume intents, mueve ficheros, dispara procesado). - `CreateTrackRoutingSlipBuilder`: creating a track (consumes intents, moves files, kicks off processing).
- `PictureWorkflowRoutingSlipBuilder` — pipeline de imágenes (descargar → redimensionar → subir → actualizar). - `PictureWorkflowRoutingSlipBuilder`: image pipeline (download → resize → upload → update).
- `AudioWorkflowRoutingSlipBuilder` — pipeline de audio (descargar → transcodificar a `.m4a` → subir carpeta → actualizar). - `AudioWorkflowRoutingSlipBuilder`: audio pipeline (download → transcode to `.m4a` → upload folder → update).
- `DeleteTrackRoutingSlipBuilder`, `DeletePlayListRoutingSlipBuilder`, `PlayListPictureSourceRoutingSlipBuilder`. - `DeleteTrackRoutingSlipBuilder`, `DeletePlayListRoutingSlipBuilder`, `PlayListPictureSourceRoutingSlipBuilder`.
## Activities (pasos) ## Activities (steps)
Agrupadas por área: Grouped by area:
- **Files**: `DownloadFileFromBucketActivity`, `UploadFileToBucketActivity`, `TransferFilesToBucketActivity`, `CopyFileInBucketActivity`, `RemoveFileFromBucketActivity`. - **Files**: `DownloadFileFromBucketActivity`, `UploadFileToBucketActivity`, `TransferFilesToBucketActivity`, `CopyFileInBucketActivity`, `RemoveFileFromBucketActivity`.
- **Audio**: `GenerateAudioWorkflowPathsActivity`, `TranscodeAudioActivity` (ffmpeg → `.m4a`), `UpdateTrackAudioActivity` (guarda `AudioFolderName` y marca `Completed`). - **Audio**: `GenerateAudioWorkflowPathsActivity`, `TranscodeAudioActivity` (ffmpeg → `.m4a`), `UpdateTrackAudioActivity` (saves `AudioFolderName` and marks it `Completed`).
- **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`. - **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`.
- **Tracks/PlayLists**: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`, `PublishTrackProcessingEventsActivity`, y equivalentes de playlist. - **Tracks/Playlists**: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`, `PublishTrackProcessingEventsActivity`, and their playlist equivalents.
- **UploadIntents**: `ConsumeUploadIntentsActivity`. - **UploadIntents**: `ConsumeUploadIntentsActivity`.
`RoutingSlipCleanUpConsumer` reacciona a `Completed`/`Faulted` para limpiar (p. ej. borrar el directorio temporal de trabajo). `RoutingSlipCleanUpConsumer` reacts to `Completed`/`Faulted` to clean up (e.g. deleting the temporary working directory).
## Transcode de audio ## Audio transcoding
`AudioTranscoderService` invoca **ffmpeg** leyendo el original por stdin y produciendo en un directorio de trabajo un único `audio.m4a`: AAC con codec/bitrate/sample-rate/profile configurables y `-movflags +faststart` (moov al inicio → seek por range). Luego `TransferFilesToBucketActivity` sube esa carpeta a `Tracks/ProcessedAudios/{folder}/` y `UpdateTrackAudioActivity` guarda el `AudioFolderName` (la carpeta **de destino en S3**, no la local) y pone el estado en `Completed`. `AudioTranscoderService` calls **ffmpeg**, reading the original from stdin and producing a single `audio.m4a` in a working directory: AAC with a configurable codec/bitrate/sample-rate/profile, and `-movflags +faststart` (moov at the start, so range-based seeking works). `TransferFilesToBucketActivity` then uploads that folder to `Tracks/ProcessedAudios/{folder}/`, and `UpdateTrackAudioActivity` saves the `AudioFolderName` (the **destination folder in S3**, not the local one) and sets the status to `Completed`.
## Configuración que necesita el Worker ## What the Worker needs configured
El Worker registra: storage (S3), transcoder (ffmpeg), pictures, DB, los consumers de MassTransit y los jobs de upload intents. Su `AppSettings` debe tener las secciones correctas: `Bucket` (= `webapi-storage`, el mismo que la API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (directorio temporal), `InfrastructureStorage`, `Database`. The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `appsettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`.
## Jobs de fondo ## Background jobs
- `UploadIntentExpirationJob` — marca intents caducados como expirados (libera cuota) cada `ExpirationJobIntervalSeconds`. - `UploadIntentExpirationJob`: marks expired intents as such (freeing quota) every `ExpirationJobIntervalSeconds`.
- `TemporalUploadsCleanUpJob` — limpia subidas temporales antiguas. - `TemporalUploadsCleanUpJob`: cleans up old temporary uploads.
Corren en el host del Worker (`AddUploadIntentJobs`). Si no se registran en ningún proceso, los intents colgados no se limpian solos. Both run on the Worker host (`AddUploadIntentJobs`). If they aren't registered anywhere, stale intents never get cleaned up on their own.
+39 -37
View File
@@ -1,74 +1,76 @@
# Proyectos # Projects
Los proyectos .NET viven en `backend/` (solución `Musify.slnx`, carpetas The .NET projects live under `backend/` (solution `Musify.slnx`, folders
`Core/`, `Hosts/` y `Tests/`); el cliente web en `web-player/`. `Core/`, `Hosts/` and `Tests/`); the web client lives under `web-player/`.
## Musify.Api (`:5111`) ## Musify.Api (`:5111`)
API REST pública (Minimal APIs, .NET 10). Es la fachada fina del sistema. The public REST API (Minimal APIs, .NET 10). The thin public face of the system.
- **Endpoints**: `UserEndpoints`, `TrackEndpoints`, `PlayListEndpoints`. - **Endpoints**: `UserEndpoints`, `TrackEndpoints`, `PlayListEndpoints`.
- **Autenticación**: JWT Bearer contra Zitadel (`Authentication/`), con `JwtBearerEventsHandler` que sincroniza el usuario en cada token validado. - **Authentication**: JWT Bearer against Zitadel (`Authentication/`), with a `JwtBearerEventsHandler` that syncs the user on every validated token.
- **`CurrentUser`**: abstracción que se enlaza como parámetro de endpoint (`IBindableFromHttpContext`) y expone los claims básicos (`Id` como `long?`, `RequiredId`, nombre, etc.) sin tener que leer `ClaimsPrincipal` a mano. - **`CurrentUser`**: an abstraction bound as an endpoint parameter (`IBindableFromHttpContext`) that exposes the basic claims (`Id` as `long?`, `RequiredId`, name, etc.) without reading `ClaimsPrincipal` by hand.
- **Emite**: presigned URLs de subida (vía `IStorageService`) y **stream-tickets** (vía `IStreamTicketService`, RS256). - **Issues**: presigned upload URLs (via `IStorageService`) and **stream tickets** (via `IStreamTicketService`, RS256).
- **OpenAPI + Scalar** para documentación/prueba; OAuth2 (PKCE) configurado para login desde Scalar. - **OpenAPI + Scalar** for docs/testing; OAuth2 (PKCE) set up for logging in from Scalar.
- **CORS** totalmente abierto **solo en Development** (para el front local). - **CORS** wide open, but **only in Development** (for the local frontend).
- Traduce `ErrorOr` → HTTP en `ResultHttpExtensions`. - Translates `ErrorOr` into HTTP in `ResultHttpExtensions`.
**Propósito/porqué**: separar la cara pública (autorización + metadatos) del procesado y del servido de bytes. **Purpose**: keep the public surface (authorization + metadata) separate from processing and byte-serving.
## Musify.Domain + Musify.Application + Musify.Infrastructure ## Musify.Domain + Musify.Application + Musify.Infrastructure
El núcleo, en tres proyectos (clean architecture): The core, split into three projects (clean architecture):
### Domain ### Domain
Entidades (`Entities/`) y value objects/enums (`ValueObjects/`: `ProcessingStatus`, `LifeCycleStatus`, `UploadIntentStatus`, …). Sin dependencias de infraestructura. Entities (`Entities/`) and value objects/enums (`ValueObjects/`: `ProcessingStatus`, `LifeCycleStatus`, `UploadIntentStatus`, …). No infrastructure dependencies.
### Application ### Application
Casos de uso (CQRS con Mediator): Use cases (CQRS with Mediator):
- `Tracks/`, `PlayLists/`, `Users/` → `Commands`, `Queries`, `Handlers`, `Responses`. - `Tracks/`, `PlayLists/`, `Users/` → `Commands`, `Queries`, `Handlers`, `Responses`.
- `Contracts/` → interfaces que implementa la infraestructura (`IDatabase`, `IStorageService`, `IAudioTranscoderService`, `IPictureService`, `IEventBus`, `IStreamTicketService`). - `Contracts/` → interfaces that infrastructure implements (`IDatabase`, `IStorageService`, `IAudioTranscoderService`, `IPictureService`, `IEventBus`, `IStreamTicketService`).
- `Configuration/` → opciones tipadas (Track, PlayList, ApplicationStorage, UploadIntent…) bindeadas y validadas al arranque. - `Configuration/` → typed options (Track, PlayList, ApplicationStorage, UploadIntent…) bound and validated at startup.
- `Shared/` → helpers transversales (`StorageKey.Combine`, `ImageSize`). - `Shared/` → cross-cutting helpers (`StorageKey.Combine`, `ImageSize`).
- `Pagination/` → `PaginatedResponse<T>`. - `Pagination/` → `PaginatedResponse<T>`.
**Porqué**: la lógica de negocio no conoce EF, S3 ni RabbitMQ; solo interfaces. Reutilizable por la API y el Worker. **Why**: business logic doesn't know about EF, S3, or RabbitMQ, only interfaces. Shared by both the API and the Worker.
### Infrastructure ### Infrastructure
Implementaciones concretas: Concrete implementations:
- `Persistence/` → `Database` (EF Core/Npgsql), migraciones. - `Persistence/` → `Database` (EF Core/Npgsql), migrations.
- `Services/` → `StorageService` (AWSSDK.S3 contra SeaweedFS), `AudioTranscoderService` (ffmpeg → `.m4a`), `PictureService` (ImageSharp), `StreamTicketService` (firma RS256). - `Services/` → `StorageService` (AWSSDK.S3 against SeaweedFS), `AudioTranscoderService` (ffmpeg → `.m4a`), `PictureService` (ImageSharp), `StreamTicketService` (RS256 signing).
- `MassTransit/` → consumers, activities y routing slips (ver [media-processing.md](media-processing.md)). - `MassTransit/` → consumers, activities, and routing slips (see [media-processing.md](media-processing.md)).
- `Jobs/` → `UploadIntentExpirationJob`, `TemporalUploadsCleanUpJob`. - `Jobs/` → `UploadIntentExpirationJob`, `TemporalUploadsCleanUpJob`.
## Musify.StreamingGateway (`:8081`) ## Musify.StreamingGateway (`:8081`)
Reverse proxy (ASP.NET + **YARP**) que sirve el audio (`.m4a`) desde SeaweedFS. Valida el stream-ticket (RS256, clave pública) y, si el objeto pedido cae bajo el prefijo autorizado, reenvía al filer. A reverse proxy (ASP.NET + **YARP**) that serves audio (`.m4a`) from SeaweedFS. Validates the stream ticket (RS256, public key) and, if the requested object falls under the authorized prefix, forwards it to the filer.
**Porqué un proyecto aparte**: escala con el tráfico de bytes independientemente de la API, tiene superficie mínima (solo sabe validar un JWT y proxiar) y permite que SeaweedFS quede en red privada. Ver [streaming.md](streaming.md). **Why a separate project**: it scales with byte traffic independently of the API, has a minimal surface (it only knows how to validate a JWT and proxy), and lets SeaweedFS stay on a private network. See [streaming.md](streaming.md).
## Musify.Worker ## Musify.Worker
Worker Service (host de fondo) que consume los eventos de MassTransit y ejecuta el procesado pesado: transcode de audio a `.m4a` (ffmpeg), generación de miniaturas (ImageSharp), transferencias de ficheros en el bucket, y los jobs de expiración/limpieza de upload intents. A background Worker Service that consumes MassTransit events and runs the heavy processing: audio transcoding to `.m4a` (ffmpeg), thumbnail generation (ImageSharp), file transfers within the bucket, and the upload-intent expiration/cleanup jobs.
**Porqué aparte**: el trabajo CPU/IO no debe ocurrir dentro de un request HTTP; se escala y reinicia por separado. (Antes se llamaba `Musify.PictureWorker`; se renombró porque hace más que imágenes.) **Why separate**: CPU/IO-heavy work shouldn't happen inside an HTTP request; it scales and restarts independently. (Previously named `Musify.PictureWorker`; renamed once it started doing more than just images.)
## web-player (`:5173` en dev, `:3000` en contenedor) ## web-player (`:5173` in dev, `:3000` in a container)
Cliente web (SvelteKit 2 + Svelte 5 + Tailwind 4). La sesión OIDC vive en el The web client (SvelteKit 2 + Svelte 5 + Tailwind 4). The OIDC session lives
servidor (cookie cifrada); las llamadas al backend salen del servidor con el on the server (an encrypted cookie); backend calls go out from the server
access token, y las subidas se hacen contra las URLs prefirmadas de S3. with the access token, and uploads go straight to the presigned S3 URLs.
## Tests ## Tests
`backend/Tests/`, carpeta `/Tests/` en el `.slnx`: cinco proyectos xUnit — `backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects:
`Musify.Domain.Tests`, `Musify.Application.Tests` (SQLite en memoria, sin `Musify.Domain.Tests`, `Musify.Application.Tests` (in-memory SQLite, no
Docker), `Musify.Infrastructure.Tests` y `Musify.Api.Tests` (Testcontainers: Docker), `Musify.Infrastructure.Tests` and `Musify.Api.Tests` (Testcontainers:
Postgres + SeaweedFS reales) y `Musify.StreamingGateway.Tests`. Detalle en real Postgres + SeaweedFS), and `Musify.StreamingGateway.Tests`. Details in
[development.md](development.md#tests). [development.md](development.md#tests).
## Otras carpetas ## Other folders
- **`deploy/`** — stacks de Docker Compose (dev y prod), autocontenidos. Ver - **`deploy/`**: Musify's own Docker Compose stacks (dev and prod). The
shared infrastructure (Postgres, Zitadel, SeaweedFS, RabbitMQ, Jaeger)
lives in the separate `Infrastructure` repo instead. See
[../deploy/README.md](../deploy/README.md). [../deploy/README.md](../deploy/README.md).
- **`docs/`** — esta documentación. - **`docs/`**: this documentation.
+30 -30
View File
@@ -1,48 +1,48 @@
# Almacenamiento (SeaweedFS / S3) # Storage (SeaweedFS / S3)
## Qué es ## What it is
**SeaweedFS** es el almacén de objetos, compatible con la API S3. Guarda los originales subidos y los derivados (audio `.m4a`, miniaturas). Se accede vía `AWSSDK.S3` desde `StorageService` (`IStorageService`). **SeaweedFS** is the object store, S3-API compatible. It holds the uploaded originals and their derivatives (`.m4a` audio, thumbnails). Accessed via `AWSSDK.S3` from `StorageService` (`IStorageService`).
Expone: It exposes:
- **S3 API** (`:8333`) — operaciones S3 (presigned URLs, put/get/list…). - **S3 API** (`:8333`): S3 operations (presigned URLs, put/get/list…).
- **filer HTTP** (`:8888`) — sirve los ficheros por ruta; lo usa el **StreamingGateway**. - **filer HTTP** (`:8888`): serves files by path, used by the **StreamingGateway**.
- master (`:9333`) y volume server — internos. - master (`:9333`) and volume server, internal only.
Bucket de la app: **`webapi-storage`** (`ApplicationStorage:Bucket`). Importante: la API y el Worker usan **el mismo bucket** (el worker procesa lo que sube la API). App bucket: **`webapi-storage`** (`ApplicationStorage:Bucket`). Important: the API and the Worker use **the same bucket** (the worker processes whatever the API uploads).
## Estructura de claves (layout) ## Key layout
- `temp/{userId}/Tracks/{guid}.{ext}` — subidas temporales (objetivo de las presigned PUT, antes de consumir el intent). - `temp/{userId}/Tracks/{guid}.{ext}`: temporary uploads, the target of presigned PUTs, before the intent is consumed.
- `uploads/{userId}/Tracks/OriginalPictures|OriginalAudios/...` — originales ya consumidos. - `uploads/{userId}/Tracks/OriginalPictures|OriginalAudios/...`: originals that have already been consumed.
- `Tracks/SmallPictures|MediumPictures|LargePictures/...` — miniaturas generadas. - `Tracks/SmallPictures|MediumPictures|LargePictures/...`: generated thumbnails.
- `Tracks/ProcessedAudios/{AudioFolderName}/` — audio transcodificado (`audio.m4a`, AAC con faststart). - `Tracks/ProcessedAudios/{AudioFolderName}/`: transcoded audio (`audio.m4a`, AAC with faststart).
Las claves se construyen **siempre** con el helper `StorageKey.Combine(...)` (en `Application/Shared`), que une segmentos con `/` limpiando espacios y barras. Está centralizado para estandarizar y no repetir lógica. Keys are always built with the `StorageKey.Combine(...)` helper (in `Application/Shared`), which joins segments with `/` and trims stray spaces and slashes. It's centralized to keep the logic consistent and avoid repeating it.
> En el filer, los objetos S3 viven bajo `/buckets/{bucket}/{key}` — de ahí que el gateway reescriba `/media/{key}` → `/buckets/webapi-storage/{key}`. > In the filer, S3 objects live under `/buckets/{bucket}/{key}`, which is why the gateway rewrites `/media/{key}` → `/buckets/webapi-storage/{key}`.
## URLs prefirmadas (presigned) ## Presigned URLs
Para **no** hacer pasar ficheros grandes por la API, el cliente habla directo con S3 mediante URLs firmadas de corta vida: To **avoid** routing large files through the API, the client talks to S3 directly using short-lived signed URLs:
- **Subidas (PUT)**: `RequestTrackUploadUrls` / `RequestPlayListPictureUpload` generan presigned PUT (con `Content-Type` y `If-None-Match: *` firmados, expiración ~120s). El cliente sube directo a S3. - **Uploads (PUT)**: `RequestTrackUploadUrls` / `RequestPlayListPictureUpload` generate presigned PUTs (with signed `Content-Type` and `If-None-Match: *`, ~120s expiry). The client uploads straight to S3.
- **Imágenes (GET)**: las miniaturas se sirven con presigned GET (un objeto = una URL). - **Images (GET)**: thumbnails are served via presigned GETs (one object = one URL).
**Por qué presigned y no proxiar**: subir un audio de ~100 MB a través de la API consumiría memoria/ancho de banda de los servidores de aplicación y escalaría mal. La presigned PUT directa a S3 es el patrón estándar y correcto. **Why presigned instead of proxying**: routing a ~100 MB audio file through the API would burn memory and bandwidth on the application servers and wouldn't scale well. A direct presigned PUT to S3 is the standard, correct pattern here.
### Upload intents y cuota ### Upload intents and quota
Cada `upload-urls` crea **UploadIntents** (reservas en estado `Issued`) y devuelve las URLs. Al crear la pista/playlist se **consumen**. Hay cuota por usuario (`MaxActiveUploadIntentsPerUser`, bytes…) para evitar abuso. El `UploadIntentExpirationJob` (en el Worker) marca como expirados los intents caducados y libera la cuota; si ese job no corre, los intents colgados se acumulan y bloquean nuevas subidas. Every `upload-urls` call creates **UploadIntents** (reservations in the `Issued` state) and returns the URLs. They're **consumed** when the track/playlist is created. There's a per-user quota (`MaxActiveUploadIntentsPerUser`, byte limits…) to prevent abuse. The `UploadIntentExpirationJob` (in the Worker) marks expired intents and frees up quota. If that job isn't running, stale intents pile up and block new uploads.
## Qué es público y qué privado (despliegue) ## What's public and what's private (deployment)
| Exponer a internet | Mantener privado | | Expose to the internet | Keep private |
|---|---| |---|---|
| S3 API `:8333` (tras TLS) — necesario para presigned PUT/GET del cliente | filer `:8888` (solo el gateway, en red interna) | | S3 API `:8333` (behind TLS), needed for the client's presigned PUT/GET | filer `:8888` (internal network, gateway only) |
| StreamingGateway `:8081` | master `:9333`, volume server, UI de admin | | StreamingGateway `:8081` | master `:9333`, volume server, admin UI |
- **El audio** se sirve por el gateway → SeaweedFS puede quedar privado para ese flujo. - **Audio** is served through the gateway, so SeaweedFS can stay private for that flow.
- **Subidas e imágenes** usan presigned → el cliente toca el endpoint S3 directamente, así que `:8333` debe ser alcanzable (pero inútil sin firma válida; es el modelo de cualquier bucket S3/MinIO). - **Uploads and images** use presigned URLs, so the client talks to the S3 endpoint directly and `:8333` has to be reachable (though useless without a valid signature, this is the standard model for any S3/MinIO bucket).
- En **producción**: `InfrastructureStorage:Address` debe ser el **endpoint S3 público** (la firma se calcula para ese host); si los servidores no lo alcanzan internamente, se usa config dividida (host público para firmar / interno para operaciones server-side). - In **production**: `InfrastructureStorage:Address` must be the **public S3 endpoint** (the signature is computed for that host). If the servers can't reach it internally, use split config: public host for signing, internal host for server-side operations.
**Regla**: el gateway y, como mucho, el `:8333` firmado son la única superficie pública; master/volume/filer/admin **nunca** se exponen. **Rule of thumb**: the gateway, and at most the signed `:8333`, are the only public surface. Master, volume, filer and admin are **never** exposed.
+31 -31
View File
@@ -1,55 +1,55 @@
# Streaming de audio # Audio streaming
## Objetivo ## Goal
Reproducir el audio **sin que los bytes pasen por la API**, pero con control de acceso. La API solo emite un permiso corto; el reverse proxy sirve los bytes desde SeaweedFS. Play audio **without the bytes passing through the API**, while still controlling access. The API only issues a short-lived permission; the reverse proxy serves the bytes from SeaweedFS.
## Formato: fichero único AAC (.m4a) por HTTP range ## Format: a single AAC file (.m4a), served via HTTP range
El audio se transcodifica a **un único `audio.m4a`** (AAC, contenedor MP4 con `+faststart` para que el átomo `moov` quede al inicio y el seek por range funcione), dentro de la carpeta `Tracks/ProcessedAudios/{AudioFolderName}/`. No hay manifest ni segmentos: el reproductor descarga el fichero por **HTTP range requests** (modelo tipo Spotify para VOD musical). Audio is transcoded into **one `audio.m4a`** (AAC, MP4 container with `+faststart` so the `moov` atom sits at the start and range-based seeking works), stored under `Tracks/ProcessedAudios/{AudioFolderName}/`. There's no manifest or segments: the player downloads the file via **HTTP range requests** (the same model Spotify uses for on-demand music).
Multi-bitrate no está implementado hoy; el camino para añadirlo es transcodificar varias calidades (`audio_96.m4a`, …) y que el cliente elija una al iniciar (sin ABR fluido a mitad de pista, igual que Spotify). Multi-bitrate isn't implemented yet; the path to add it is transcoding several qualities (`audio_96.m4a`, …) and letting the client pick one at playback start (no seamless mid-track ABR, same as Spotify).
## El acceso: stream-ticket de prefijo ## Access control: a prefix-scoped stream ticket
Se autoriza **el prefijo de la carpeta** de la pista con un *stream-ticket* de corta vida, y el proxy lo valida en cada petición. Aunque hoy sea un único objeto, el ticket sigue acotado al prefijo `Tracks/ProcessedAudios/<folder>/` (cubre el fichero actual y cualquier calidad futura). Access is authorized for **the track's folder prefix** via a short-lived *stream ticket*, validated by the proxy on every request. Even though there's only one object today, the ticket is still scoped to the prefix `Tracks/ProcessedAudios/<folder>/` (covering the current file and any future quality variants).
## Flujo ## Flow
1. `GET /tracks/{id}/stream` (autenticado) → la API autoriza y devuelve: 1. `GET /tracks/{id}/stream` (authenticated) → the API authorizes the request and returns:
```json ```json
{ "manifestUrl": "http://<gateway>/media/Tracks/ProcessedAudios/<folder>/audio.m4a", { "manifestUrl": "http://<gateway>/media/Tracks/ProcessedAudios/<folder>/audio.m4a",
"ticket": "<JWT RS256>", "expiresInSeconds": 3600 } "ticket": "<JWT RS256>", "expiresInSeconds": 3600 }
``` ```
El ticket lleva el claim `prefix = "Tracks/ProcessedAudios/<folder>/"`. The ticket carries the claim `prefix = "Tracks/ProcessedAudios/<folder>/"`.
2. El reproductor abre la URL del audio añadiéndole `?t=<ticket>` (o la cabecera `X-Stream-Ticket`). 2. The player opens the audio URL with `?t=<ticket>` appended (or the `X-Stream-Ticket` header).
3. El **StreamingGateway** (YARP) valida el ticket y reenvía al filer; los bytes van SeaweedFS → cliente (con range/seek). 3. The **StreamingGateway** (YARP) validates the ticket and forwards the request to the filer; the bytes flow SeaweedFS → client (with range/seek support).
## El ticket (RS256) ## The ticket (RS256)
- Lo firma la API con clave **privada** (`StreamTicketService`); el gateway valida con la **pública** (`TicketValidator`). Así el gateway **no puede emitir** tickets, solo verificarlos. - Signed by the API with a **private** key (`StreamTicketService`); validated by the gateway with the **public** one (`TicketValidator`). That way the gateway **can't issue** tickets, only verify them.
- Claims: `sub`, `prefix`, `aud = media-gateway`, `iss = musify-webapi`, `exp` (TTL ~1h). - Claims: `sub`, `prefix`, `aud = media-gateway`, `iss = musify-webapi`, `exp` (~1h TTL).
**Por qué RS256 (asimétrica)**: separa responsabilidades — emisor (la API) y verificador (gateway) no comparten secreto; el gateway, aunque se comprometa, no puede crear permisos. **Why RS256 (asymmetric)**: it separates responsibilities. The issuer (API) and the verifier (gateway) don't share a secret, so even if the gateway is compromised, it can't mint permissions.
## El gateway (validación + proxy) ## The gateway (validation + proxy)
`TicketValidationMiddleware` en cada petición a `/media/**`: `TicketValidationMiddleware`, on every request to `/media/**`:
1. Lee `?t=`, valida firma/aud/iss/exp. 1. Reads `?t=`, validates its signature/aud/iss/exp.
2. Comprueba que la clave pedida **empieza por** el `prefix` del ticket (`StartsWith`). Si no → 403. 2. Checks that the requested key **starts with** the ticket's `prefix`. If not → 403.
3. Quita el `?t=` y deja pasar a YARP, que reescribe `/media/{key}` → `/buckets/webapi-storage/{key}` del filer. 3. Strips the `?t=` and passes the request on to YARP, which rewrites `/media/{key}` → `/buckets/webapi-storage/{key}` for the filer.
- Sin ticket → **401**; fuera de prefijo → **403**; ok → **200** (y **206** en range requests, así que el seek funciona). - No ticket → **401**; outside the prefix → **403**; ok → **200** (and **206** for range requests, so seeking works).
- Pasa `Range`/`Accept-Ranges` y expone esos headers por CORS. - Passes through `Range`/`Accept-Ranges` and exposes those headers via CORS.
## ¿Un ticket por canción? ## One ticket per track?
Sí. Cada ticket está acotado a **una** carpeta (una pista). Para reproducir otra, se pide otro ticket. Es barato (la API solo firma un JWT) y es lo más seguro: cada permiso abre solo lo que vas a reproducir. Yes. Each ticket is scoped to **one** folder (one track). Playing a different one means requesting a new ticket. It's cheap (the API just signs a JWT) and it's the safest option: every permission only unlocks what you're about to play.
- Si algún día molesta (muchísimos cambios de pista), el mismo mecanismo del claim `prefix` permite ampliar el alcance: a nivel de playlist o de toda la biblioteca (`Tracks/ProcessedAudios/`). Trade-off seguridad ↔ comodidad. Por defecto, **por canción**. - If that ever becomes a problem (very frequent track switching), the same `prefix` claim mechanism can widen the scope to a whole playlist or the whole library (`Tracks/ProcessedAudios/`). It's a security/convenience trade-off. The default is **per track**.
## Pendiente / notas de seguridad ## Open items / security notes
- La política de quién puede pedir el ticket es hoy "cualquier autenticado" (TODO: restringir con `UserHasTrack`/visibilidad). - Today, the policy for who can request a ticket is "any authenticated user" (TODO: restrict it with `UserHasTrack`/visibility).
- El check de prefijo es `StartsWith` literal; conviene endurecer rechazando claves con `..` (path traversal), aunque navegadores y ASP.NET normalizan `..`. - The prefix check is a literal `StartsWith`; it would be worth hardening it against `..` path traversal, even though browsers and ASP.NET normalize `..`.
- El ticket viaja en la query (`?t=`); el gateway lo elimina antes de reenviar (no llega a los logs del filer). - The ticket travels in the query string (`?t=`); the gateway strips it before forwarding, so it never reaches the filer's logs.
+2
View File
@@ -9,3 +9,5 @@ package
package-lock.json package-lock.json
src/lib/api/schema.d.ts src/lib/api/schema.d.ts
static static
.agents
skills-lock.json
+1
View File
@@ -3,6 +3,7 @@
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 100,
"endOfLine": "auto",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/routes/layout.css", "tailwindStylesheet": "./src/routes/layout.css",
"overrides": [ "overrides": [
+59 -32
View File
@@ -1,56 +1,83 @@
# Musify — web-player # Musify: web-player
Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**. Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill.
## Stack ## Stack
SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`). SvelteKit 2 (`adapter-node`), Svelte 5 **runes**, Tailwind 4, TypeScript strict, Zitadel OIDC auth (`openid-client` + `jose`).
No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`). There's no `svelte.config.js` (config is inline in `vite.config.ts`) and no `tailwind.config.js` (config lives in `src/routes/layout.css`).
## Comandos ## Commands
```sh ```sh
npm run dev # servidor de desarrollo (puerto 5173) npm run dev # dev server (port 5173)
npm run build npm run build
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio npm run check # svelte-check, must exit 0 errors before closing a change
npm run format # prettier --write . npm run format # prettier --write .
npm run lint # prettier --check . && eslint . && npm run lint:tokens npm run lint # prettier --check . && eslint . && npm run lint:tokens
npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo) npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
``` ```
## Reglas ## Rules
- **Sin comentarios** en el código salvo que sean imprescindibles. - **No comments** in code unless essential.
- `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio. - `npm run lint` and `npm run check` must exit clean before closing a change.
- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave. - **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
- **Server-only bajo `$lib/server/`**; nunca importado desde cliente. - **Server only code goes under `$lib/server/`**, and it's never imported from client code.
- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`. - SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`. - Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-sveltekit-reload`.
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita. - Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to `$lib/components/ui/`.
## Estilos ## Folder structure
El sistema de diseño vive en `src/routes/layout.css` (tokens `@theme` + recetas `@utility`) y `src/lib/theme/` (`theme.css` para neutros estáticos, `tokens.ts` para los tokens dinámicos por hue de usuario). `scripts/check-tokens.mjs` (`npm run lint:tokens`, parte de `lint`) falla si `src/**/*.svelte` reintroduce lo que el refactor de `docs/frontend-refactor-plan.md` (P0–P14) eliminó — léelo antes de añadir un valor arbitrario o un radio/tamaño "de fábrica". - `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`.
- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search.
- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`.
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader.
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
- `src/lib/components/ui/` holds the design system components, split into five categories:
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on).
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
- **Radios:** siempre `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (o `rounded-full` para píldoras/círculos). Nunca `rounded-sm/md/lg/xl/2xl/3xl` ni `rounded` a secas. ## Styles
- **Tamaños cuadrados:** `size-*` en vez de `h-N w-N` repetido (icon: `size-icon-xs/sm/md/lg/xl`; portadas: `size-cover-xs…hero`).
- **Tipografía:** recetas `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`; tracking con `tracking-display`/`tracking-eyebrow`, no `tracking-[…]` suelto. The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
- **Espaciado:** escala de Tailwind en pasos de `.5` (evita cuartos como `.25`/`.75`; solo aceptables si el token semántico lo pide, p. ej. `mb-4.5` en `SectionHeading`).
- **Colores:** siempre `--mf-*`/`text-*`/`bg-*`/`border-*` de `theme.css`/`tokens.ts`. Nunca hex ni `rgba()` literales en un `.svelte` (esos literales solo existen como fuente de verdad dentro de `theme.css`/`tokens.ts`). - **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
- **`style="…"` y arbitrarios `-[…]`:** solo la lista blanca de B.4 en `docs/frontend-refactor-plan.md` (stagger `--i`, hue de tile, posición de menú, anchos/alturas dinámicos vía `var(--mf-*)`, `text-[clamp(…)]`, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, paddings en `vh` para centrado vertical). Todo lo demás, a un token. - **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`).
- Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. - **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
- Gaps conocidos y aceptados (documentados en `scripts/check-tokens.mjs`): `strokeWidth={n}` en iconos no está unificado (13 valores distintos repartidos por casi todos los componentes; migrarlo a las recetas `stroke-thin/regular/bold` de forma segura requeriría poder verificar visualmente que `lucide-svelte` respeta `stroke-width` por CSS, así que se dejó fuera de esta pasada). - **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
## App shell
The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
## Motion
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
## Auth ## Auth
`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`. `hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`.
Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`. Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
## Backend API ## Backend API
Cliente tipado con `openapi-fetch` sobre el OpenAPI de `Musify.Api`. En un `load`/action server: Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action:
```ts ```ts
import { createApiClient } from '$lib/server/api'; import { createApiClient } from '$lib/server/api';
@@ -58,4 +85,4 @@ const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefine
const { data, error: err } = await api.GET('/tracks'); const { data, error: err } = await api.GET('/tracks');
``` ```
Devuelve `{ data, error }` (estilo ErrorOr), no lanza. Los tipos viven en `src/lib/api/schema.d.ts` y se regeneran con `npm run gen:api` (requiere la API dev levantada en `API_BASE_URL`, que expone `/openapi/v1.json` solo en Development). It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
@@ -1,183 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:#050507}
a{color:#b4a4ea;text-decoration:none}
a:hover{color:#cdc0f5}
img{display:block}
input,textarea{font-family:inherit}
input::placeholder,textarea::placeholder{color:#5d5d63}
@keyframes modalIn{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
</style>
</helmet>
<div style="position:relative;min-height:100vh;display:grid;place-items:center;padding:40px 20px;background:radial-gradient(120% 90% at 50% 0%,#15131c,#08080b 58%,#050507);font-family:'Plus Jakarta Sans',system-ui,sans-serif;color:#c9c8cc;-webkit-font-smoothing:antialiased">
<div style="position:absolute;inset:0;background:rgba(4,4,6,.55);backdrop-filter:blur(3px)"></div>
<div data-screen-label="Crear playlist" style="position:relative;width:100%;max-width:560px;border-radius:24px;overflow:hidden;border:1px solid rgba(255,255,255,.075);background:linear-gradient(180deg,#141419,#0d0d11 62%,#0b0b0e);box-shadow:0 32px 90px rgba(0,0,0,.66),0 2px 0 rgba(255,255,255,.035) inset;animation:modalIn .28s cubic-bezier(.2,.8,.3,1)">
<div style="position:absolute;inset:0;pointer-events:none;background:{{ auraTop }};transition:background .5s ease"></div>
<!-- HEADER -->
<div style="position:relative;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px 26px 0">
<div>
<div style="font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:#8f8f93;font-weight:600;margin-bottom:9px">Tu biblioteca</div>
<div style="font-family:'Sora',sans-serif;font-size:21px;font-weight:600;letter-spacing:-.03em;color:#d8d7db">Crear playlist</div>
</div>
<div onClick="{{ close }}" title="Cerrar" style="flex:0 0 auto;width:32px;height:32px;border-radius:10px;display:grid;place-items:center;cursor:pointer;color:#87878c;border:1px solid rgba(255,255,255,.06);background:rgba(255,255,255,.025);transition:background .18s ease,color .18s ease" style-hover="background:rgba(255,255,255,.07);color:#d0cfd4">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"></path></svg>
</div>
</div>
<!-- BODY -->
<div style="position:relative;padding:22px 26px 0;display:flex;gap:20px;align-items:flex-start">
<!-- COVER -->
<div style="flex:0 0 auto;width:124px">
<div style="position:relative;width:124px;height:124px;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,.07);background:{{ coverGrad }};box-shadow:{{ coverGlow }};transition:background .45s ease,box-shadow .45s ease;display:grid;place-items:center">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="rgba(255,255,255,.62)" stroke-width="1.5" stroke-linecap="round"><path d="M9 18V6l10-2v12"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="16.5" cy="16" r="2.5"></circle></svg>
</div>
<div style="display:flex;gap:6px;margin-top:11px">
<sc-for list="{{ swatches }}" as="s" hint-placeholder-count="5">
<div onClick="{{ s.pick }}" title="{{ s.name }}" style="flex:1;height:20px;border-radius:7px;cursor:pointer;background:{{ s.dot }};box-shadow:{{ s.ring }};transition:box-shadow .18s ease"></div>
</sc-for>
</div>
<div onClick="{{ upload }}" style="margin-top:9px;text-align:center;font-size:11px;font-weight:500;color:#8a8a8f;padding:7px 0;border-radius:9px;border:1px dashed rgba(255,255,255,.1);cursor:pointer;transition:color .18s ease,border-color .18s ease" style-hover="color:#cfccd9;border-color:rgba(255,255,255,.2)">Subir imagen</div>
</div>
<!-- FIELDS -->
<div style="flex:1;min-width:0">
<label style="display:block;font-size:11px;font-weight:600;letter-spacing:.03em;color:#87878c;margin-bottom:8px">Nombre</label>
<input value="{{ name }}" onChange="{{ onName }}" maxLength="60" placeholder="Playlist sin título" style="width:100%;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.075);outline:none;color:#dcdbe0;font-family:'Sora',sans-serif;font-size:16px;font-weight:600;letter-spacing:-.02em" style-focus="border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.055)">
<div style="display:flex;justify-content:flex-end;font-size:10.5px;color:#65656a;margin-top:6px">{{ counter }}</div>
<label style="display:block;font-size:11px;font-weight:600;letter-spacing:.03em;color:#87878c;margin:10px 0 8px">Descripción <span style="font-weight:400;color:#5f5f65">· opcional</span></label>
<textarea value="{{ desc }}" onChange="{{ onDesc }}" rows="3" placeholder="Para qué sirve esta playlist, cuándo la escuchas…" style="width:100%;resize:none;padding:11px 14px;border-radius:12px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.075);outline:none;color:#c4c3c9;font-size:12.5px;line-height:1.6" style-focus="border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.055)"></textarea>
</div>
</div>
<!-- OPTIONS -->
<div style="position:relative;padding:20px 26px 0">
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 15px;border-radius:13px;background:rgba(255,255,255,.026);border:1px solid rgba(255,255,255,.055)">
<div style="min-width:0">
<div style="font-size:12.5px;font-weight:600;color:#cfccd9">Visibilidad</div>
<div style="font-size:11px;color:#7b7b81;margin-top:3px">{{ visHint }}</div>
</div>
<div style="flex:0 0 auto;display:flex;gap:3px;padding:3px;border-radius:10px;background:rgba(0,0,0,.34);border:1px solid rgba(255,255,255,.05)">
<sc-for list="{{ visOptions }}" as="v" hint-placeholder-count="2">
<div onClick="{{ v.pick }}" style="padding:6px 14px;border-radius:8px;font-size:11.5px;font-weight:600;cursor:pointer;background:{{ v.bg }};color:{{ v.fg }};transition:background .18s ease,color .18s ease">{{ v.label }}</div>
</sc-for>
</div>
</div>
<div onClick="{{ toggleCollab }}" style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:9px;padding:13px 15px;border-radius:13px;cursor:pointer;background:rgba(255,255,255,.026);border:1px solid rgba(255,255,255,.055);transition:background .18s ease" style-hover="background:rgba(255,255,255,.045)">
<div style="min-width:0">
<div style="font-size:12.5px;font-weight:600;color:#cfccd9">Colaborativa</div>
<div style="font-size:11px;color:#7b7b81;margin-top:3px">Quien tenga el enlace puede añadir canciones</div>
</div>
<div style="flex:0 0 auto;width:40px;height:23px;border-radius:20px;padding:3px;background:{{ collabTrack }};transition:background .2s ease">
<div style="width:17px;height:17px;border-radius:50%;background:#0c0c10;transform:{{ collabKnob }};transition:transform .2s cubic-bezier(.3,.8,.3,1)"></div>
</div>
</div>
</div>
<!-- FOOTER -->
<div style="position:relative;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-top:22px;padding:17px 26px 20px;border-top:1px solid rgba(255,255,255,.055);background:rgba(0,0,0,.2)">
<div style="font-size:11px;color:#6e6e74;max-width:230px;line-height:1.5">Podrás añadir canciones desde cualquier pantalla.</div>
<div style="display:flex;gap:9px;margin-left:auto">
<div onClick="{{ close }}" style="padding:11px 19px;border-radius:11px;border:1px solid rgba(255,255,255,.1);font-size:12.5px;font-weight:500;cursor:pointer;color:#a9a7b2;transition:background .18s ease,color .18s ease" style-hover="background:rgba(255,255,255,.05);color:#d3d2d8">Cancelar</div>
<div onClick="{{ create }}" style="display:flex;align-items:center;gap:8px;padding:11px 21px;border-radius:11px;font-size:12.5px;font-weight:600;background:{{ ctaBg }};color:{{ ctaFg }};cursor:{{ ctaCursor }};opacity:{{ ctaOpacity }};transition:background .18s ease,opacity .18s ease">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg>
Crear playlist
</div>
</div>
</div>
</div>
<sc-if value="{{ created }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;left:50%;bottom:34px;transform:translateX(-50%);display:flex;align-items:center;gap:11px;padding:12px 18px;border-radius:13px;background:#16161c;border:1px solid rgba(255,255,255,.09);box-shadow:0 18px 46px rgba(0,0,0,.6);animation:fadeIn .22s ease">
<div style="width:26px;height:26px;border-radius:8px;background:{{ coverGrad }};flex:0 0 auto"></div>
<div style="font-size:12.5px;color:#cfccd9;font-weight:500">Playlist «{{ createdName }}» creada</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:900,&quot;height&quot;:760}}">
const HUES = [
{ name: 'Ámbar', h: 68 },
{ name: 'Violeta', h: 288 },
{ name: 'Azul', h: 244 },
{ name: 'Verde', h: 158 },
{ name: 'Rojo', h: 22 },
];
class Component extends DCLogic {
state = { name: '', desc: '', hue: 1, vis: 'Privada', collab: false, created: false, createdName: '' };
renderVals() {
const h = HUES[this.state.hue].h;
const name = this.state.name;
const ready = name.trim().length > 0;
return {
name, desc: this.state.desc,
counter: name.length + '/60',
onName: e => this.setState({ name: e.target.value }),
onDesc: e => this.setState({ desc: e.target.value }),
coverGrad: `linear-gradient(150deg,oklch(.66 .16 ${h}),oklch(.34 .11 ${h}) 58%,#0e0e13)`,
coverGlow: `0 14px 34px oklch(.5 .13 ${h} / .22)`,
auraTop: `radial-gradient(110% 60% at 18% -10%,oklch(.55 .13 ${h} / .16),transparent 62%)`,
swatches: HUES.map((s, i) => ({
name: s.name,
dot: `linear-gradient(145deg,oklch(.66 .16 ${s.h}),oklch(.38 .11 ${s.h}))`,
ring: i === this.state.hue
? '0 0 0 1.5px rgba(255,255,255,.85), 0 0 0 4px rgba(0,0,0,.5)'
: '0 0 0 1px rgba(255,255,255,.07)',
pick: () => this.setState({ hue: i }),
})),
visOptions: ['Privada', 'Pública'].map(label => ({
label,
bg: this.state.vis === label ? '#cfccd9' : 'transparent',
fg: this.state.vis === label ? '#0a0a0d' : '#8e8e94',
pick: () => this.setState({ vis: label }),
})),
visHint: this.state.vis === 'Privada'
? 'Solo tú la ves en tu biblioteca'
: 'Aparece en tu perfil y se puede compartir',
collabTrack: this.state.collab ? `oklch(.68 .15 ${h})` : 'rgba(255,255,255,.12)',
collabKnob: this.state.collab ? 'translateX(17px)' : 'translateX(0)',
toggleCollab: () => this.setState(s => ({ collab: !s.collab })),
ctaBg: ready ? '#c6c4cb' : 'rgba(255,255,255,.08)',
ctaFg: ready ? '#0a0a0d' : '#6c6c72',
ctaCursor: ready ? 'pointer' : 'not-allowed',
ctaOpacity: ready ? 1 : 0.85,
create: () => {
if (!ready) return;
this.setState({ created: true, createdName: name.trim() });
clearTimeout(this._t);
this._t = setTimeout(() => this.setState({ created: false }), 2600);
},
close: () => this.setState({ name: '', desc: '', collab: false, created: false }),
upload: () => {},
created: this.state.created,
createdName: this.state.createdName,
};
}
componentWillUnmount() { clearTimeout(this._t); }
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,399 +0,0 @@
# Handoff: Musify — Web Music Player
## Overview
Musify es un reproductor de música web: un player permanente, navegación lateral, y vistas de Home / Descubrir / Playlists / Me gusta / Reciente, más estados de carga, error, vacío y sin resultados. Su rasgo de identidad es que **toda la paleta de acento y los fondos derivan de la canción en reproducción**: al cambiar de pista, el tinte del hero, el glow ambiental, el sidebar, la barra del player, el logo, la barra de progreso y los acentos viran en bloque con una transición de 0.6s.
## About the Design Files
Los archivos de este paquete son **referencias de diseño hechas en HTML** — prototipos que muestran aspecto y comportamiento previstos, **no código de producción para copiar tal cual**. La tarea es **recrear estos diseños en el entorno del codebase destino** (React, Vue, SwiftUI, nativo…) siguiendo sus patrones y librerías ya establecidos. Si todavía no hay entorno, elige el framework más apropiado para el proyecto e implementa allí.
El prototipo está escrito como un componente único con estado local y estilos inline. En producción conviene separarlo en componentes (Sidebar, TopBar, Player, Queue, TrackRow, MediaCard, sections de Home) y mover los tokens a la solución de estilos del proyecto.
## Fidelity
**Alta fidelidad (hifi).** Colores, tipografía, espaciado, radios, estados y microinteracciones son definitivos. Se espera una recreación fiel usando las librerías del codebase. Las carátulas son fotografías genéricas de marcador de posición; el contenido musical (artistas, álbumes, canciones, playlists, duraciones, reproducciones) es **mock data** y debe sustituirse por datos reales.
**Regla de producto:** la UI nunca menciona la procedencia de la música. Sin etiquetas de fuente, proveedor ni "powered by". Para el usuario esto es solo Musify.
---
## Layout global
Altura fija de viewport, sin scroll de página; solo el área de contenido y la cola scrollean.
```
┌──────────┬──────────────────────────────┬──────────┐
│ Sidebar │ TopBar │ Queue │
│ 254px ├──────────────────────────────┤ 304px │
│ │ Main (scroll) │ (258px │
│ │ │ <1180) │
└──────────┴──────────────────────────────┴──────────┘
Player flotante (absolute, left/right 0, bottom 0, padding 0 16px 16px)
```
- Raíz: `position:relative; height:100vh; overflow:hidden; background: pageBg; transition: background .6s ease`.
- Dos capas decorativas absolutas y no interactivas sobre el fondo: el **ambient glow** y un scrim inferior `radial-gradient(130% 85% at 50% 118%, rgba(0,0,0,.8), transparent 62%)`.
- Fila principal `display:flex; height:100%`.
- El main tiene `padding: 6px 26px` y `padding-bottom` dinámico: `126px` en desktop, `206px` cuando hay barra de pestañas móvil.
### Breakpoints
| Ancho | Comportamiento |
|---|---|
| `>= 1180px` | Sidebar visible (254px), cola 304px, player completo |
| `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px |
| `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` |
---
## Sistema de color dinámico
Cada pista lleva un `hue` (0–360) asignado. Todo el color de la app se deriva de ese hue en oklch. Los valores están repartidos por la rueda para que el cambio entre pistas consecutivas sea evidente (paso mínimo ~50°).
```js
// hue = hue de la pista en reproducción
accent = oklch(0.74 0.145 H) // acción, barra de progreso, corazón activo, EQ
accentTitle = oklch(0.87 0.07 H) // títulos sobre superficie tintada
accentMuted = oklch(0.79 0.035 H) // título de la pista activa en tarjetas
accentSoft = oklch(0.74 0.145 H / .08) // fondo de fila/tarjeta activa
accentLine = oklch(0.74 0.145 H / .2) // borde de superficie acentuada
accentHair = oklch(0.74 0.145 H / .09) // borde de tarjeta activa
accentBtnBg = oklch(0.74 0.145 H / .15)
accentBtnBgHover = oklch(0.74 0.145 H / .24)
// superficies
pageBg = oklch(0.068 0.008 H)
sidebarBg = linear-gradient(180deg, oklch(0.095 0.012 H), oklch(0.068 0.007 H))
panelBg = oklch(0.09 0.010 H / .28) // panel de cola
barBg = oklch(0.105 0.012 H / .9) // player y tabs (con backdrop-filter: blur(22px))
hairline = oklch(0.62 0.030 H / .08) // todos los separadores
// gradientes: tc(a, l) = oklch(0.34 - l/380 , 0.11 - l/1600 , H / a)
ambient = radial-gradient(52% 38% at 14% 0%, tc(.26,30), transparent 54%),
radial-gradient(46% 34% at 78% 0%, tc(.20,40), transparent 56%)
heroBg = linear-gradient(105deg, tc(.34,42) 0%, tc(.11,70) 28%, tc(.03,88) 50%, rgba(6,6,9,0) 70%)
spotlightBg = linear-gradient(100deg, tc(.28,46) 0%, tc(.10,74) 30%, tc(.03,88) 52%, rgba(6,6,9,0) 72%)
logoGrad = linear-gradient(140deg, oklch(0.78 0.15 H), oklch(0.62 0.13 H+40) 92%)
logoGlow = 0 0 20px oklch(0.74 0.145 H / .3)
```
**Importante:** hero y spotlight terminan en `alpha 0` antes del borde derecho (70–72%) y **no llevan borde**, para fundirse con el fondo sin corte visible.
Los elementos que cambian de color reciben `transition: background .6s ease` (y `box-shadow .6s ease` en el logo): raíz, sidebar, panel de cola, barra del player, tabs.
### Grises fijos (no dependen del hue)
| Rol | Hex |
|---|---|
| Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` |
| Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` |
| Texto base del documento | `#c9c8cc` |
| Secundario | `#8a8a8e`, `#8f8f93`, `#919195` |
| Terciario | `#7f7f83`, `#7d7d81`, `#848488` |
| Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` |
| Iconos inactivos player | `#747478` |
| Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` |
| Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` |
Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color.
---
## Tipografía
- Display / títulos: **Sora** 400/500/600/700.
- UI / texto: **Plus Jakarta Sans** 400/500/600/700.
- `-webkit-font-smoothing: antialiased`.
| Uso | Fuente | Tamaño | Peso | Tracking |
|---|---|---|---|---|
| H1 hero | Sora | 36px / 1.1 | 600 | -0.035em |
| H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em |
| H2 sección | Sora | 17.5px | 600 | -0.025em |
| Título spotlight | Sora | 23px | 600 | -0.03em |
| Wordmark | Sora | 17.5px | 600 | -0.02em |
| Subtítulo de sección | Jakarta | 12px | 400 | — |
| Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em |
| Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em |
| Fila de canción (título) | Jakarta | 12.5–13px | 500 | — |
| Meta / artista | Jakarta | 11–11.5px | 400 | — |
| Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` |
Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`.
---
## Escalas
**Radios:** 8px (miniaturas pequeñas, chips) · 9–11px (carátulas 36–48px, botones) · 12–13px (filas activas, carátulas de tarjeta) · 14–16px (tarjetas, portadas grandes) · 18px (player, tabs, portada de cabecera) · 22–24px (hero, spotlight) · 50% (avatares, botón de play, artistas) · 999px (chips de sugerencia).
**Sombras:** carátula de tarjeta `0 14px 32px rgba(0,0,0,.45)` · artista `0 12px 28px rgba(0,0,0,.4)` · portada de cabecera `0 24px 56px rgba(0,0,0,.55)` · player `0 20px 56px rgba(0,0,0,.6)` · botón primario hero `0 10px 28px rgba(0,0,0,.45)`.
**Gaps de grid:** tarjetas 20px · artistas 18px · playlists 14–22px · filas compactas 6–14px.
**Anchos mínimos de columna (auto-fill):** mixes/álbumes/descubrir 164px · artistas 132px · playlists (tarjeta ancha) 258px · playlists (índice) 196px · continuar escuchando 268px · populares 330px · géneros 228px.
---
## Pantallas
### 1. Sidebar (`>= 1180px`)
`width:254px; padding:22px 12px 0 20px; border-right:1px solid hairline; background: sidebarBg`.
- **Wordmark**: cuadrado 24px con `logoGrad` + `logoGlow`, gap 11px, texto "Musify". Padding inferior 26px.
- **Nav** (columna, gap 2px). Ítem: `padding:9px 12px; border-radius:10px; font-size:13.5px; weight:500`, icono SVG 17px stroke 1.6, etiqueta flexible, contador a la derecha (11px, `#5c5c60`, tabular).
- Activo: color `#d5d4d8`, fondo `rgba(255,255,255,.06)`.
- Inactivo: color `#8b8b8f`, fondo transparente. Hover: fondo `rgba(255,255,255,.04)`, color `#d5d4d8`. Transición `.16s`.
- Ítems: Inicio · Descubrir · Playlists (7) · Me gusta (312) · Reciente.
- **Separador**: `margin:24px 12px 14px; height:1px; background:rgba(255,255,255,.05)`.
- **Cabecera "TUS PLAYLISTS"** (10.5px uppercase, 0.13em, `#616165`) con un "+" a la derecha que navega al índice de playlists.
- **Lista de playlists** (scroll, gap 1px). Fila: miniatura 34px radio 8px (imagen `opacity:.88`), nombre 12.5px, meta 11px `#6c6c70`. Si esa playlist está sonando, un ecualizador de 3 barras de 2px a la derecha.
### 2. TopBar
`padding:16px 26px 12px; gap:14px`.
- Atrás / adelante: círculos de 30px, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.05)`, icono 15px. Atrás activo (`#8b8b8f`, hover `#dbdadf`), adelante deshabilitado (`#555559`).
- Buscador: `flex:1; max-width:480px; height:38px; radius:11px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.055)`, lupa 15px `#737377`, input 13px transparente. Placeholder: "Canciones, artistas, álbumes o playlists".
- Spacer.
- Botón "Cola": alto 33px, radio 10px, 12.5px/500, icono 15px. Activo: color `#d6d5da`, fondo `rgba(255,255,255,.06)`. Inactivo: `#8b8b8f`, fondo `rgba(255,255,255,.028)`.
- Avatar 33px circular con borde `rgba(255,255,255,.1)`.
### 3. Home
Ritmo editorial deliberado — no filas iguales encadenadas:
**a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`.
- Saludo H1 dinámico por hora y día (ver *Copy dinámico*).
- Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`.
- Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px.
- Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos".
**b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título.
**c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central.
- Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario.
- Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha.
**d) Mixes** — H2 "Mixes" + sub "Generados a partir de lo que más repites". Grid `minmax(164px,1fr)` gap 20px. Tarjeta cuadrada radio 13px con scrim `linear-gradient(180deg,transparent 45%,rgba(0,0,0,.42))` y botón de play circular 38px abajo a la derecha (`rgba(10,10,13,.76)`, blur 8px, borde `rgba(255,255,255,.12)`) que aparece en hover.
**e) Artistas que sigues** — grid `minmax(132px,1fr)` gap 18px, retratos circulares, texto centrado, nombre 12.5px/600, oyentes 11px.
**f) Populares esta semana** — grid de dos columnas `auto-fit minmax(330px,1fr)`, `gap:6px 34px`. Fila: índice/EQ 18px, carátula 38px, título + artista, reproducciones 11px y duración 11.5px.
**g) Tus playlists** — tarjetas anchas `minmax(258px,1fr)` gap 14px: portada 62px radio 11px, nombre 13.5px/600, nº de canciones 11.5px, artistas principales 11px `#696a6d`, play en hover. Enlace "VER TODAS" en la cabecera.
### 4. Descubrir
H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes.
### 5. Tracklist (Me gusta / Reciente / Playlist)
Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio".
Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`:
- Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`.
- Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px.
- Col 3: álbum (o `—` si falta).
- Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular.
- Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`.
### 5b. Resultados de búsqueda
**Vista propia, no una tracklist.** La búsqueda devuelve canciones, álbumes, artistas y playlists, así que no lleva portada-hero ni columnas de tabla: es una lista agrupada donde cada tipo se reconoce a simple vista.
**Cabecera** (`margin:12px 0 18px`): eyebrow `RESULTADOS DE BÚSQUEDA` (10.5px uppercase, tracking .16em, `#8a8a8e`, 600) · H1 Sora 26px/600 tracking -0.03em `#d9d8dc` con el texto `Resultados para «{query}»` · meta 12.5px `#8a8a8e`: `{n} coincidencias en canciones, álbumes, artistas y playlists` (singular "coincidencia" con 1).
**Chips de filtro** (`display:flex; gap:7px; flex-wrap:wrap; margin-bottom:26px`): `Todo`, `Canciones`, `Álbumes`, `Artistas`, `Playlists`. Cada chip `padding:7px 14px; radius:999px; font-size:12px/500` y lleva a su derecha el contador en 10.5px tabular-nums.
- Activo: fondo `#cfccd9`, texto `#0a0a0d`, contador `rgba(10,10,13,.55)`, sin borde.
- Inactivo: fondo `rgba(255,255,255,.03)`, texto `#9b9b9f`, contador `#65656a`, borde `1px solid rgba(255,255,255,.07)`.
- Transición `background .16s, color .16s`.
**Mejor resultado** (solo con el filtro `Todo`): fila-tarjeta `display:flex; gap:20px; padding:18px; radius:18px; border:1px solid rgba(255,255,255,.06)`, fondo `rgba(255,255,255,.025)` → `.05` en hover, `margin-bottom:34px`. Miniatura 88px con el radio del tipo y `box-shadow:0 14px 34px rgba(0,0,0,.5)`. Eyebrow `Mejor resultado · {Tipo}` 10.5px uppercase tracking .14em en el **tinte del tipo**; título Sora 22px/600 tracking -0.03em `#dcdbe0`; subtítulo 12.5px `#84848a`. A la derecha, botón circular 46px `#c6c4cb` / icono `#0a0a0d`, `opacity .55` → `1` en hover.
Selección: el primer elemento cuyo nombre **empieza** por la consulta, recorriendo Artista → Canción → Álbum → Playlist; si ninguno empieza por ella, el primer resultado del primer grupo no vacío.
**Grupos** (`margin-bottom:30px` cada uno). Cabecera de grupo: título Sora 14.5px/600 `#cfccd9` + regla `1px rgba(255,255,255,.06)` que ocupa el espacio libre + contador 11px `#6e6e74` (`{n} resultados`). Con filtro `Todo` cada grupo muestra **máximo 4** elementos; al filtrar por un tipo se muestran todos los de ese tipo.
**Fila de resultado:** `grid-template-columns: 52px 1fr auto; gap:15px; padding:9px 12px; radius:12px; border-bottom:1px solid rgba(255,255,255,.035)`; fondo transparente → `rgba(255,255,255,.035)` en hover (`.15s ease`), sin elevación.
- Miniatura 52px con degradado de respaldo del hue del elemento y overlay `rgba(6,6,9,.55)` + play 15px `#e2e1e6` que aparece en hover (`opacity 0 → 1`).
- Título 13.5px/500 `#cbcacf` → `#e0dfe4` en hover, seguido del **badge de tipo**: 9.5px uppercase, tracking .1em, 600, `padding:3px 7px; radius:5px`, color del tinte del tipo sobre el mismo tinte al 11%.
- Subtítulo 11.5px `#7f7f83` (una línea, ellipsis).
- Meta a la derecha 11.5px `#76767c` tabular-nums.
**Diferenciación por tipo** — la forma de la miniatura es la señal primaria; el badge la confirma. El tinte se deriva del hue de la canción en reproducción con un desplazamiento fijo, así que la vista sigue siendo cromáticamente coherente con el resto de la app:
| Tipo | Radio de miniatura | Tinte | Subtítulo | Meta |
|---|---|---|---|---|
| Canción | `9px` (cuadrado suave) | `oklch(0.80 0.10 H)` | `artista · álbum` | duración |
| Álbum | `5px` (casi recto) | `oklch(0.80 0.10 H+46)` | `artista · año` | `Álbum` |
| Artista | `50%` (círculo) | `oklch(0.80 0.10 H-52)` | oyentes | `Artista` |
| Playlist | `13px` (muy redondeado) | `oklch(0.80 0.10 H+92)` | artistas principales | `{n} canciones` |
Orden de los grupos: Canciones, Álbumes, Artistas, Playlists. Los grupos vacíos no se renderizan.
**Coincidencias:** canciones por título + artista + álbum; álbumes por título + `artista · año`; artistas por nombre; playlists por nombre + artistas principales. Todo en minúsculas, `includes`.
**Acciones:** canción → fija la pista y reproduce; álbum y artista → reproducen; playlist → abre la vista de playlist con su contexto y limpia la búsqueda.
### 6. Índice de playlists
H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales.
### 7. Cola (aside derecho)
`border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico.
- Cabecera "En cola" (Sora 14.5px) + cerrar "×".
- "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`.
- Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`.
- Lista scrollable: miniatura 36px, título 12px, artista 11px, duración.
### 8. Player (siempre visible)
Barra flotante: `radius:18px; padding:11px 16px; background: barBg; backdrop-filter: blur(22px); border:1px solid hairline; box-shadow:0 20px 56px rgba(0,0,0,.6)`; `pointer-events:none` en el contenedor y `auto` en la barra.
- **Izquierda (256px):** carátula 48px radio 11px, título 13px/600, artista · álbum 11.5px, corazón 16px (activo en `accent`).
- **Centro (flex:1):** shuffle · anterior · play/pausa · siguiente · repetir, gap 14px. Play: círculo 40px `#c6c4cb` con icono `#0a0a0d`, hover `#d6d5da` (**sin scale**). Shuffle/repeat activos en `accent`, inactivos `#747478`.
Progreso: tiempo transcurrido (10.5px tabular, ancho mín. 32px) · pista `rgba(255,255,255,.09)` de 3.5px con relleno en `accent` · duración. Clic en la pista = seek proporcional.
- **Derecha (256px):** letra, volumen (icono + pista de 84px con relleno `#8e8c98`), y botón de cola. Oculto por completo en móvil.
### 9. Barra de pestañas (`< 1180px`)
Bajo el player, `margin-top:10px; padding:7px; radius:18px`, mismo `barBg` y blur. Icono 17px + etiqueta 10px, distribuidos con `space-around`. Muestra los 5 ítems de navegación.
---
## Estados
- **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar.
- **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms.
- **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones.
- **Sin resultados:** se muestra cuando **ningún** tipo tiene coincidencias (canciones + álbumes + artistas + playlists = 0). Título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px).
- **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen.
---
## Interacciones
| Elemento | Comportamiento |
|---|---|
| Nav / tabs | Cambia de vista y limpia la búsqueda |
| Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto |
| Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` |
| Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) |
| Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición |
| Pista de progreso | Clic → `pos = (x / ancho) * duración` |
| Pista de volumen | Clic → `vol` entre 0 y 1 |
| Corazón (fila o player) | Alterna en el mapa `liked` |
| Shuffle / repeat | Alternan, se tiñen de `accent` |
| Botón "Cola" | Abre/cierra el aside |
| Buscar | Filtra canciones, álbumes, artistas y playlists → vista de resultados agrupada; vacío → vuelve a la vista normal; 0 coincidencias en total → estado "sin resultados" |
| Chip de filtro de resultados | Fija `sfilter`; `Todo` recorta cada grupo a 4, un tipo concreto muestra todos |
| Nueva playlist (sidebar / índice) | Abre el modal de crear playlist |
**Animación — reglas:**
- **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play.
- Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`.
- Cambio de tinte por canción: `.6s ease`.
- Entrada de vista: `rise` (`opacity 0 → 1`, `translateY(12px) → 0`) en `.3s ease`. Cola: `.22s`.
- EQ de pista activa: `eq` `.9s ease-in-out infinite` alternando `scaleY(.25)` y `scaleY(1)`, con desfases de 0.25/0.5s (0.3/0.6s en el sidebar).
---
## Estado necesario
```
view 'home' | 'explore' | 'playlists' | 'playlist' | 'liked' | 'recent'
listCtx nombre de la playlist abierta
idx índice de la pista en reproducción
playing boolean
pos segundos transcurridos (intervalo de 1s, vuelve a 0 al llegar a la duración)
vol 0..1
queueOpen boolean (forzado a false por debajo de 760px)
query texto de búsqueda
sfilter 'Todo' | 'Canciones' | 'Álbumes' | 'Artistas' | 'Playlists' (filtro de resultados)
liked { [trackId]: true }
shuffle / repeat boolean
hover clave del elemento con hover (o null)
w ancho de ventana (listener de resize)
demoState 'normal' | 'loading' | 'error' | 'empty' (solo para demostrar estados)
```
### Datos esperados
`Track { id, title, artist, album?, duration, cover?, hue, plays? }` ·
`Album { title, artist, year, cover? }` ·
`Artist { name, listeners, photo? }` ·
`Playlist { name, trackCount, topArtists, cover?, isPlaying? }`
Todos los campos opcionales deben tener degradado o texto de respaldo. El `hue` debería calcularse en backend desde la carátula (color dominante, con saturación mínima garantizada) y venir en el payload; en el prototipo está precalculado por pista.
---
## Modal: crear playlist
Diseño aparte en `Crear playlist.dc.html`. Se invoca desde "Nueva playlist" (sidebar e índice de playlists).
**Scrim:** `rgba(4,4,6,.55)` + `backdrop-filter: blur(3px)` sobre el fondo `radial-gradient(120% 90% at 50% 0%, #15131c, #08080b 58%, #050507)`. Modal centrado con `display:grid; place-items:center; padding:40px 20px`.
**Contenedor:** `max-width:560px; radius:24px; border:1px solid rgba(255,255,255,.075)`, fondo `linear-gradient(180deg,#141419,#0d0d11 62%,#0b0b0e)`, `box-shadow:0 32px 90px rgba(0,0,0,.66)` + `inset 0 2px 0 rgba(255,255,255,.035)`. Entrada `modalIn .28s cubic-bezier(.2,.8,.3,1)` (`opacity 0 → 1`, `translateY(14px) scale(.985)` → sin transformar).
**Aura:** capa absoluta no interactiva `radial-gradient(110% 60% at 18% -10%, oklch(.55 .13 H / .16), transparent 62%)`, con `transition: background .5s` — vira con el acento elegido.
**Header** (`padding:24px 26px 0`): eyebrow `TU BIBLIOTECA` 10.5px uppercase tracking .16em `#8f8f93`; título Sora 21px/600 tracking -0.03em `#d8d7db` "Crear playlist". Cerrar: 32px, radio 10px, borde `rgba(255,255,255,.06)`, fondo `rgba(255,255,255,.025)` → `.07` y color `#87878c` → `#d0cfd4` en hover.
**Portada** (columna izquierda, 124px):
- Cuadrado 124px radio 16px, `linear-gradient(150deg, oklch(.66 .16 H), oklch(.34 .11 H) 58%, #0e0e13)`, borde `rgba(255,255,255,.07)`, `box-shadow:0 14px 34px oklch(.5 .13 H / .22)`, con el icono de nota 26px `rgba(255,255,255,.62)` centrado. Transición `.45s` al cambiar de acento.
- **Selector de acento:** 5 franjas `flex:1; height:20px; radius:7px` (gap 6px, `margin-top:11px`), cada una `linear-gradient(145deg, oklch(.66 .16 h), oklch(.38 .11 h))`. Hues: Ámbar 68, Violeta 288 (por defecto), Azul 244, Verde 158, Rojo 22. Seleccionada: `box-shadow:0 0 0 1.5px rgba(255,255,255,.85), 0 0 0 4px rgba(0,0,0,.5)`; el resto `0 0 0 1px rgba(255,255,255,.07)`.
- **Subir imagen:** botón de texto 11px `#8a8a8f`, `padding:7px 0; radius:9px`, borde `1px dashed rgba(255,255,255,.1)`; hover texto `#cfccd9` y borde `rgba(255,255,255,.2)`.
**Campos** (columna derecha, `gap:20px` respecto a la portada):
- Etiquetas 11px/600 `#87878c`.
- Nombre: `padding:12px 14px; radius:12px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.075)`, texto **Sora 16px/600** tracking -0.02em `#dcdbe0`, placeholder "Playlist sin título" `#5d5d63`, `maxlength 60`. Foco: borde `rgba(255,255,255,.2)`, fondo `.055`. Contador `{n}/60` 10.5px `#65656a` alineado a la derecha.
- Descripción (opcional): `textarea` 3 filas, `resize:none`, 12.5px/1.6 `#c4c3c9`, mismo tratamiento de superficie y foco. Placeholder "Para qué sirve esta playlist, cuándo la escuchas…".
**Opciones** (`padding:20px 26px 0`, dos bloques `radius:13px; padding:13px 15px`, fondo `rgba(255,255,255,.026)`, borde `rgba(255,255,255,.055)`, gap 9px):
- **Visibilidad** — segmentado de 2 (`Privada` por defecto / `Pública`) en un riel `radius:10px` `rgba(0,0,0,.34)`; opción activa fondo `#cfccd9` texto `#0a0a0d`, inactiva `#8e8e94`. El texto de ayuda cambia: "Solo tú la ves en tu biblioteca" / "Aparece en tu perfil y se puede compartir".
- **Colaborativa** — toggle 40×23px, riel `rgba(255,255,255,.12)` → `oklch(.68 .15 H)` al activar, perilla 17px `#0c0c10` con `translateX(17px)`, `.2s cubic-bezier(.3,.8,.3,1)`. Ayuda: "Quien tenga el enlace puede añadir canciones". Toda la fila es clicable.
**Footer** (`border-top:1px solid rgba(255,255,255,.055)`, fondo `rgba(0,0,0,.2)`, `padding:17px 26px 20px`): nota 11px `#6e6e74` "Podrás añadir canciones desde cualquier pantalla."; a la derecha "Cancelar" (secundario, borde `rgba(255,255,255,.1)`, `#a9a7b2`) y "Crear playlist" (primario con icono +).
**Validación:** el CTA solo se habilita con nombre no vacío (`trim`). Deshabilitado: fondo `rgba(255,255,255,.08)`, texto `#6c6c72`, `cursor:not-allowed`. Habilitado: `#c6c4cb` sobre `#0a0a0d`.
**Confirmación:** al crear, toast fijo abajo-centro (`bottom:34px`, `radius:13px`, fondo `#16161c`, borde `rgba(255,255,255,.09)`, `box-shadow:0 18px 46px rgba(0,0,0,.6)`, `fadeIn .22s`) con un cuadrado 26px del degradado elegido y el texto «{nombre}» creada. Se oculta a los 2.6s.
**Estado:** `{ name, desc, hue: 0..4, vis: 'Privada'|'Pública', collab: boolean, created: boolean, createdName }`.
**Salida esperada:** `POST /playlists { name, description, coverHue, visibility, collaborative }` → redirigir a la playlist recién creada.
---
## Copy dinámico
Saludo del hero según hora y día de la semana:
- `< 5h` → "¿Sesión de madrugada, {nombre}?"
- `< 13h` → "Buenos días, {nombre}"
- `< 20h` → "Buenas tardes, {nombre}" (miércoles → "Feliz miércoles"; viernes → "Feliz viernes")
- `>= 20h` → "Buenas noches, {nombre}"
- Sábado/domingo → "Que tengas buen finde, {nombre}" (o "¿Madrugada de finde, {nombre}?" antes de las 5h)
---
## Assets
- **Carátulas y retratos:** marcadores de posición fotográficos servidos por `picsum.photos` con una semilla por elemento, pintados como `background-image` (no `<img>`) para evitar peticiones fallidas mientras se resuelven los datos. Sustituir por las imágenes reales del catálogo.
- **Iconos:** SVG inline, `stroke-width` 1.6–1.8, `stroke-linecap:round`. Reemplazables por el icon set del codebase.
- **Fuentes:** Sora y Plus Jakarta Sans (Google Fonts).
- Sin logotipos de terceros. El wordmark de Musify es tipográfico + el cuadrado con degradado.
## Files
- `Crear playlist.dc.html` — modal de creación de playlist (diseño aparte).
- `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty.
- `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia.
- `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior):
- `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home
- `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png`
Nota: las capturas son anteriores al rediseño de la vista de resultados de búsqueda (5b) y al modal de crear playlist; para esos dos, la referencia son los archivos HTML y este documento.
Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color.

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