- Rename the local results heading from 'Mi música' to 'Musify'.
- Replace the per-card add-to-playlist icon on YouTube results with a right-click context menu listing all playlists.
- Stop the search box from clearing what the user is typing when a debounced navigation resolves after they've kept typing, by decoupling the input's value from the server-driven query prop.
The player never registered Media Session action handlers, so the OS/keyboard next-track and previous-track keys had nothing to call — only play/pause worked via the browser's built-in audio-element heuristic. Now previoustrack/nexttrack are wired to the player, and metadata/playback state are kept in sync for the lock-screen/OS media widget.
The queue/history identity for a song originating on YouTube now always uses its videoId, instead of switching to the local track id once it finishes downloading. Previously that switch made the same song produce two separate entries when merging session and server listening history.
Explore now has Todo/Mi musica/YouTube Music filters (searching both by default), YouTube results share the local grid card style, and cards show the artist instead of the upload date.
Playing a YouTube song now creates a lightweight track row (without queueing a download) and writes listening history; saving to a playlist queues the download via the new DownloadRequested flag.
Rows now span the full page width and show track duration plus
listens count, matching the columns already used elsewhere in the
app. Also trims the trailing bottom padding to match other pages.
GetListeningHistory used AsEnumerable() before DistinctBy/Take, which
forced EF Core to materialize every history row for the user just to
keep the last 15 distinct tracks. Now groups by track and takes the
15 most recently played track ids in SQL, then fetches only those.
Home and explore had an oversized trailing bottom padding compared to
the rest of the app; brought both down to match. Also matches "Mis
listas" grid/limit on home to "Novedades", and sizes the library page
header the same as the playlists page header.
Create and edit playlist modals now upload/preview a cover image
(reusing the presigned-upload flow already used for tracks) and share
identical layout and coloring. Adds a cover proxy route and wires
PlaylistArt to render a playlist's real cover before falling back to
the track mosaic/gradient.
Also reworks the playlist detail page: full-width borderless "Añadir
de tu biblioteca" table with listens count and click-to-play, drops
the empty-playlist placeholder message, and restyles the playlists
grid to match the rest of the app.
TrackApplicationResponse.FromEntity counted track.ListeningHistories,
but none of the queries that built a response ever Included it, so
the collection was always empty and listensCount was always 0. Each
query now projects the count as a correlated subquery before mapping
to the response.
Adds GET /playlists/{id}/cover (mirrors the track cover endpoint) so
uploaded playlist pictures can actually be served. Also drops the
NotEmpty rule on playlist description, which rejected the blank
description the frontend treats as optional; CreatePlayList now falls
back to the domain's default text instead.
Add @fontsource-variable (manrope/sora/inter), @lucide/svelte and @types/node, mark @lucide/svelte as noExternal for SSR, and add the dev-server launch config.
Move authenticated pages under an (app) group with a Sidebar shell, add /auth/login and page-based /login, drop the old explore page and login +server endpoint, and add the server upload helper.
Expand the player store, split player controls (transport, seek, volume, track info), add Cover/PlaylistArt/NowPlaying UI primitives and a cover image proxy route.
- Add data-sveltekit-reload to the /login links so the browser performs a full
navigation (the client router intercepted them and aborted on the external
Zitadel redirect)
- Default post-login returnTo to / (home) since /explore is not built yet
- 'Empezar gratis' now starts the register flow (/login?mode=register)
The 'latest' Zitadel image requires the Login UI v2, which runs as a separate
'login' container this stack doesn't deploy, so OIDC redirected to /ui/v2/login
and returned {code:5, Not Found}. provision.ps1 now disables the loginV2
requirement via the feature API so OIDC uses the built-in /ui/login. Also
applied to the running instance.
Add Authorization Code + PKCE login/logout against Zitadel, handled entirely
server-side with an httpOnly signed session cookie:
- $lib/server/auth: openid-client discovery (config cached), jose-signed
session encode/decode, cookie/name constants
- routes: /login (builds the authorize URL with PKCE/state/nonce, ?mode=register
-> prompt=create), /auth/callback (code exchange + session cookie),
/logout (clears session + Zitadel end_session)
- hooks.server populates locals.user/accessToken from the session
- +layout.server exposes the user; Navbar shows the signed-in user + Salir,
or Iniciar sesión / Registrarse
- switch to adapter-node; env-driven config (.env gitignored, .env.example
committed) with issuer http://host.docker.internal:8080
Verified: svelte-check + build clean; dev smoke test shows the landing in the
logged-out state and /login issuing a 302 to Zitadel's authorize endpoint with
PKCE/state/nonce (discovery reachable). Real client id/secret still needed to
complete the token exchange.
New SvelteKit app in frontend/ (minimal template, TypeScript, TailwindCSS v4).
Adds the app shell (dark music theme navbar + footer) and a landing page with
hero and feature cards. CTAs point to /login and /explore (built in later parts).
Verified: production build succeeds and the page renders with Tailwind styles
applied (checked via preview).
- Remove the no-op Worker BackgroundService and its registration; the process
is kept alive and does its work through the MassTransit bus and the upload
intent jobs (their own hosted services)
- Fix the gateway middleware guard to reject on string.IsNullOrEmpty(prefix)
instead of 'is null', which no longer matched TicketValidator returning
string.Empty for invalid tickets (invalid tickets skipped the 401 and an
empty object key could slip through)
Build: 0 warnings, 0 errors.
Tighten the stream-ticket authorization in the gateway middleware:
- Enforce a path-segment boundary on the prefix check instead of a bare
StartsWith, so a ticket for 'users/12' no longer grants 'users/1234/...'
(exact-match still allowed for single-object grants)
- Reject object keys containing '..'/'.' segments before proxying, closing a
path-traversal bypass that SeaweedFS would otherwise resolve outside the
authorized prefix
Also simplify TicketValidator.ValidateTicketAsync from ErrorOr<string> to
string? (null = invalid) since it has a single failure mode; drop the now
unused ErrorOr package reference from the gateway.
Verified: build clean; the authorization predicate passes a table of cases
covering both the escalation and traversal vulnerabilities.
Build: 0 warnings, 0 errors.
- Document every endpoint's responses (Produces<T>/status codes/validation)
so the generated OpenAPI/Scalar docs carry response schemas and status codes
instead of empty responses
- Cache recently-synced user ids (IMemoryCache, 15 min) in the JWT
TokenValidated handler so user provisioning no longer hits the DB on every
authenticated request
- Add ProblemDetails + UseExceptionHandler for consistent RFC7807 errors
- Add ToCreatedResult ErrorOr helper and use it in the create endpoints
- Normalize the playlists route to lowercase (/playlists)
Verified: booted the Api against RabbitMQ + Postgres in Docker and inspected
/openapi/v1.json — operations now expose response schemas/status codes and the
route is /playlists.
Build: 0 warnings, 0 errors.
Single docker-compose stack for local development (Postgres, Zitadel,
RabbitMQ, SeaweedFS + bucket init, Jaeger) plus an optional overlay to run
the .NET apps in Docker. up.ps1 orchestrates the full bootstrap: stack up,
stream-ticket keys, EF Core migrations and Zitadel provisioning.
- Zitadel is provisioned automatically (service-account PAT -> project +
OIDC app with JWT access token); the generated ClientId is wired into
Musify.Api user-secrets and .env. No manual console steps.
- ZITADEL_EXTERNAL_DOMAIN=host.docker.internal so token validation works
both with local apps and full-docker.
- App Dockerfiles copy AppSettings*.json to lowercase so config loads on
case-sensitive Linux; Worker image ships ffmpeg.
- Scripts split by responsibility (common/stream-keys/migrate/provision);
apps overlay de-duped via YAML anchors.
- Remove the superseded standalone seaweedfs compose; point docs/development
at the unified stack.
Register AuditableEntityInterceptor as a singleton and attach it through the
AddDbContext options callback instead of newing it up inside
Database.OnConfiguring. The interceptor is stateless, so a single shared
instance is reused across contexts and its wiring lives in the DI layer.
The design-time factory keeps working (interceptor is runtime-only, irrelevant
for migrations).
Verified: build + ef migrations list (design-time context) OK; the Worker host
builds and validates the full DI graph (reaches endpoint configuration) with no
captive-dependency or interceptor error.
Build: 0 warnings, 0 errors.
- Register IAmazonS3 as a singleton instead of scoped: the AWS SDK client is
thread-safe and manages its own connection pool, so one instance per DI scope
(per request / per job iteration) was wasteful and risked socket exhaustion
- AudioTranscoderService: start reading stdout/stderr before writing stdin to
remove the latent pipe-buffer deadlock shape
- Dispose the resized-picture MemoryStream in ResizePictureActivity
- Background jobs use PeriodicTimer instead of while + Task.Delay
Verified: Worker boots against RabbitMQ + Postgres in Docker with no captive-
dependency error (dev scope validation) and both jobs start.
Build: 0 warnings, 0 errors.
Adds the PlayList picture-processing lifecycle saga (correlated by PlayListId)
mirroring the track one, and generalizes the failure plumbing:
- Rename the track fault translator to ProcessingSlipFaultConsumer and use a
generic Workflow.SubjectId variable + ProcessKind; it now emits typed
Track{Picture,Audio}/PlayListPicture ProcessingFailed events for all slips
- PlayListProcessingStateMachine (+ EF-persisted PlayListProcessingState,
migration): starts on CreatePlayListResourcesEvent, finalizes on
PlayListPictureProcessed, and on failure marks the playlist Failed and
removes the orphaned final picture (PlayListProcessingFailedConsumer +
MarkPlayListAsFailedActivity)
- UpdatePlayListPictureActivity publishes PlayListPictureProcessed on success
- Picture slip carries SubjectId + PlayListPicture kind + fault subscription
Verified end-to-end in Docker (RabbitMQ + migrated Postgres): both saga
endpoints, the fault translator and cleanup consumers configure, and an
addressing cross-check confirms all 21 activity execute queues the builders
address exist (caught + fixed a kebab mismatch on mark-play-list-as-failed).
Build: 0 warnings, 0 errors.
On definitive failure of the picture or audio slip, the saga now reaches a
Failed state and recovers end-to-end:
- Picture/audio track slips carry TrackId + ProcessKind variables and a
subscription to a fault translator (TrackProcessingSlipFaultConsumer) that
turns RoutingSlipFaulted into a typed Track{Picture,Audio}ProcessingFailed
- The saga stores the final object keys from the start event and, on failure,
transitions to Failed and publishes TrackProcessingFailed
- TrackProcessingFailedConsumer runs a cleanup slip that removes the orphaned
final picture/audio copies and marks the track LifeCycleStatus=Failed
(MarkTrackAsFailedActivity) — the track stays as a visible, recoverable record
- Migration for the new saga columns
Verified: Worker boots against RabbitMQ + Postgres in Docker; the new
endpoints (fault translator, failed consumer, mark-track-as-failed) configure
and the bus starts clean.
Build: 0 warnings, 0 errors.
Introduce a MassTransit saga state machine that correlates by TrackId and
tracks the two parallel processing slips (picture + audio):
- TrackProcessingStateMachine + EF-persisted TrackProcessingState
(pessimistic concurrency, EntityFrameworkRepository), migration added
- Starts on CreateTrackResourcesEvent; UpdateTrack{Picture,Audio}Activity now
publish TrackPictureProcessed / TrackAudioProcessed on success; when both
arrive the saga finalizes (Ready). Failure transitions to a Failed state
(cleanup wired in the next phase)
- Add LifeCycleStatus.Failed and the four outcome events
Verified: booted the Worker against RabbitMQ + migrated Postgres in Docker;
the saga endpoint 'track-processing-state' is configured and the bus starts
clean.
Build: 0 warnings, 0 errors.
Replace ~240 lines of hand-written per-endpoint wiring with MassTransit's
ConfigureEndpoints + KebabCaseEndpointNameFormatter, and apply retry + EF
outbox globally through AddConfigureEndpointsCallback. Adding a consumer or
activity is now a single AddConsumer/AddActivity line.
- Normalize the 4 mismatched activity endpoint names to kebab-case so the
routing-slip addressing keeps resolving (update-play-list-picture,
delete/mark/publish play-list variants); cleanup consumer queue is now
routing-slip-clean-up
- Drop the now-unused QueueName consts from event consumers and trim
EndpointHelper / delete the manual endpoint-config partial
Verified end-to-end: booted the Worker against RabbitMQ in Docker and
confirmed all 38 endpoints are configured with the exact names the builders
address (19 execute queues + compensations + consumers + routing-slip-clean-up).
Build: 0 warnings, 0 errors.
Fix (recoverability bug):
- TransferFilesToBucketActivity returned Completed() with no log, so its
compensation could never delete the transferred files (orphaned objects on
fault). IStorageService.TransferFilesAsync now returns the real uploaded
keys; the activity logs them so Compensate can remove them. Also drops the
Path.Combine key recomputation (produced backslash keys on Windows).
Consistency / DRY:
- Normalize all courier activities to internal + folder-based namespaces
(several were public and in the flat .Activities namespace)
- Extract the duplicated exponential retry into UseStandardRetry
- Unify endpoint-name logic into a single EndpointHelper (remove the parallel
MassTransitEndpointNames helper)
Build: 0 warnings, 0 errors.
- Add Musify.Application.AddApplication(services, config) as the layer's DI
entry point: registers Mediator, UploadIntentValidator and all app configs
- Collapse the 5 near-identical AddXConfiguration methods into a single
generic AddValidatedOptions<T>(config, section)
- Move UploadIntentValidator registration out of Infrastructure into the
Application layer (fixes layering leak); Infra config binding now uses the
generic helper
- Api calls AddApplication; Worker uses AddValidatedOptions for its configs
Build: 0 warnings, 0 errors.
One file per use case under the feature folder (Tracks/PlayLists/Users),
dropping the Commands/Queries/Handlers sub-folders and their namespaces.
Each file now holds the record and its handler in namespace
Musify.Application.<Feature>; external usings updated accordingly.
Build: 0 warnings, 0 errors.
Ardalis Result<T> carried incoherent success sub-statuses and a clunky
generic converter; ErrorOr models success-or-errors cleanly.
- Application: all commands/queries/handlers now return ErrorOr<T>
(non-generic Result -> ErrorOr<Success>); errors via Error.NotFound/
Conflict/Validation/Unauthorized/Failure; success via value or new Success()
- Drop ResultExtensions.As<>(): error propagation is now just return .Errors
- Rewrite HTTP mapping (ErrorOrHttpExtensions) off Error.Type; success maps
to 200 (value) or 204 (Success); Created still built in the endpoints
- Endpoints/gateway middleware: !IsSuccess -> IsError
- Swap Ardalis.Result package for ErrorOr 2.1.1 (remove unused ref from Worker)
Build: 0 warnings, 0 errors.
Run autoskills in backend/: detects .NET/C#/ASP.NET Core/Minimal API and
installs 13 skills for universal (.agents) and claude-code (.claude) agents,
with backend/skills-lock.json. Adds ASP.NET Core, Minimal API + OpenAPI,
file-upload and containerization skills on top of the prior C#/.NET set.
Removes the stale root skills from the old layout.
- Move all .NET (6 projects, solution, Directory.*.props, nuget.config)
into backend/ so the monorepo root can host non-.NET projects later
- Remove throwaway test artifacts: player.html, pl.json
- Trim dead Flutter rules from .gitignore
Build from backend/Musify.slnx: 0 warnings, 0 errors.