Author SHA1 Message Date
Admin 63018a7c72 Move shared infrastructure to a separate Infrastructure repo
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger no longer run as part
of this stack. They're shared services (used by Musify, Ping and
docker-manager) that now live in a separate Infrastructure repository
and must already be running.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified with npm run check/build.
2026-09-20 22:48:08 +02:00
Admin a4ca1f32d3 Remove no-op playlist picture migration; cover picture-add transition in tests
The Sept migration MakePlayListPictureColumnsOptional altered the
PlayLists picture columns to nullable:true when they were already
nullable:true (set back in MakePlayListPicturesNullable, two months
earlier) — a dead migration left over from making the Pictures
navigation itself optional, which produces no actual schema change.
Verified with `dotnet ef migrations has-pending-model-changes` and a
full migration run against a fresh Postgres container that removing
it introduces no drift.

Also add a test for updating a playlist that has no picture yet,
which was the one Pictures-related state transition without coverage.
2026-09-20 22:40:40 +02:00
Admin 58eb670fed web-player: unify Artwork and Avatar
Add src/lib/components/ui/Artwork.svelte and Avatar.svelte (B.3) and
migrate every use of Cover, PlaylistArt, MixArt and ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explore, mix, user profile, TopBar,
MobileHeader). Remove the 4 old components.

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

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

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

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

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

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

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

Verified in the browser: --mf-accent/--mf-bg resolve correctly during
SSR, data-theme='light' applies before the first render with no
flash.
2026-09-20 22:26:37 +02:00
Admin ea9bf07126 Clean up Application layer commands/queries readability and correctness
Add missing AsNoTracking() on read-only queries, drop redundant
Update() calls on already-tracked entities, and fix inconsistent
formatting (stray spaces in generics, ternaries, and lambdas) across
the Albums/Tracks/PlayLists/Users command and query handlers.
2026-09-20 22:23:57 +02:00
Admin 11789e78fe web-player: base design system tokens
Add, additively (no component migration yet), the tokens from
section B.1 of the refactor plan: radii (tag/thumb), cover and icon
sizes, container widths, typographic tracking/leading, button colors
(--color-btn*), typography @utility recipes (text-display-*,
text-eyebrow, text-body, text-count), icon stroke
(stroke-thin/regular/bold), surfaces/grids (glass-bar, grid-cards*,
theme-transition), and the .animate-enter stagger via --i. Add
layout variables (--mf-player-side-w, --mf-menu-w, --mf-submenu-w,
--mf-range-*) and semantic z-index in theme.css. Clean up unused
color utilities (--color-accent-dim/glow/hair/line/muted,
--color-on-accent) and remove --mf-sidebar-bg (no importers) from
theme.css and tokens.ts. Scrollbar moves to tokens with a light-mode
variant.

Verified with npm run build (Tailwind compiles the @utility recipes
with responsive variants inside @apply).
2026-09-20 22:21:04 +02:00
Admin 96c75a32c8 web-player: baseline fixes and stray bugs
Fix eslint (dead imports in liked), missing title size in MediaCard,
non-exclusive classes in the Home spotlight rows, typography in the
album delete modal, useless text-base on icon buttons (Sidebar,
Queue), and remove Badge.svelte (unused).
2026-09-20 22:14:48 +02:00
127 changed files with 972 additions and 4899 deletions
+47 -49
View File
@@ -1,57 +1,55 @@
# Musify # Musify
Music streaming platform: upload your own tracks, process them asynchronously A music streaming app. Upload your own tracks, organize them into playlists
(audio transcoded to `.m4a`, thumbnails generated) and stream them without the and albums, and stream them back with a web player built around your
bytes ever going through the API. listening habits: auto-generated mixes, a home feed, search, likes, and an
Explore page for finding something new.
## What it does
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
- **Playlists**, likes, and a "recently played" history.
- **Mixes**, playlists generated automatically from what you actually listen to.
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
- **Accounts via OIDC login**, with an optional anonymous listening mode.
- Light and dark themes, and a layout that works on both desktop and mobile.
## How it's built
**Backend**: .NET 10, split into a REST API, a background worker, and a
dedicated streaming gateway.
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
- Zitadel for login (OIDC/OAuth2).
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
The session lives on the server, so the backend access token never reaches the browser.
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
shared infrastructure that lives in a separate `Infrastructure` git
repository, used by Musify and by other projects that need the same OIDC
provider and object storage.
## Project layout
``` ```
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client web-player/ the SvelteKit web client
deploy/ Docker Compose stacks — self-contained, no wrapper scripts deploy/ Docker Compose stacks for dev and production
docs/ What each piece does and why (in Spanish) docs/ architecture notes, one file per area
``` ```
```sh Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS) layout. They live in the separate `Infrastructure` repository instead.
```
## Run it ## Digging deeper
Development (Docker Desktop): [docs/](docs/README.md) covers how each piece works and why: architecture,
authentication, storage, streaming, media processing, and the local dev
```sh setup. [deploy/README.md](deploy/README.md) has the full instructions for
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d running the stack, in development or on a production server.
```
Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC
provisioning — all as one-shot jobs baked into the compose file. Apps run from
the IDE by default; add `--profile apps up -d --build` as a second command to
run them in Docker instead.
Production, on a server with a domain:
```sh
cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
```
Idempotent — run the same commands again to deploy an update. See
[deploy/README.md](deploy/README.md) for the details, the port map and the
production prerequisites.
## Components
| Service | Port (dev) | Role |
|---|---|---|
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails |
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
More in [docs/](docs/README.md): [architecture](docs/architecture.md),
[authentication](docs/authentication.md), [storage](docs/storage.md),
[streaming](docs/streaming.md), [media processing](docs/media-processing.md),
[development](docs/development.md).
@@ -1,6 +1,6 @@
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses
{ {
/// <summary>A page of the user's catalog albums (their own).</summary>
public record AlbumsSearchResponse( public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items, IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber, int PageNumber,
@@ -3,8 +3,7 @@ using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
/// <summary>Reads the parts of <see cref="PlaybackConfiguration"/> that are safe to expose to an
/// unauthenticated client, so it can decide whether to gate the app behind sign-in at all.</summary>
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>; public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
@@ -2,28 +2,16 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
/// <summary>Controls whether listeners without a session can stream music at all, and if so,
/// how much of a track they get to hear before being cut off (a Spotify-style preview).</summary>
public sealed class PlaybackConfiguration public sealed class PlaybackConfiguration
{ {
public const string SectionName = "Playback"; public const string SectionName = "Playback";
/// <summary>When false (the default), every stream endpoint requires a signed-in user, exactly
/// like before this setting existed. When true, an anonymous request is issued a stream ticket
/// too, capped by <see cref="AnonymousFragmentSeconds"/> if that is greater than zero.</summary>
public bool AllowAnonymousListening { get; set; } public bool AllowAnonymousListening { get; set; }
/// <summary>How many seconds of a track an anonymous listener may hear, enforced by the
/// Streaming Gateway clamping the byte range it will serve for that ticket. Zero (the default)
/// means no cap: anonymous listeners hear the full track, same as a signed-in user. Ignored
/// when <see cref="AllowAnonymousListening"/> is false.</summary>
[Range(0, 3600)] [Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; } public int AnonymousFragmentSeconds { get; set; }
/// <summary>Bytes per second of the transcoded audio, used to turn <see cref="AnonymousFragmentSeconds"/>
/// into a byte cap for the stream ticket. Defaults to the app's own transcoder output (128 kbps
/// AAC, <c>AudioTranscoder:Ffmpeg:AudioBitrate</c>) — update this alongside that setting if it
/// ever changes, since the two aren't wired together.</summary>
[Range(1, int.MaxValue)] [Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
} }
@@ -1,7 +1,5 @@
namespace Musify.Application.Configuration.Responses namespace Musify.Application.Configuration.Responses
{ {
/// <param name="AllowAnonymousListening">Whether listeners without a session can stream music at all.</param>
/// <param name="AnonymousFragmentSeconds">How many seconds of a track an anonymous listener may hear.
/// Zero means no cap. Meaningless when <paramref name="AllowAnonymousListening"/> is false.</param>
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
} }
@@ -2,11 +2,7 @@ namespace Musify.Application.Contracts
{ {
public interface IStreamTicketService public interface IStreamTicketService
{ {
/// <param name="userId">Null for an anonymous listener — the ticket is issued without a
/// <c>sub</c> claim.</param>
/// <param name="keyPrefix">The storage key (prefix) the ticket authorizes reading from.</param>
/// <param name="maxBytes">When set, the Streaming Gateway clamps the byte range it will serve
/// for this ticket to <c>[0, maxBytes)</c> — used for the anonymous preview fragment.</param>
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
} }
@@ -12,11 +12,6 @@ namespace Musify.Application.Mixes
public record GenerateMixesForUserCommand(long UserId) public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
/// <summary>Builds each user's mixes purely from their own library: a "Descubrimiento" mix of
/// tracks they own but haven't listened to yet, and a "Tu mezcla diaria" mix blending recently
/// listened tracks with unheard ones. There's no artist/genre metadata for local uploads today,
/// so mixes can't be seeded by "similar artist" the way the old YouTube-backed discovery did —
/// once a tagging system exists, this is the place to seed mixes by tag instead.</summary>
public class GenerateMixesForUserCommandHandler( public class GenerateMixesForUserCommandHandler(
IDatabase database, IDatabase database,
MixConfiguration mixConfiguration, MixConfiguration mixConfiguration,
@@ -5,9 +5,7 @@ using Musify.Domain.Entities;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes
{ {
/// <summary>Resolves MixItem rows to their MixItemApplicationResponse shape via a single batched
/// track lookup, instead of an EF Include chain deep enough (Mix -&gt; Items -&gt; Track -&gt; Owner) to
/// force a correlated-subquery translation some providers (SQLite, notably) can't produce.</summary>
internal static class MixItemMapper internal static class MixItemMapper
{ {
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
@@ -4,13 +4,6 @@ using System.Text.Json.Serialization;
namespace Musify.Application.Serialization; namespace Musify.Application.Serialization;
// Zitadel-issued user ids routinely exceed Number.MAX_SAFE_INTEGER (2^53-1).
// Serialized as a raw JSON number, the frontend's JSON.parse silently rounds
// them to the nearest representable double, corrupting the id (it becomes a
// different, sometimes valid, user id). Applied via [property: JsonConverter(...)]
// on the specific id-shaped `long`/`long?` response properties (not globally —
// unrelated `long` fields like byte sizes must stay numbers), this serializes
// them as a JSON string instead, which round-trips exactly.
public sealed class LongAsStringConverter : JsonConverter<long> public sealed class LongAsStringConverter : JsonConverter<long>
{ {
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
@@ -30,8 +30,6 @@ public sealed class TrackStreamIssuer(
folderPath, folderPath,
streamGatewayConfiguration.AudioFileName); streamGatewayConfiguration.AudioFileName);
// Anonymous listeners have no User row to satisfy ListeningHistory's foreign key against, and
// their plays aren't "history" anyone can look back on anyway, so there's nothing to record.
if (userId != null) if (userId != null)
{ {
var newListeningHistory = new ListeningHistory var newListeningHistory = new ListeningHistory
@@ -9,8 +9,7 @@ using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
/// <param name="UserId">Null for an anonymous request — allowed only when
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
public record GetTrackStreamQuery(Guid TrackId, long? UserId) public record GetTrackStreamQuery(Guid TrackId, long? UserId)
: IQuery<ErrorOr<TrackStreamResponse>>; : IQuery<ErrorOr<TrackStreamResponse>>;
@@ -1,6 +1,6 @@
namespace Musify.Application.Tracks.Responses namespace Musify.Application.Tracks.Responses
{ {
/// <summary>A page of the user's catalog tracks (their own uploads).</summary>
public record TracksSearchResponse( public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items, IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber, int PageNumber,
@@ -1,9 +1,6 @@
namespace Musify.Domain.ValueObjects namespace Musify.Domain.ValueObjects
{ {
/// <summary>
/// Encodes which genre pairs are mutually exclusive on a single track (e.g. a track cannot
/// be tagged as both Classical and Metal at the same time).
/// </summary>
public static class GenreCompatibility public static class GenreCompatibility
{ {
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
@@ -31,9 +28,6 @@ namespace Musify.Domain.ValueObjects
public static bool AreCompatible(Genre a, Genre b) => public static bool AreCompatible(Genre a, Genre b) =>
a == b || !IncompatiblePairs.Contains((a, b)); a == b || !IncompatiblePairs.Contains((a, b));
/// <summary>
/// Returns every mutually exclusive genre pair found within the given set of tags.
/// </summary>
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags) public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
{ {
var distinctTags = tags.Distinct().ToList(); var distinctTags = tags.Distinct().ToList();
@@ -24,11 +24,6 @@ namespace Musify.Infrastructure.Persistence
if (context == null) if (context == null)
return; return;
// SavingChanges(Async) fires before EF's own automatic change detection for this
// save, so entities modified through plain property assignment (query, then set a
// property, then SaveChanges — the normal case) still show as Unchanged here unless
// something already forced detection. Force it explicitly, otherwise UpdatedAt is
// never bumped on a real update, only on inserts.
context.ChangeTracker.DetectChanges(); context.ChangeTracker.DetectChanges();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260531234306_AddUserProfilePictureUrl")] [Migration("20260531234306_AddUserProfilePictureUrl")]
partial class AddUserProfilePictureUrl partial class AddUserProfilePictureUrl
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddUserProfilePictureUrl : Migration public partial class AddUserProfilePictureUrl : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -18,7 +18,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true); nullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260601195757_RenameTimestampsAndAudit")] [Migration("20260601195757_RenameTimestampsAndAudit")]
partial class RenameTimestampsAndAudit partial class RenameTimestampsAndAudit
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RenameTimestampsAndAudit : Migration public partial class RenameTimestampsAndAudit : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -31,7 +31,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
newName: "CreatedAt"); newName: "CreatedAt");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705123932_AddTrackProcessingStateSaga")] [Migration("20260705123932_AddTrackProcessingStateSaga")]
partial class AddTrackProcessingStateSaga partial class AddTrackProcessingStateSaga
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackProcessingStateSaga : Migration public partial class AddTrackProcessingStateSaga : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -27,7 +27,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
}); });
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705124807_AddTrackProcessingSagaKeys")] [Migration("20260705124807_AddTrackProcessingSagaKeys")]
partial class AddTrackProcessingSagaKeys partial class AddTrackProcessingSagaKeys
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackProcessingSagaKeys : Migration public partial class AddTrackProcessingSagaKeys : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -29,7 +29,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true); nullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260705125356_AddPlayListProcessingStateSaga")] [Migration("20260705125356_AddPlayListProcessingStateSaga")]
partial class AddPlayListProcessingStateSaga partial class AddPlayListProcessingStateSaga
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddPlayListProcessingStateSaga : Migration public partial class AddPlayListProcessingStateSaga : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -27,7 +27,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
}); });
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260713234847_AddListeningHistory")] [Migration("20260713234847_AddListeningHistory")]
partial class AddListeningHistory partial class AddListeningHistory
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddListeningHistory : Migration public partial class AddListeningHistory : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -47,7 +47,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "UserId"); column: "UserId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260714114537_AddTrackDuration")] [Migration("20260714114537_AddTrackDuration")]
partial class AddTrackDuration partial class AddTrackDuration
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackDuration : Migration public partial class AddTrackDuration : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
@@ -18,7 +18,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: 0); defaultValue: 0);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260715193951_AddTrackSourceArtistExternalId")] [Migration("20260715193951_AddTrackSourceArtistExternalId")]
partial class AddTrackSourceArtistExternalId partial class AddTrackSourceArtistExternalId
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackSourceArtistExternalId : Migration public partial class AddTrackSourceArtistExternalId : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -41,7 +41,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
filter: "\"ExternalId\" <> ''"); filter: "\"ExternalId\" <> ''");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260715230836_AddTrackDownloadRequested")] [Migration("20260715230836_AddTrackDownloadRequested")]
partial class AddTrackDownloadRequested partial class AddTrackDownloadRequested
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackDownloadRequested : Migration public partial class AddTrackDownloadRequested : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
@@ -18,7 +18,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: false); defaultValue: false);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716214442_MakeTrackMediaAndExternalIdNullable")] [Migration("20260716214442_MakeTrackMediaAndExternalIdNullable")]
partial class MakeTrackMediaAndExternalIdNullable partial class MakeTrackMediaAndExternalIdNullable
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MakeTrackMediaAndExternalIdNullable : Migration public partial class MakeTrackMediaAndExternalIdNullable : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -66,7 +66,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716214752_AddArtistsAndTrackOwnership")] [Migration("20260716214752_AddArtistsAndTrackOwnership")]
partial class AddArtistsAndTrackOwnership partial class AddArtistsAndTrackOwnership
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddArtistsAndTrackOwnership : Migration public partial class AddArtistsAndTrackOwnership : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<long>( migrationBuilder.AddColumn<long>(
@@ -154,7 +154,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260716220047_DropTrackArtistColumn")] [Migration("20260716220047_DropTrackArtistColumn")]
partial class DropTrackArtistColumn partial class DropTrackArtistColumn
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class DropTrackArtistColumn : Migration public partial class DropTrackArtistColumn : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -15,7 +15,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
table: "Tracks"); table: "Tracks");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717164632_MovePlayListPicturesToOwnedType")] [Migration("20260717164632_MovePlayListPicturesToOwnedType")]
partial class MovePlayListPicturesToOwnedType partial class MovePlayListPicturesToOwnedType
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,16 +4,15 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MovePlayListPicturesToOwnedType : Migration public partial class MovePlayListPicturesToOwnedType : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717164812_RenameTrackDurationToDurationSeconds")] [Migration("20260717164812_RenameTrackDurationToDurationSeconds")]
partial class RenameTrackDurationToDurationSeconds partial class RenameTrackDurationToDurationSeconds
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RenameTrackDurationToDurationSeconds : Migration public partial class RenameTrackDurationToDurationSeconds : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -16,7 +16,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
newName: "DurationSeconds"); newName: "DurationSeconds");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717172507_SplitTrackIntoLocalAndExternal")] [Migration("20260717172507_SplitTrackIntoLocalAndExternal")]
partial class SplitTrackIntoLocalAndExternal partial class SplitTrackIntoLocalAndExternal
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class SplitTrackIntoLocalAndExternal : Migration public partial class SplitTrackIntoLocalAndExternal : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -156,7 +156,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldNullable: true); oldNullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260717223358_MakePlayListPicturesNullable")] [Migration("20260717223358_MakePlayListPicturesNullable")]
partial class MakePlayListPicturesNullable partial class MakePlayListPicturesNullable
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MakePlayListPicturesNullable : Migration public partial class MakePlayListPicturesNullable : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -51,7 +51,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldMaxLength: 64); oldMaxLength: 64);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720180629_WidenTrackTitle")] [Migration("20260720180629_WidenTrackTitle")]
partial class WidenTrackTitle partial class WidenTrackTitle
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class WidenTrackTitle : Migration public partial class WidenTrackTitle : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -31,7 +31,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
oldMaxLength: 50); oldMaxLength: 50);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720180747_AddAlbums")] [Migration("20260720180747_AddAlbums")]
partial class AddAlbums partial class AddAlbums
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddAlbums : Migration public partial class AddAlbums : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -100,7 +100,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "OwnerUserId"); column: "OwnerUserId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260720184656_AddUserAlbumExternalId")] [Migration("20260720184656_AddUserAlbumExternalId")]
partial class AddUserAlbumExternalId partial class AddUserAlbumExternalId
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddUserAlbumExternalId : Migration public partial class AddUserAlbumExternalId : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -28,7 +28,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721155102_AddMixes")] [Migration("20260721155102_AddMixes")]
partial class AddMixes partial class AddMixes
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddMixes : Migration public partial class AddMixes : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -83,7 +83,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "TrackId"); column: "TrackId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721161653_RemoveUserAlbumExternalId")] [Migration("20260721161653_RemoveUserAlbumExternalId")]
partial class RemoveUserAlbumExternalId partial class RemoveUserAlbumExternalId
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RemoveUserAlbumExternalId : Migration public partial class RemoveUserAlbumExternalId : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -24,7 +24,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
column: "OwnerUserId"); column: "OwnerUserId");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721162102_DropMixGeneratedOn")] [Migration("20260721162102_DropMixGeneratedOn")]
partial class DropMixGeneratedOn partial class DropMixGeneratedOn
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class DropMixGeneratedOn : Migration public partial class DropMixGeneratedOn : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -15,7 +15,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
table: "Mixes"); table: "Mixes");
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<DateOnly>( migrationBuilder.AddColumn<DateOnly>(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260721190735_MakePlayListDescriptionOptional")] [Migration("20260721190735_MakePlayListDescriptionOptional")]
partial class MakePlayListDescriptionOptional partial class MakePlayListDescriptionOptional
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class MakePlayListDescriptionOptional : Migration public partial class MakePlayListDescriptionOptional : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -27,7 +27,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
"""); """);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724170051_DurationAsDouble")] [Migration("20260724170051_DurationAsDouble")]
partial class DurationAsDouble partial class DurationAsDouble
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,16 +4,15 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class DurationAsDouble : Migration public partial class DurationAsDouble : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724181012_AddExternalAlbums")] [Migration("20260724181012_AddExternalAlbums")]
partial class AddExternalAlbums partial class AddExternalAlbums
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddExternalAlbums : Migration public partial class AddExternalAlbums : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -36,7 +36,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260724182659_AddExternalAlbumThumbnail")] [Migration("20260724182659_AddExternalAlbumThumbnail")]
partial class AddExternalAlbumThumbnail partial class AddExternalAlbumThumbnail
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddExternalAlbumThumbnail : Migration public partial class AddExternalAlbumThumbnail : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
@@ -18,7 +18,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
nullable: true); nullable: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260910125022_AddAlbumProcessingStateSaga")] [Migration("20260910125022_AddAlbumProcessingStateSaga")]
partial class AddAlbumProcessingStateSaga partial class AddAlbumProcessingStateSaga
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddAlbumProcessingStateSaga : Migration public partial class AddAlbumProcessingStateSaga : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -28,7 +28,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
}); });
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260914144913_AddPlayListVisibility")] [Migration("20260914144913_AddPlayListVisibility")]
partial class AddPlayListVisibility partial class AddPlayListVisibility
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -4,10 +4,10 @@
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddPlayListVisibility : Migration public partial class AddPlayListVisibility : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
@@ -18,7 +18,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
defaultValue: 0); defaultValue: 0);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260914150045_AddTrackLikes")] [Migration("20260914150045_AddTrackLikes")]
partial class AddTrackLikes partial class AddTrackLikes
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackLikes : Migration public partial class AddTrackLikes : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -49,7 +49,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260914151900_AddUserFollows")] [Migration("20260914151900_AddUserFollows")]
partial class AddUserFollows partial class AddUserFollows
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddUserFollows : Migration public partial class AddUserFollows : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@@ -49,7 +49,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260915165436_RemoveYouTubeIntegration")] [Migration("20260915165436_RemoveYouTubeIntegration")]
partial class RemoveYouTubeIntegration partial class RemoveYouTubeIntegration
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -5,25 +5,16 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class RemoveYouTubeIntegration : Migration public partial class RemoveYouTubeIntegration : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
// Data cleanup first, while the External*/Local*/UserAlbums tables (and the loose
// MixItem columns) still exist to query/cascade from. Deleting the base Tracks/Albums
// rows for anything that only ever existed as an ExternalTrack/ExternalAlbum cascades
// (FKs are ON DELETE CASCADE) to their own ExternalTracks/ExternalAlbums row, TrackArtist,
// PlayListHasTrack, AlbumHasTrack, TrackLike, ListeningHistory, UserHasTrack and MixItems
// rows that referenced them — this is deliberately lossy: those rows only existed to back
// the YouTube integration being removed here.
migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");"""); migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");""");
migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");"""); migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");""");
// Live YouTube search hits that were seeded into a mix but never materialized into a
// real track (MixItemSource.YouTube, no TrackId) — MixItem.TrackId is about to become
// required, so these rows can't be kept.
migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;"""); migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;""");
migrationBuilder.AddColumn<long>( migrationBuilder.AddColumn<long>(
@@ -132,7 +123,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Restrict); onDelete: ReferentialAction.Restrict);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -16,7 +16,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
[Migration("20260916153700_AddTrackTags")] [Migration("20260916153700_AddTrackTags")]
partial class AddTrackTags partial class AddTrackTags
{ {
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
@@ -6,10 +6,10 @@ using Musify.Domain.ValueObjects;
namespace Musify.Infrastructure.Persistence.Migrations namespace Musify.Infrastructure.Persistence.Migrations
{ {
/// <inheritdoc />
public partial class AddTrackTags : Migration public partial class AddTrackTags : Migration
{ {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterDatabase() migrationBuilder.AlterDatabase()
@@ -41,7 +41,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
unique: true); unique: true);
} }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
@@ -1,4 +1,4 @@
// <auto-generated /> 
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -41,13 +41,8 @@ public sealed class JwtBearerEventsHandler(
if (cache.TryGetValue(cacheKey, out _)) if (cache.TryGetValue(cacheKey, out _))
return; return;
// Access tokens from the IdP are minimal (just sub/scope/aud) — profile attributes live on
// the userinfo endpoint, so ask for them there instead of relying on token claims alone.
var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted); var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted);
// Zitadel also issues short-form JWT claim names ("name", "given_name", "family_name", "email"),
// but the newer JsonWebTokenHandler (default since .NET 8) no longer auto-maps those to the
// legacy ClaimTypes.* URIs the way JwtSecurityTokenHandler used to — so check both forms too.
var username = userInfo?.Name var username = userInfo?.Name
?? userInfo?.PreferredUsername ?? userInfo?.PreferredUsername
?? principal.FindFirstValue("name") ?? principal.FindFirstValue("name")
@@ -39,13 +39,6 @@ public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> op
return Task.CompletedTask; return Task.CompletedTask;
} ); } );
// Properties annotated with [JsonConverter(typeof(LongAsStringConverter))] (id-shaped
// `long` fields — see that converter for why) serialize as a JSON string, but the
// schema generator still reflects the declared CLR `long`/`long?` type as a bare
// int64 number. Reconcile the declared schema so generated clients (openapi-typescript)
// type exactly those properties as `string`, matching what actually goes over the wire —
// scoped to the attribute, not the CLR type, so unrelated `long` fields (byte sizes, …)
// are left alone.
options.AddSchemaTransformer((schema, context, _) => options.AddSchemaTransformer((schema, context, _) =>
{ {
if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter)
-2
View File
@@ -48,6 +48,4 @@ app
app.Run(); app.Run();
// Makes the top-level-statements Program class public so
// WebApplicationFactory<Program> (Musify.Api.Tests) can see it.
public partial class Program; public partial class Program;
@@ -6,10 +6,6 @@ using Musify.StreamingGateway.Configuration;
namespace Musify.StreamingGateway.Authentication; namespace Musify.StreamingGateway.Authentication;
/// <param name="Prefix">The storage key prefix the ticket authorizes reading from. Empty means the
/// ticket was missing, malformed, expired, or otherwise invalid.</param>
/// <param name="MaxBytes">When set, the caller is limited to the first <c>MaxBytes</c> bytes of the
/// object — the anonymous preview fragment cap carried by the <c>maxBytes</c> claim.</param>
public sealed record TicketValidationResult(string Prefix, long? MaxBytes) public sealed record TicketValidationResult(string Prefix, long? MaxBytes)
{ {
public static readonly TicketValidationResult Invalid = new(string.Empty, null); public static readonly TicketValidationResult Invalid = new(string.Empty, null);
@@ -72,12 +72,6 @@ public sealed class TicketValidationMiddleware(
await next(context); await next(context);
} }
/// <summary>Rewrites the request's <c>Range</c> header so nothing past <paramref name="maxBytes"/>
/// is ever asked of the filer — this is what actually enforces the anonymous preview length,
/// downstream of the ticket carrying that cap. A request with no <c>Range</c> header (a plain,
/// whole-file GET) gets one synthesized, so the filer always responds with a bounded 206 instead of
/// streaming the full object. Returns false when the requested range starts at or past the cap, in
/// which case the caller should answer 416 directly instead of proxying anything.</summary>
private static bool TryClampRange(HttpContext context, long maxBytes) private static bool TryClampRange(HttpContext context, long maxBytes)
{ {
var rangeHeader = context.Request.Headers.Range; var rangeHeader = context.Request.Headers.Range;
-5
View File
@@ -7,11 +7,6 @@ using Musify.Infrastructure.Services;
var builder = Host.CreateApplicationBuilder(); var builder = Host.CreateApplicationBuilder();
// Registering Mediator pulls in every handler in Musify.Application, including ones
// (e.g. CreatePlayList, CreateTrack) that depend on IEventBus, which the Worker never
// registers because it only consumes events, it never publishes them. Only the handlers
// this process actually dispatches (GenerateMixesForUserCommand) are ever constructed,
// so the default eager validate-on-build check is too strict for this host.
builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions
{ {
ValidateOnBuild = false, ValidateOnBuild = false,
+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
+18 -16
View File
@@ -3,11 +3,14 @@
# #
# 1. replace every `example.com` with your domain, # 1. replace every `example.com` with your domain,
# 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 these DNS records at the server, all proxied/TLS-terminated the
# same way: example.com, www, api, auth, stream, s3. # same way: example.com, www, api, auth, stream, s3.
# #
# 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
@@ -20,22 +23,15 @@ PUBLIC_STREAM_URL=https://stream.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 +40,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
+86 -76
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: adds the nginx edge and joins Infrastructure's `infra-net` network. |
| `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. |
The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind the 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,53 +69,55 @@ 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: On a server with a domain:
1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled
in. It creates the `infra-net` network this stack joins.
2. From this repository's root:
```sh ```sh
cp deploy/.env.prod.example deploy/.env.prod cp deploy/.env.prod.example deploy/.env.prod
@@ -110,31 +126,29 @@ cp deploy/.env.prod.example deploy/.env.prod
Fill it in: Fill it in:
1. Replace every `example.com` with your domain and every `CHANGE_ME` with a 1. Replace every `example.com` with your domain and every `CHANGE_ME` with a
random value (`openssl rand -hex 24`; the Zitadel masterkey needs exactly random value (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
32 characters). `chmod 600 deploy/.env.prod`. 2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`** — the origin
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)* certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
mode in front of it, so a Cloudflare Origin Certificate is enough; any mode in front of it, so a Cloudflare Origin Certificate is enough; any
certificate valid for the five hostnames works. certificate valid for the five hostnames works.
3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server, 3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server,
proxied/TLS-terminated the same way. proxied and TLS-terminated the same way.
Then, same two commands as development, with the prod env file and overlay: Then:
```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 and the nginx edge; the
jobs; the second builds and starts the applications, already wired to the second builds and starts the applications, already wired to the OIDC client
OIDC client ids the first one produced. Both are idempotent — run them again ids the first one produced. Both are idempotent, so running them again
to deploy a new version (`docker compose ... --profile apps up -d --build` deploys a new version (`... --profile apps up -d --build` alone is enough for
alone is enough for an update once the stack already exists; it reuses the an update once the stack already exists).
infra containers and only rebuilds and restarts the apps).
Only nginx publishes ports (80/443); everything else is reachable only on the Only nginx publishes ports (80/443). Every other Musify service, and all of
internal compose network. Infrastructure's, is reachable only on the internal `infra-net` network.
--- ---
@@ -142,17 +156,16 @@ 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: nginx edge, joins infra-net
├─ .env.example / .env.prod.example ├─ .env.example / .env.prod.example
├─ keys/ # RS256 stream-ticket key pair (generated) ├─ keys/ # RS256 stream-ticket key pair (generated)
├─ nginx/ └─ nginx/
│ ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst
│ ├─ snippets/proxy.conf # proxy headers shared by every vhost ├─ snippets/proxy.conf # proxy headers shared by every vhost
│ └─ certs/ # origin.pem + origin.key (not versioned) └─ 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 +174,15 @@ 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,
which this stack does not run. `zitadel-init` turns that requirement off so
the built-in `/ui/login` is used.
- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`; - **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`;
on case-sensitive Linux the host looks for `appsettings*.json`, so their on case-sensitive Linux the host looks for `appsettings*.json`, so their
Dockerfiles create lowercase copies at publish time. Dockerfiles create lowercase copies at publish time.
- **Re-running `zitadel-init` or `migrate`** is safe: the former reuses the - **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they
existing project/apps by name (regenerating the web app's secret only if it reuse the existing project/apps/bucket by name, and `migrate` is a normal
is missing from the env file), the latter is a normal `dotnet ef database `dotnet ef database update`, a no-op once the schema is current.
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:
+12 -12
View File
@@ -1,16 +1,19 @@
name: musify-prod name: musify-prod
# Production overlay: adds the nginx edge and the host-mounted secrets. # Production overlay: adds the nginx edge and joins Infrastructure's shared
# Nothing but nginx publishes a port; every other service is reachable only on # `infra-net` network so this project's containers can reach Postgres,
# the internal `musify-prod_default` network. # Zitadel, SeaweedFS, RabbitMQ and Jaeger by container name. None of them
# run as part of this compose project anymore (see
# ../../../Infrastructure/README.md, which must already be up). Nothing but
# nginx publishes a port; every other service is reachable only on infra-net.
# #
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
#
# 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.
@@ -20,16 +23,13 @@ services:
# Every vhost proxies through `set $upstream ...; proxy_pass $upstream;` # Every vhost proxies through `set $upstream ...; proxy_pass $upstream;`
# with the embedded Docker DNS resolver (see nginx/templates), so nginx # 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 # 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 # 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 # (profile-gated services aren't part of that invocation yet) and still be
# reachable at the auth domain for zitadel-init to provision through. # reachable at the auth domain for zitadel-init to provision through.
nginx: nginx:
image: nginx:1.27-alpine image: nginx:1.27-alpine
container_name: musify-nginx container_name: musify-nginx
depends_on:
zitadel:
condition: service_started
environment: environment:
# The entrypoint renders /etc/nginx/templates/*.template with envsubst. # The entrypoint renders /etc/nginx/templates/*.template with envsubst.
# The filter keeps it from eating nginx's own $host / $remote_addr. # The filter keeps it from eating nginx's own $host / $remote_addr.
+60 -168
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:
@@ -495,10 +392,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:
+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.
+91 -87
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.
@@ -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>

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