Author SHA1 Message Date
Admin a081b85efc Restore the invitations tab, render library tracks on first paint and re-measure the tab indicator
CI/CD / BackEnd (push) Successful in 1m45s
CI/CD / WebPlayer (push) Successful in 52s
CI/CD / Deploy (push) Skipped
2026-09-25 02:40:50 +02:00
Admin 76b62ec384 Document coauthor invitations, invite dialog and coauthor helpers 2026-09-25 02:24:42 +02:00
Admin 2a7e805480 Link track coauthors next to the artist in rows and the player 2026-09-25 02:23:43 +02:00
Admin dddb57c98b Add coauthor invitations tab with accept and reject 2026-09-25 02:22:46 +02:00
Admin 058a902a81 Invite and remove track coauthors from the library songs list 2026-09-25 02:21:37 +02:00
Admin 153c33bf51 Add TagInput chip input 2026-09-25 02:17:53 +02:00
Admin 2b2f4093fb Map track coauthors, coauthor invites and invitations 2026-09-25 02:17:13 +02:00
Admin 8a5b1bd353 Regenerate API schema for track coauthors 2026-09-25 02:15:32 +02:00
Admin 03f5936944 Add track coauthors with invitations, acceptance and coauthor-aware track responses
Owners invite existing users to coauthor a track; invitees accept or reject,
and either side can remove a coauthor. Only accepted coauthors appear in track
responses and in the coauthor's public track list. The owner also receives the
full invitation list with statuses on the user tracks response.
2026-09-25 02:14:23 +02:00
Admin a888a44b41 Document the library components and test dropped folder reading 2026-09-25 01:53:59 +02:00
Admin d874e06d64 Merge direct upload queue 2026-09-25 01:52:28 +02:00
Admin f3ed68856c Refresh processing tracks live, apply publish and delete optimistically and guard unsaved album edits 2026-09-25 01:52:27 +02:00
Admin 3eccf6dc53 Upload from the library queue straight to storage and retire the upload page 2026-09-25 01:51:20 +02:00
Admin e19aba777d Add upload queue store with concurrent uploads, progress, cancel and retry 2026-09-25 01:51:20 +02:00
Admin 493cc74cfb Add direct upload API for presigning, creating tracks and following processing 2026-09-25 01:51:19 +02:00
Admin 9f592cfdb8 Filter library tracks on the server and add track publishing, bulk delete and album types 2026-09-25 01:49:44 +02:00
Admin 93d7a23c53 Regenerate API schema for track publication, album types and batch uploads 2026-09-25 01:43:38 +02:00
Admin 202164aa50 Merge backend library endpoints 2026-09-25 01:38:19 +02:00
Admin ebfd41b160 Add track publication, album types, optional track pictures and batch upload URLs
Tracks
- Track.IsPublished (migration marks existing tracks as published). New
  tracks are created unpublished.
- PUT /tracks/{id}/published and PUT /tracks/published change the flag;
  publishing requires completed audio processing (the batch endpoint
  reports skipped tracks).
- An unpublished track is only visible to its owner. A WherePublished /
  VisibleTo filter is applied to search, genres, listening history, last
  listened, likes, mixes, album and playlist contents and counts, recently
  listened albums, public user tracks, track details and stream tickets.
- isPublished is part of the track responses, GET /tracks/users/{userId}
  accepts a published filter and status-counts reports published and
  unpublished totals.
- CreateTrackRequest.pictureIntentId is optional: without it the track
  uses the album cover or the preset picture and no picture processing
  runs. CreateTrackResourcesEvent carries nullable picture fields, so
  queues must be drained before deploying.
- POST /tracks/upload-urls accepts an audios list and one optional picture
  shared by all of them, and returns an intent per file. The upload intent
  limits are raised so a batch fits.

Albums
- AlbumType (Album, EP, Single) on create, update and responses.
- CreateAlbumRequest.trackIds creates the album and links the tracks in a
  single save.
2026-09-25 01:37:55 +02:00
Admin 1c20e106fa Merge library components and styles work 2026-09-25 01:24:00 +02:00
Admin 43928ba9b9 Animate library tabs, rows, editor track lists, status badges and dropzone
Tabs get a sliding indicator that persists across library routes, grid rows flip and collapse on removal, the selection bar and filters crossfade, album tracks move between lists with a crossfade, processing badges pulse and the dropzone scales while dragging.
2026-09-25 01:23:40 +02:00
Admin ebe9769d4e Align library styles with the design system
Use type recipes and Button danger variant, add danger tint, dropzone and cover-fill tokens, give SearchInput a size prop so the top bar keeps its larger search, show queue details inline on mobile, derive upload limits from constants and document the card hover style.
2026-09-25 01:20:32 +02:00
Admin 074ea7bf26 Extract shared data grid, selection bar, file dropzone and genre picker components
Track lists and the upload queue now render through a declarative DataGrid, the album editor reuses AlbumForm with a split layout and a track picker, and genre labels and types are shared helpers.
2026-09-25 01:17:59 +02:00
Admin 7cac6ff446 Move library album and track operations into shared server modules 2026-09-25 01:15:04 +02:00
Admin d839a72700 Add track status filter and counts, album track ids and bulk track deletion
GET /tracks/users/{userId} accepts repeated status values and returns the
albums each track belongs to. GET /tracks/users/{userId}/status-counts,
GET /albums/{id}/tracks/ids and DELETE /tracks with a list of track ids
are new.
2026-09-25 01:14:59 +02:00
Admin 797bed8e7d Split the library page into song, album and upload routes with linked tabs 2026-09-25 01:11:01 +02:00
Admin 9928840577 Add library manager with tracks, albums and uploads sections 2026-09-25 01:07:15 +02:00
Admin 4ac73d8fa3 Update sidebar to the new page 2026-09-24 19:25:15 +02:00
Admin 88487064e1 Update Sidebar.svelte to adjust padding for playlist display 2026-09-24 19:10:04 +02:00
Admin e4e4617f6e Copy .editorconfig into the migrator image so analyzers match the local build 2026-09-24 19:08:50 +02:00
Admin c2100c189f Added worker to the launch file 2026-09-24 18:02:52 +02:00
Admin 1a302491d1 Changes 2026-09-24 17:58:48 +02:00
Admin e84c16cf92 A lot of changes 2026-09-24 17:54:16 +02:00
Admin ece6ce2a43 Merge branch 'backend-refactor-rev' into audit-merge-three
Adapt the refactored web player to the new backend API (id path params,
bulk track adds, flat list items, mix kind) and port AddAlbumToPlayList
to the backend conventions.
2026-09-24 17:30:40 +02:00
Admin ecf9faf640 Merge branch 'frontend-refactor-rev' into audit-merge-three 2026-09-24 17:25:31 +02:00
Admin 35d6314ce8 Point the IDE dev settings at the shared Infrastructure stack
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 41s
CI/CD / Deploy (push) Skipped
appsettings.Development.json still targeted Musify's old own Postgres
(port 59000) with the superuser, the default RabbitMQ vhost as guest and
the admin S3 keys. They now use Infrastructure's dev ports and Musify's
own dev credentials (role, vhost and S3 identity), the same defaults as
deploy/.env.example.

The API's non-secret Zitadel settings (issuer, metadata, endpoints) move
there too, so user-secrets only need the per-machine client id.
2026-09-24 17:11:50 +02:00
Admin f99f74f4a1 Use Musify's own credentials on the shared services
Infrastructure now gives each project its own Postgres role, RabbitMQ
user and vhost, S3 identity limited to its bucket and a Zitadel service
account that owns only its project, instead of handing out the admin
accounts.

- Postgres, RabbitMQ and S3 settings default to Musify's own (musify).
- MassTransit connects to the `musify` vhost (RABBITMQ_VHOST, taken from
  the address path); RABBITMQ_DEFAULT_USER/PASS become RABBITMQ_USER and
  RABBITMQ_PASSWORD.
- zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's
  zitadel/.output/musify) and uses that PAT. It no longer creates the
  Zitadel project, which that account can't do: Infrastructure does.
2026-09-24 17:05:23 +02:00
Admin f2765e2e7b Call the API by container name from the web player
In prod every project shares infra-net, where the `api` service name
can resolve to another project's container as well.
2026-09-24 15:06:25 +02:00
Admin 7b8f3f1222 Use a reverse-domain redirect scheme for the native app
- Android/iOS now return to cat.ikerdemo.musify:/callback instead of
  musify://callback: a private-use scheme based on a domain we own
  (RFC 8252 section 7.1), so another app is unlikely to claim it.
- The Musify Native app no longer needs devMode: loopback and custom
  scheme redirects are both valid for a native app.
- zitadel-init also updates an existing app's config, so the change
  reaches instances created before it (the client id is kept).
- iOS takes the scheme from everything before the first ':', since the
  new URI has a single slash.
2026-09-24 15:06:25 +02:00
Admin c33b0e8bfd Stop disabling Zitadel Login V2 from zitadel-init
The shared Infrastructure stack now runs the Login V2 container, and
turning the feature off here would switch every project on the shared
instance back to the old login.
2026-09-24 15:06:25 +02:00
Admin 8e85af2b3b Render unknown URLs inside the app shell and move ListeningStats to the domain types
CI/CD / BackEnd (push) Successful in 1m19s
CI/CD / WebPlayer (push) Successful in 46s
CI/CD / Deploy (push) Skipped
Add catch-all routes so unknown pages 404 inside the (app) layout and unknown
/api URLs return a JSON 404 instead of redirecting to the login page. Restore
the full root error page.
2026-09-24 14:29:45 +02:00
Admin 2d23eb87c6 Update frontend docs for the new structure and fix session test typing 2026-09-24 14:06:46 +02:00
Admin e5ce63a192 Add tests for API endpoints, liked store, listen tracker and paged list 2026-09-24 14:04:43 +02:00
Admin b5c9658d65 Add tests for sessions, guards, API error details and the app layout load 2026-09-24 14:03:35 +02:00
Admin ba0a2373b5 Normalize API counters in the server mappers instead of route loaders 2026-09-24 14:02:23 +02:00
Admin b0163f6a4d Split the explore page into genre grid, top result and search results components 2026-09-24 14:01:33 +02:00
Admin 0a192645d6 Drive the theme accent from the app layout instead of the player 2026-09-24 13:59:54 +02:00
Admin 351b333529 Split the server API module into client, guards, form fields and action helpers 2026-09-24 13:59:21 +02:00
Admin 155f0e2b6a Move liked tracks state to state/ and recently played merge to player/ 2026-09-24 13:58:43 +02:00
Admin a54d1ef077 Move store-connected components out of ui/ into components/music and lint the ui boundary 2026-09-24 13:58:13 +02:00
Admin 2361b88e0a Rename the app shell components folder and move AccountMenu next to its consumers 2026-09-24 13:57:35 +02:00
Admin e7c2fd5571 Move the app shell and its data loading into the (app) route group layout 2026-09-24 13:57:02 +02:00
Admin b8524aa5a8 Update naming conventions in .editorconfig and docs.editorc
CI/CD / BackEnd (push) Successful in 1m34s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Skipped
2026-09-24 13:31:31 +02:00
Admin 73e37dbecd Change editor rule 2026-09-24 13:24:43 +02:00
Admin 3126cb6dd6 web-player: regenerate package-lock with npm 10 so npm ci works on Node 22
CI/CD / BackEnd (push) Successful in 1m19s
CI/CD / WebPlayer (push) Successful in 45s
CI/CD / Deploy (push) Skipped
2026-09-24 13:16:43 +02:00
Admin 16478678b5 Use System.Text.Json number handling for long values 2026-09-24 13:14:05 +02:00
Admin 03afcc8a90 web-player: sync package-lock with missing emnapi optional dependencies
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Failing after 8s
CI/CD / Deploy (push) Skipped
2026-09-24 13:02:38 +02:00
Admin 7f51145915 Update docs for the refactored backend and add backend conventions guide 2026-09-24 13:01:32 +02:00
Admin 28a7c4a98c Enable more analyzers, drop log-and-rethrow in file activities and apply dotnet format
Use invariant culture when formatting ids, put cancellation tokens last in endpoint handlers, remove the
CS8618 pragmas and keep only the analyzer overrides that still apply.
2026-09-24 12:57:53 +02:00
Admin 2d0867e68a Add tests for MassTransit activities, routing slips, sagas, fault handling and playlist visibility
Infrastructure exposes its internals to the test project so the activities and slip builders can be
exercised directly, and the fault consumer and state machines run on the MassTransit test harness.
2026-09-24 12:53:33 +02:00
Admin bc6ca3651f Share the entity model between Database and the test database via ModelConfiguration 2026-09-24 12:41:05 +02:00
Admin c4cf5af7f1 Make the Worker validate its service container and align Worker and Gateway hosts
The Worker now registers the Application layer, observability and stream tickets, validates the
container on build and ships a complete appsettings. The Gateway drops underscore fields and uses a
placeholder bucket in its default YARP transform.
2026-09-24 12:40:18 +02:00
Admin fcf3093ad8 Unify API route parameters, return IResult everywhere, merge picture upload DTOs and centralize validation limits
Guid and long route constraints, id/trackId parameter names, Update*Request without the New prefix,
sentence-case operation summaries, shared security scheme id and a single Limits class.
2026-09-24 12:36:48 +02:00
Admin 405df082cc Deduplicate MassTransit routing slips, activities and consumers 2026-09-24 12:25:55 +02:00
Admin 0cba32f776 Simplify infrastructure services and move all recurring jobs to Hangfire 2026-09-24 12:18:57 +02:00
Admin 0fa033faff Separate responsibilities: start-listening command, API-side JSON converters, bulk track endpoints, mix kinds 2026-09-24 12:14:53 +02:00
Admin f210fa4702 web-player: add albums to playlists through the bulk endpoint
CI/CD / BackEnd (push) Successful in 1m29s
CI/CD / WebPlayer (push) Failing after 7s
CI/CD / Deploy (push) Skipped
2026-09-24 12:05:34 +02:00
Admin 75d0c0817e Deduplicate picture flows: shared upload issuer, cover query and picture source change 2026-09-24 12:04:54 +02:00
Admin 4b708aa987 backend: add endpoint to add a whole album to a playlist 2026-09-24 12:04:28 +02:00
Admin 4b147d9ee3 Add shared pagination, projections and ownership helpers; simplify query and command handlers 2026-09-24 11:59:34 +02:00
Admin bd07b1134e web-player: fix icon size guardrail and add larger icon tokens 2026-09-24 11:56:09 +02:00
Admin c041daf649 web-player: add lint guardrails for imports, non-null assertions, unused props and icon sizes; refresh docs 2026-09-24 11:54:38 +02:00
Admin 975fa7e4fd web-player: consistent imports, tokens, glossary and app route grouping 2026-09-24 11:52:57 +02:00
Admin 1a2ae37f49 web-player: show only real upload steps 2026-09-24 11:51:47 +02:00
Admin 1cad36b929 web-player: extract menu, paged list and play-all components from pages 2026-09-24 11:51:14 +02:00
Admin 5a0f82b77b Clean up domain entities: shared picture value object, lifecycle and ownership interfaces, explicit tables 2026-09-24 11:43:50 +02:00
Admin 8cb9386447 web-player: unify primitive link/button rendering, add onEscape action and self-contained AccountMenu 2026-09-24 05:27:25 +02:00
Admin 5cda7cb4c1 web-player: split player, navigation and accent state into focused modules 2026-09-24 05:25:35 +02:00
Admin 4d943e068e Unify picture configuration and options registration 2026-09-24 05:23:40 +02:00
Admin e89463fb83 web-player: use playlist totals from the API, cache genres and parallelize explore requests 2026-09-24 05:21:29 +02:00
Admin b011ccdd5b backend: expose track count and duration on playlist responses 2026-09-24 05:20:20 +02:00
Admin 5e819d7676 Remove dead code: unused storage/transcoder members, Upload table, retry columns, redundant packages 2026-09-24 05:19:59 +02:00
Admin 1df7f6ccf6 web-player: share form limits between client and server and extract form parsers 2026-09-24 05:19:41 +02:00
Admin 0e3576922f Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete 2026-09-24 05:14:05 +02:00
Admin 2915737f03 web-player: centralize server helpers (apiFor, authedAction, form readers) and unify page sizes 2026-09-24 05:13:51 +02:00
Admin 5b2a0e4d46 web-player: normalize tracks, albums and playlists into single domain types at the server boundary 2026-09-24 05:09:37 +02:00
Admin 37fc229bf6 web-player: remove dead code and unused props 2026-09-24 05:04:09 +02:00
Admin bc43b4b6eb web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs 2026-09-24 05:02:26 +02:00
Admin ab388e6ad6 Fix processing pipeline: report album failures, fault handling on create slips, config key names, audio duration 2026-09-24 05:02:04 +02:00
Admin c8e9c7790e Close data leaks: remove anonymous user creation, hide private playlists, validate uploads and clamp gateway ranges 2026-09-24 05:00:21 +02:00
Admin dda8c30d45 backend: expose owner user id on playlist responses 2026-09-24 04:59:50 +02:00
Admin 906f38140a web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage 2026-09-24 04:59:28 +02:00
Admin 3e546d4a1e web-player: fix open redirect via returnTo, require SESSION_SECRET and validate API inputs 2026-09-24 04:58:08 +02:00
Admin c8fe28b07b Add editorconfig and analyzers, format backend code 2026-09-24 04:54:17 +02:00
Admin b2f450dcf7 web-player: add Vitest, unit tests and pure queue operations 2026-09-24 04:54:09 +02:00
NotS1mp 87c3f97529 Merge pull request 'Web player general changes' (#22) from web-player-general-changes into master
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 2m4s
Reviewed-on: #22
2026-09-23 22:37:30 +00:00
Admin 302dd1e0f8 Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
2026-09-24 00:32:03 +02:00
NotS1mp 421fc929e8 Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s
Reviewed-on: #20
2026-09-23 22:24:22 +00:00
NotS1mp c4ab5dc388 Merge branch 'master' into dynamic-genres
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m20s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 22:15:22 +00:00
NotS1mp 920dcc6779 Merge pull request 'Listening time accuracy' (#19) from listening-time-accuracy into master
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m40s
Reviewed-on: #19
2026-09-23 22:15:10 +00:00
Admin 430c711ff2 Refactor CI/CD workflow to improve database migration process and key generation
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m27s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 23:59:45 +02:00
Admin cfdc61a4b8 Scroll content inside the shell so the player shares the ambient backdrop
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The player dock used to sit fixed over the scrolling page, so it needed an
opaque background. The shell is now a fixed-height frame where only the
content column scrolls, letting the dock stay transparent like the sidebar
and queue. Scroll reset/restore on navigation moves to restoreScroll(), and
the stable scrollbar gutter moves from <html> to the content scroller.
2026-09-23 23:52:27 +02:00
Admin 0f28e19c1d Remove follow button from user rows
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 23:20:27 +02:00
Admin 7316e355ee Add a second greeting variant per time slot 2026-09-23 23:20:27 +02:00
Admin b39e55fd87 Restore master hero and spotlight gradients and sway them in place 2026-09-23 23:15:15 +02:00
Admin cb046bba9e Let users browse followers and following lists
Following lists are public; followers are visible to the user and to mutual followers.
Adds GET /users/{id}/followers and /following, exposes isFollowing and canViewFollowers
on the profile, and returns isFollowedByViewer from user search so the web player no
longer calls /is-following per user.
2026-09-23 22:55:14 +02:00
Admin b9a40186b1 Lighten active surface gradients in light mode
CI/CD / BackEnd (push) Successful in 1m15s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 22:44:42 +02:00
Admin fd0a39b879 IsExplicit 2026-09-23 22:42:28 +02:00
Admin cc7afe4326 Add explicit flag to tracks and mark it on upload 2026-09-23 22:41:57 +02:00
Admin 72cbc18a48 Add follow button next to each user in search results
Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
2026-09-23 22:34:56 +02:00
Admin 84408b842b Semibold 2026-09-23 22:25:04 +02:00
Admin 339fcb56ad Semibold 2026-09-23 22:24:57 +02:00
Admin 74cf534be5 Animate queue drag and drop reordering 2026-09-23 22:18:21 +02:00
Admin 89820c1377 Fix gradient background syntax in Artwork component 2026-09-23 22:10:46 +02:00
Admin 2fe26b15dd Allow reordering upcoming tracks in the queue by drag and drop 2026-09-23 22:10:21 +02:00
Admin 2cb560fda4 Keep light mode gradients visible while drifting 2026-09-23 22:07:50 +02:00
Admin d8530b9209 Animate menu close with shared pop transition 2026-09-23 22:01:51 +02:00
Admin 5049b0ba91 Update global icon stroke width to 1.5 in layout.css 2026-09-23 21:59:24 +02:00
Admin 1b98a75c0e Added shadow 2026-09-23 21:59:13 +02:00
Admin cbbde95773 Set global icon stroke width and remove per-icon strokeWidth props 2026-09-23 21:55:00 +02:00
Admin 26bb8a50a3 Solid player bar, gradient sliders and smooth progress 2026-09-23 21:48:39 +02:00
Admin bf19bb31f9 Make accent hue crossfade between songs last 2 to 4 seconds 2026-09-23 21:40:22 +02:00
Admin 55cc803708 Drift accent gradients on buttons, hero, spotlight, active rows and modals 2026-09-23 21:37:41 +02:00
Admin 805e521b1d Animate ambient background, add mute toggle and smooth queue panel 2026-09-23 21:36:01 +02:00
Admin f8d431a0d7 Implement volume control with mute functionality; update PlayerState to manage muted state 2026-09-23 21:28:17 +02:00
Admin 7b72db8a24 Add ambient drift animation to app backdrop; adjust inset and handle reduced motion 2026-09-23 21:28:07 +02:00
Admin b61ff1ebec Make Discover and upload genres dynamic
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles,
and the upload form kept its own copy of the genre list and compatibility
rules. Both now come from the backend.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

compose.prod.yml joins Infrastructure's infra-net network instead of
running its own copies. Docs and READMEs updated to match.
2026-09-21 22:09:08 +02:00
Admin c5748e9765 Fix route 2026-09-21 22:08:53 +02:00
Admin 23e30d96fc Translate docs to English, rewrite README, drop design handoff folder
Translate all of docs/ from Spanish to English and rewrite the root
README as a short project overview instead of a technical run guide.
Remove web-player/design_handoff_musify_player, an old design-tool
export that was no longer needed.
2026-09-21 18:41:04 +02:00
NotS1mp 3f8d517ee3 Merge pull request 'Design Prot Merge' (#1) from design-prot into master
Reviewed-on: #1
2026-09-21 15:48:16 +00:00
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
Admin a963e2c9af Update backend NuGet packages to latest compatible versions
Bump MassTransit family to 8.5.10 (latest free/open-source patch on
the 8.x line; v9 requires a paid commercial license, so it's out of
scope). Align OpenTelemetry.Instrumentation.EntityFrameworkCore and
.Process prerelease versions with the rest of the OpenTelemetry 1.19.0
set. Microsoft.OpenApi and SixLabors.ImageSharp are left as-is: OpenApi
is already pinned to the latest version compatible with
Microsoft.AspNetCore.OpenApi's [2.12.0, 3.0.0) constraint, and
ImageSharp 3.x/4.x carry a commercial licensing model.
2026-09-20 22:09:48 +02:00
Admin 4251d292e1 Changes on packages 2026-09-20 16:23:49 +02:00
Admin 3f5e01f910 Update packages 2026-09-20 16:00:57 +02:00
Admin c14db5a711 Checkpoint web-player design-system refactor and backend track genre tagging
Advances the ongoing token/componentization cleanup (anchored player dock,
border-based panels instead of floating shadows, corrected light-mode
accent/gradient tokens) alongside in-progress backend support for track
genre tags.
2026-09-17 17:43:37 +02:00
Admin 5ae9955bd0 Remove YouTube integration from web-player; unify Home track rows
Drops every YouTube-backed route, proxy and type (external
albums/tracks, thumbnail proxy, youtube.ts) and simplifies
player/track/mix types to the local-only catalog now that the
backend contract dropped Source/ExternalId. Genre tiles on Explore
and "Populares esta semana" on Home stay as deliberate client-only
placeholders per product decision, just without the YouTube backing.

Also finishes the design-system unification pass: Home's three
hand-rolled track rows (Continuar escuchando, Playlist destacada,
Populares esta semana) now reuse the shared TrackTitleCell component
instead of duplicating cover+title+artist markup, and the orphaned
Rail.svelte (no importers) is removed.
2026-09-15 21:17:14 +02:00
Admin 31032bd8e8 Remove YouTube integration from backend and deployment config
Drops the YouTube-backed second catalog source entirely: the
Local/External split on Track/Album collapses into single concrete
entities, Artist/TrackArtist/ExternalTrack/ExternalAlbum and the
YouTube service/download pipeline (yt-dlp, MassTransit consumer,
routing slip) are deleted, and mixes now source discovery only from
local listening history. Includes the RemoveYouTubeIntegration EF
Core migration and matching Worker/deploy/compose cleanup (no more
yt-dlp/python/deno in the Worker image, no thumbnail-cache/YtDlp
config sections).
2026-09-15 21:17:03 +02:00
Admin 5696683859 Fix light-mode regression and finish design-system cleanup
A code-review pass on the previous commit found a real bug: the explore
genre tiles used var(--mf-ink) as a decorative gradient endpoint, but that
token is meant for text-on-accent and flips to white in light mode,
turning the tiles solid white. Replaces it with a new, deliberately
theme-invariant --mf-tile-shade token (same category as --mf-cover-grad).

Also: raises CollectionHeader's meta-line contrast in light mode
(text-muted, ~3:1, to text-fg-2), makes its actions prop optional so
pages without conditional actions don't render an empty wrapper, moves
AccountMenu.svelte into $lib/components/ui/ per the repo's own
componentization rule, adds the aria-expanded TopBar's trigger was
missing relative to MobileHeader's, and migrates the profile page
(u/[id]/+page.svelte) onto CollectionHeader — a sixth duplicate of the
same header pattern that the previous commit touched but missed.
2026-09-15 18:06:37 +02:00
Admin f1756468bc Unify web-player design tokens and componentize collection headers
Migrates remaining one-off colors (bg-white/[N%], border-white/N, hardcoded
shadows/gradients) to the existing --mf-* tokens so light mode reacts
consistently everywhere, including a fix to --color-hover which was
hardcoded instead of pointing at --mf-surface-2. Extracts CollectionHeader
(album/playlist/mix/liked headers) and AccountMenu (TopBar/MobileHeader)
to remove five and two duplicated implementations respectively, and
normalizes inconsistent empty-state copy.
2026-09-15 17:53:47 +02:00
Admin 7861600081 Redesign explore search and inline top-bar search box
- Explore: replace per-kind colored badges/tints in search results
  with the app's neutral accent, add an always-visible Usuarios chip,
  and keep the top-result type label as plain text
- Move SearchBox into TopBar/MobileHeader as an inline debounced
  search form, dropping the standalone SearchBox component
- Sync design handoff artboards with the current search UI
2026-09-15 03:35:05 +02:00
Admin dc1ba89a6d Add likes, follows and playlist visibility; redesign web-player UI
Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
2026-09-14 23:42:45 +02:00
969 changed files with 50379 additions and 20234 deletions
+15 -2
View File
@@ -4,14 +4,27 @@
{ {
"name": "musify-web", "name": "musify-web",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "web-player", "run", "dev", "--", "--port", "3000"], "runtimeArgs": ["--prefix", "web-player", "run", "dev"],
"port": 3000 "port": 3000,
"autoPort": true
}, },
{ {
"name": "musify-api", "name": "musify-api",
"runtimeExecutable": "dotnet", "runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"], "runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
"port": 5111 "port": 5111
},
{
"name": "musify-worker",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Worker"],
"port": 5112
},
{
"name": "musify-streaming-gateway",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"],
"port": 8081
} }
] ]
} }
+127
View File
@@ -0,0 +1,127 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+3 -1
View File
@@ -7,6 +7,9 @@ obj/
*.userosscache *.userosscache
.vs/ .vs/
artifacts/ artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS # Rider / VS
.idea/ .idea/
@@ -25,5 +28,4 @@ Thumbs.db
# Deploy secrets and runtime state # Deploy secrets and runtime state
deploy/keys/ deploy/keys/
deploy/secrets/ deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/ deploy/zitadel/.output/
+47 -49
View File
@@ -1,57 +1,55 @@
# Musify # Musify
Music streaming platform: upload your own tracks (or import them from YouTube), A music streaming app. Upload your own tracks, organize them into playlists
process them asynchronously (audio transcoded to `.m4a`, thumbnails generated) and albums, and stream them back with a web player built around your
and stream them without the 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, YouTube downloads |
| `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).
+79
View File
@@ -0,0 +1,79 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{csproj,props,targets,slnx,xml}]
indent_size = 2
[*.json]
indent_size = 4
[**/Migrations/**.cs]
generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = block_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_diagnostic.IDE0005.severity = warning
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first.
# Private fields are _camelCase whatever their modifiers (static, readonly, const).
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = underscore_camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_style.underscore_camel_case.required_prefix = _
# Non-private fields (constants, static readonly, etc.) are PascalCase.
dotnet_naming_rule.other_fields.symbols = other_fields
dotnet_naming_rule.other_fields.style = pascal_case
dotnet_naming_rule.other_fields.severity = warning
dotnet_naming_symbols.other_fields.applicable_kinds = field
dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_rule.interfaces.symbols = interfaces
dotnet_naming_rule.interfaces.style = interface_style
dotnet_naming_rule.interfaces.severity = warning
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.interface_style.capitalization = pascal_case
dotnet_naming_style.interface_style.required_prefix = I
dotnet_naming_rule.types_and_members.symbols = types_and_members
dotnet_naming_rule.types_and_members.style = pascal_case
dotnet_naming_rule.types_and_members.severity = warning
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
dotnet_naming_symbols.types_and_members.applicable_accessibilities = *
# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the
# EF-style IDatabase.Set<T>() and the Musify.Application.Shared namespace, whose names are deliberate.
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion
# The routing slip builders are stateless services resolved from DI; they stay instance members.
dotnet_diagnostic.CA1822.severity = suggestion
# Test names follow Method_Scenario_Expected.
[Tests/**.cs]
dotnet_diagnostic.CA1707.severity = none
# xUnit collection fixtures are conventionally named ...Collection.
dotnet_diagnostic.CA1711.severity = none
+58
View File
@@ -0,0 +1,58 @@
# Musify: backend
.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`.
## Commands
Run from `backend/`.
```sh
dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005)
dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers)
dotnet format Musify.slnx --verify-no-changes
```
- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit.
- EF migrations: `dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses.
- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean.
- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit.
## Style
- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it).
- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006).
- `ArgumentNullException.ThrowIfNull(x)` without `nameof`.
- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`).
- User-facing strings are in English (the web-player owns translation).
- Trim stored names; normalized columns go through `TextNormalizer.Normalize`.
- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last.
## Application layer
- One file per use case, `<Feature>/<UseCase>.cs`, holding the record and its handler; responses go in `Responses/`.
- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204.
- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows.
- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found).
- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow.
- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`.
## API layer
- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids.
- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise).
- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`.
- `.WithSummary(...)` is sentence case without a trailing period.
## Infrastructure and hosts
- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity<T>`, `DeleteEntityActivity<T>`, `UpdatePicturesActivity<T>`).
- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes.
- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's.
- The StreamingGateway references neither Application nor Infrastructure and keeps its own options.
## Tests
- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051).
- Test names are `Method_Scenario_Expected`.
- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness.
- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`.
@@ -1,85 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.UserAlbums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album is null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var localTrack = await database.LocalTracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (localTrack is not null && localTrack.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.Conflict(description: "Track is already in the album.");
}
var albumTracksQuery = database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
var nextTrackNumber = hasTracks
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
: 1;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = request.TrackId,
TrackNumber = nextTrackNumber
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
request.TrackId, request.AlbumId, nextTrackNumber);
return new Success();
}
}
}
@@ -0,0 +1,76 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToAlbumCommandHandler(
IDatabase database,
ILogger<AddTracksToAlbumCommandHandler> logger)
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId })
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var alreadyInAlbum = await database.AlbumHasTracks
.AsNoTracking()
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
var nextTrackNumber = (await database.AlbumHasTracks
.Where(link => link.AlbumId == request.AlbumId)
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
foreach (var trackId in toAdd)
{
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = trackId,
TrackNumber = nextTrackNumber++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
return Result.Success;
}
}
}
@@ -2,14 +2,15 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -18,7 +19,9 @@ namespace Musify.Application.Albums
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear, int? ReleaseYear,
Guid PictureIntentId) Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler( public class CreateAlbumCommandHandler(
@@ -26,9 +29,7 @@ namespace Musify.Application.Albums
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger, ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, AlbumConfiguration albumConfiguration)
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
@@ -37,92 +38,67 @@ namespace Musify.Application.Albums
.AsNoTracking() .AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken); .AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
{ return AppErrors.NotFound("User", request.UserId);
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( var trackIds = (request.TrackIds ?? []).Distinct().ToList();
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); var tracks = await database.Tracks
if (pictureIntent.IsError) .AsNoTracking()
return pictureIntent.Errors; .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
.ToListAsync(cancellationToken);
var intent = pictureIntent.Value; var missing = trackIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
var title = request.Title.Trim(); var title = request.Title.Trim();
var album = new UserAlbum var album = new Album
{ {
OwnerUserId = request.UserId, OwnerUserId = request.UserId,
Title = title, Title = title,
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description, Description = request.Description,
ReleaseYear = request.ReleaseYear, ReleaseYear = request.ReleaseYear,
Pictures = new AlbumPictures Type = request.Type,
{ Pictures = picture.Pictures
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
}; };
await database.UserAlbums.AddAsync(album, cancellationToken); await database.Albums.AddAsync(album, cancellationToken);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); for (var index = 0; index < trackIds.Count; index++)
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{ {
await database.SaveChangesAsync(cancellationToken); await database.AlbumHasTracks.AddAsync(
} new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
catch (Exception exception)
{
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
}
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
}
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken); cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
} }
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet();
var visibleTrackIds = trackIds.Where(published.Contains).ToList();
return AlbumApplicationResponse.FromEntity(
album,
visibleTrackIds.Count,
[.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]);
} }
} }
} }
@@ -1,8 +1,10 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -11,40 +13,25 @@ namespace Musify.Application.Albums
public class DeleteAlbumCommandHandler( public class DeleteAlbumCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus,
ILogger<DeleteAlbumCommandHandler> logger) ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>> : ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.UserAlbums var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (found.IsError)
if (album is null) return found.Errors;
{
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId) var album = found.Value;
{ album.LifeCycleStatus = LifeCycleStatus.Removing;
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
database.UserAlbums.Remove(album); await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
try logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
}
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); return Result.Success;
return new Success();
} }
} }
} }
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -14,25 +15,16 @@ namespace Musify.Application.Albums
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{ {
var album = await database.UserAlbums var album = await database.Albums
.AsNoTracking() .AsNoTracking()
.Active()
.Where(a => a.Id == request.Id) .Where(a => a.Id == request.Id)
.Select(a => new .SelectResponse()
{
Album = a,
TrackCount = a.AlbumTracks.Count,
CoverTrackIds = a.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
if (album is null) return album == null
return Error.NotFound(); ? AppErrors.NotFound("Album", request.Id)
: album;
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
} }
} }
} }
@@ -1,49 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures is null)
return Error.NotFound();
var routes = albumConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -0,0 +1,35 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId)
: IQuery<ErrorOr<AlbumTrackIdsResponse>>;
public class GetAlbumTrackIdsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTrackIdsQuery, ErrorOr<AlbumTrackIdsResponse>>
{
public async ValueTask<ErrorOr<AlbumTrackIdsResponse>> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var trackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId
&& albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
return new AlbumTrackIdsResponse(trackIds);
}
}
}
@@ -1,12 +1,10 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -23,29 +21,19 @@ namespace Musify.Application.Albums
{ {
var albumExists = await database.Albums var albumExists = await database.Albums
.AsNoTracking() .AsNoTracking()
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken); .AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists) if (!albumExists)
return Error.NotFound(); return AppErrors.NotFound("Album", request.AlbumId);
var tracksQuery = database.AlbumHasTracks return await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count }) .ThenBy(albumTrack => albumTrack.Id)
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .Select(albumTrack => albumTrack.Track)
.SelectResponse()
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
} }
} }
} }
@@ -1,113 +1,34 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<ErrorOr<AlbumsSearchResponse>>; : IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler( public class GetAlbumsQueryHandler(IDatabase database)
IDatabase database, : IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
IYouTubeMusicService youTubeMusicService,
ILogger<GetAlbumsQueryHandler> logger)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
{ {
// Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
// still be mid-keystroke to justify a live call to the external search API.
private const int MinTitleLengthForYouTubeSearch = 2;
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{ {
var albumsQuery = database.Albums var albumsQuery = database.Albums
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .Active();
if (!string.IsNullOrEmpty(request.Title)) if (!string.IsNullOrWhiteSpace(request.Title))
{ {
var normalizedTitle = request.Title.Trim().ToUpperInvariant(); var normalizedTitle = TextNormalizer.Normalize(request.Title);
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
} }
var totalCount = await albumsQuery.CountAsync(cancellationToken); return await albumsQuery
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt) .OrderByDescending(album => album.CreatedAt)
.Select(album => new .ThenBy(album => album.Id)
{ .SelectResponse()
Album = album, .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var localItems = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
var knownExternalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in pagedEntities)
{
if (entry.Album is ExternalAlbum { Source: TrackSource.YouTube } external)
knownExternalIds.Add(external.ExternalId);
}
var youtubeItems = new List<AlbumSearchItemResponse>();
string? nextContinuationToken = null;
var youtubeUnavailable = false;
if (request.Title?.Trim().Length >= MinTitleLengthForYouTubeSearch)
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
await SearchYouTubeAsync(request.Title, request.YoutubeContinuationToken, knownExternalIds, cancellationToken);
return AlbumsSearchResponse.Create(
[.. localItems, .. youtubeItems],
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage,
nextContinuationToken,
youtubeUnavailable);
}
private async Task<(List<AlbumSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
string title,
string? continuationToken,
HashSet<string> knownExternalIds,
CancellationToken cancellationToken)
{
var searchResult = await youTubeMusicService.SearchAlbumsAsync(title, continuationToken ?? string.Empty, cancellationToken);
if (searchResult.IsError)
{
logger.LogInformation(
"YouTube album search unavailable while combining album results: {Error}", searchResult.FirstError.Description);
return ([], null, true);
}
var items = searchResult.Value.Items
.Where(album => knownExternalIds.Add(album.AlbumId))
.Select(AlbumSearchItemResponse.FromYouTubeAlbum)
.ToList();
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
? null
: searchResult.Value.ContinuationToken;
return (items, nextToken, false);
} }
} }
} }
@@ -4,8 +4,6 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -21,33 +19,16 @@ namespace Musify.Application.Albums
.AsNoTracking() .AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken); .AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found"); return AppErrors.NotFound("User", request.UserId);
var albumsQuery = database.UserAlbums return await database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => album.OwnerUserId == request.UserId); .Active()
.Where(album => album.OwnerUserId == request.UserId)
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt) .OrderByDescending(album => album.CreatedAt)
.Select(album => new .ThenBy(album => album.Id)
{ .SelectResponse()
Album = album, .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
} }
} }
} }
@@ -1,4 +1,3 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
@@ -7,54 +6,47 @@ using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse> >>; : IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> : IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{ {
var listenedTrackIds = database.ListeningHistories var listens = database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == request.UserId); .Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join( .Join(
listenedTrackIds, listens,
albumTrack => albumTrack.TrackId, albumTrack => albumTrack.TrackId,
history => history.TrackId, history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt } ) (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId) .GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) } ) .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt) .OrderByDescending(entry => entry.LastListenedAt)
.ThenBy(entry => entry.AlbumId)
.Take(request.Limit) .Take(request.Limit)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (ranked.Count == 0) if (ranked.Count == 0)
return new List<AlbumApplicationResponse>(); return [];
var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albums = await database.Albums var albumsById = await database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => albumIds.Contains(album.Id)) .Where(album => albumIds.Contains(album.Id))
.Select(album => new .SelectResponse()
{ .ToDictionaryAsync(album => album.Id, cancellationToken);
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
} )
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId)) .Where(entry => albumsById.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId]) .Select(entry => albumsById[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList(); .ToList();
} }
} }
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -16,20 +17,9 @@ namespace Musify.Application.Albums
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.UserAlbums var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
.AsNoTracking() if (album.IsError)
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); return album.Errors;
if (album is null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var albumTracks = await database.AlbumHasTracks var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
@@ -37,11 +27,8 @@ namespace Musify.Application.Albums
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link is null) if (link == null)
{ return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
database.AlbumHasTracks.Remove(link); database.AlbumHasTracks.Remove(link);
@@ -53,7 +40,7 @@ namespace Musify.Application.Albums
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return new Success(); return Result.Success;
} }
} }
} }
@@ -1,13 +1,9 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Albums.Responses; using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -16,90 +12,14 @@ namespace Musify.Application.Albums
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler( public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
IDatabase database, : ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
{ await issuer.IssuePictureAsync(
var userExists = await database.Users request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
.AsNoTracking() request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.AlbumPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
return new AlbumPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
} }
} }
@@ -1,17 +1,8 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses
{ {
// [JsonPolymorphic]/[JsonDerivedType] are required here, not optional: without them
// System.Text.Json serializes strictly by the declared CLR type at each call site
// (AlbumApplicationResponse, both as a bare return type and nested inside
// AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse instance would
// silently lose ExternalId/ThumbnailUrl on the wire. This also gives every response a
// "source" discriminator field for free, matching AlbumSearchItemResponse.Source.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
[JsonDerivedType(typeof(AlbumApplicationResponse), typeDiscriminator: "Local")]
[JsonDerivedType(typeof(ExternalAlbumApplicationResponse), typeDiscriminator: "YouTube")]
public record AlbumApplicationResponse( public record AlbumApplicationResponse(
Guid Id, Guid Id,
string Title, string Title,
@@ -24,12 +15,13 @@ namespace Musify.Application.Albums.Responses
string? LargeImageKeyName, string? LargeImageKeyName,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds,
AlbumType Type)
{ {
public const int CoverTrackCount = 4; public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public static AlbumApplicationResponse FromEntity( public static AlbumApplicationResponse FromEntity(
UserAlbum album, Album album,
int trackCount, int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null) IReadOnlyList<Guid>? coverTrackIds = null)
{ {
@@ -45,20 +37,8 @@ namespace Musify.Application.Albums.Responses
album.Pictures?.LargeName, album.Pictures?.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? [],
} album.Type);
public static AlbumApplicationResponse FromEntity(
Album album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return album switch
{
ExternalAlbum externalAlbum => ExternalAlbumApplicationResponse.FromEntity(externalAlbum, trackCount, coverTrackIds),
UserAlbum userAlbum => FromEntity(userAlbum, trackCount, coverTrackIds),
_ => throw new NotSupportedException($"Unsupported album entity type '{album.GetType().Name}'.")
};
} }
} }
} }
@@ -0,0 +1,34 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses
{
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
query.Select(album => new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList(),
album.Type));
}
}
@@ -1,22 +0,0 @@
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses
{
/// <summary>One item of a combined local/external album listing. Exactly one of
/// <see cref="Album"/> or <see cref="YouTubeAlbum"/> is populated: <see cref="Album"/> for
/// anything already in the catalog (locally created albums and external albums materialized
/// earlier), <see cref="YouTubeAlbum"/> for a live external search hit that hasn't been
/// materialized yet, so it has no album id.</summary>
public record AlbumSearchItemResponse(
TrackSource Source,
AlbumApplicationResponse? Album,
YouTubeAlbumResult? YouTubeAlbum)
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) =>
new(album is ExternalAlbumApplicationResponse ? TrackSource.YouTube : TrackSource.Local, album, null);
public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) =>
new(TrackSource.YouTube, null, album);
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
}
@@ -1,38 +0,0 @@
namespace Musify.Application.Albums.Responses
{
/// <summary>Result of a combined albums listing/search: a page of local catalog albums
/// (user-created and previously materialized external albums), plus, when a search term was
/// given, live external hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
/// the local page — the external source has no stable total, so its continuation is tracked
/// separately via <see cref="NextYoutubeContinuationToken"/>.</summary>
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextLocalPage,
string? NextYoutubeContinuationToken,
bool YoutubeUnavailable,
bool HasNextPage)
{
// HasNextPage is a real field (not a computed property) so it shows up as a required member
// of the generated OpenAPI schema, same as every other field here — a get-only property added
// outside the primary constructor gets reflected as optional instead, which is never true here.
public static AlbumsSearchResponse Create(
IReadOnlyList<AlbumSearchItemResponse> items,
int pageNumber,
int pageSize,
int pageCount,
int totalItemCount,
bool hasPreviousPage,
bool hasNextLocalPage,
string? nextYoutubeContinuationToken,
bool youtubeUnavailable) =>
new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage,
nextYoutubeContinuationToken, youtubeUnavailable,
HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken));
}
}
@@ -1,47 +0,0 @@
using Musify.Domain.Entities;
namespace Musify.Application.Albums.Responses
{
public record ExternalAlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds,
string ExternalId,
string? ThumbnailUrl)
: AlbumApplicationResponse(
Id, Title, Description, ReleaseYear, OwnerUserId, TrackCount,
SmallImageKeyName, MediumImageKeyName, LargeImageKeyName,
CreatedAt, UpdatedAt, CoverTrackIds)
{
public static ExternalAlbumApplicationResponse FromEntity(
ExternalAlbum album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new ExternalAlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
0,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? [],
album.ExternalId,
album.ThumbnailUrl);
}
}
}
@@ -2,14 +2,10 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -19,134 +15,38 @@ namespace Musify.Application.Albums
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear, int? ReleaseYear,
Guid? NewPictureIntentId) AlbumType? Type = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler( public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, ILogger<UpdateAlbumCommandHandler> logger)
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.UserAlbums var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (found.IsError)
if (album is null) return found.Errors;
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var album = found.Value;
var title = request.Title.Trim(); var title = request.Title.Trim();
album.Title = title; album.Title = title;
album.NormalizedTitle = title.ToUpperInvariant(); album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = request.Description; album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.ReleaseYear = request.ReleaseYear; album.ReleaseYear = request.ReleaseYear;
album.Type = request.Type ?? album.Type;
UploadIntent? pictureIntent = null; await database.SaveChangesAsync(cancellationToken);
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
album.Pictures = new AlbumPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
database.UserAlbums.Update(album);
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id); logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync( return await database.Albums
Guid albumId, .AsNoTracking()
UploadIntent pictureIntent, .Where(a => a.Id == album.Id)
string finalPictureKey, .SelectResponse()
CancellationToken cancellationToken) .SingleAsync(cancellationToken);
{
try
{
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
}
} }
} }
} }
@@ -0,0 +1,59 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumPictureCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumPictureCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumPictureCommand request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = found.Value;
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
album.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated picture of album {AlbumId}", album.Id);
return await database.Albums
.AsNoTracking()
.Where(a => a.Id == album.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
}
}
}
@@ -19,8 +19,6 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" /> <PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" /> <PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -3,26 +3,27 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Services; using Musify.Application.Services;
namespace Musify.Application; namespace Musify.Application
public static class ApplicationDependencyInjection
{ {
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) public static class ApplicationDependencyInjection
{ {
services.AddMediator(); public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
services.AddScoped<UploadIntentValidator>(); {
services.AddScoped<TrackStreamIssuer>(); services.AddMediator();
services.AddScoped<YouTubeTrackProvisioner>(); services.AddScoped<UploadIntentValidator>();
services.AddScoped<UploadIntentIssuer>();
services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions<PlayListConfiguration>(configuration);
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName); services.AddValidatedOptions<AlbumConfiguration>(configuration);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions<TrackConfiguration>(configuration);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName); services.AddValidatedOptions<MixConfiguration>(configuration);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName); services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName); services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName); services.AddValidatedOptions<PlaybackConfiguration>(configuration);
return services; return services;
}
} }
} }
@@ -1,4 +1,4 @@
using Mediator; using Mediator;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] [assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -0,0 +1,35 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Coauthors.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record GetCoauthorInvitationsQuery(long UserId, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<CoauthorInvitationResponse>>;
public class GetCoauthorInvitationsQueryHandler(IDatabase database)
: IQueryHandler<GetCoauthorInvitationsQuery, PaginatedResponse<CoauthorInvitationResponse>>
{
public async ValueTask<PaginatedResponse<CoauthorInvitationResponse>> Handle(GetCoauthorInvitationsQuery request, CancellationToken cancellationToken)
{
return await database.TrackCoauthors
.AsNoTracking()
.Where(coauthor => coauthor.UserId == request.UserId
&& coauthor.Status == CoauthorStatus.Pending
&& coauthor.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderByDescending(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.Id)
.Select(coauthor => new CoauthorInvitationResponse(
coauthor.Id,
coauthor.TrackId,
coauthor.Track.Title,
coauthor.Track.Owner.Name,
coauthor.Track.OwnerUserId,
coauthor.InvitedAt))
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -0,0 +1,84 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record InviteCoauthorsCommand(long OwnerId, Guid TrackId, IReadOnlyList<long> UserIds)
: ICommand<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>;
public class InviteCoauthorsCommandHandler(
IDatabase database,
ILogger<InviteCoauthorsCommandHandler> logger)
: ICommandHandler<InviteCoauthorsCommand, ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>> Handle(InviteCoauthorsCommand request, CancellationToken cancellationToken)
{
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.OwnerId, cancellationToken);
if (found.IsError)
return found.Errors;
var userIds = request.UserIds.Distinct().ToList();
if (userIds.Contains(request.OwnerId))
return Error.Validation("Coauthor.Self", "You cannot invite yourself as a coauthor.");
var existingUserIds = await database.Users
.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.Select(user => user.Id)
.ToListAsync(cancellationToken);
var missing = userIds.Where(id => !existingUserIds.Contains(id)).Select(id => (long?)id).FirstOrDefault();
if (missing != null)
return AppErrors.NotFound("User", missing.Value);
var existing = await database.TrackCoauthors
.Where(coauthor => coauthor.TrackId == request.TrackId && userIds.Contains(coauthor.UserId))
.ToListAsync(cancellationToken);
var alreadyInvited = existing.FirstOrDefault(coauthor => coauthor.Status != CoauthorStatus.Rejected);
if (alreadyInvited != null)
return AppErrors.Conflict("Coauthor.AlreadyInvited", $"User {alreadyInvited.UserId} was already invited to this track.");
var now = DateTime.UtcNow;
foreach (var rejected in existing)
{
rejected.Status = CoauthorStatus.Pending;
rejected.InvitedAt = now;
rejected.RespondedAt = null;
}
var reinvitedIds = existing.Select(coauthor => coauthor.UserId).ToHashSet();
foreach (var userId in userIds.Where(id => !reinvitedIds.Contains(id)))
{
await database.TrackCoauthors.AddAsync(new TrackCoauthor
{
TrackId = request.TrackId,
UserId = userId,
InvitedAt = now
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Coauthor.AlreadyInvited", "A user was already invited to this track."), cancellationToken);
if (saved.IsError)
return saved.Errors;
logger.LogInformation("Owner {OwnerId} invited {Count} coauthors to track {TrackId}", request.OwnerId, userIds.Count, request.TrackId);
return await database.TrackCoauthors
.AsNoTracking()
.Where(coauthor => coauthor.TrackId == request.TrackId)
.OrderBy(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.UserId)
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
.ToListAsync(cancellationToken);
}
}
}
@@ -0,0 +1,47 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record RemoveCoauthorCommand(long UserId, Guid TrackId, long CoauthorUserId)
: ICommand<ErrorOr<Success>>;
public class RemoveCoauthorCommandHandler(
IDatabase database,
ILogger<RemoveCoauthorCommandHandler> logger)
: ICommandHandler<RemoveCoauthorCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveCoauthorCommand request, CancellationToken cancellationToken)
{
var ownerUserId = await database.Tracks
.AsNoTracking()
.Where(track => track.Id == request.TrackId && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => (long?)track.OwnerUserId)
.SingleOrDefaultAsync(cancellationToken);
if (ownerUserId == null)
return AppErrors.NotFound("Track", request.TrackId);
if (ownerUserId != request.UserId && request.CoauthorUserId != request.UserId)
return AppErrors.Forbidden("Track", request.TrackId);
var coauthor = await database.TrackCoauthors
.SingleOrDefaultAsync(c => c.TrackId == request.TrackId && c.UserId == request.CoauthorUserId, cancellationToken);
if (coauthor == null)
return AppErrors.NotFound("Coauthor", request.CoauthorUserId);
database.TrackCoauthors.Remove(coauthor);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} removed coauthor {CoauthorUserId} from track {TrackId}",
request.UserId, request.CoauthorUserId, request.TrackId);
return Result.Success;
}
}
}
@@ -0,0 +1,44 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record RespondToCoauthorInvitationCommand(long UserId, Guid InvitationId, bool Accept)
: ICommand<ErrorOr<Success>>;
public class RespondToCoauthorInvitationCommandHandler(
IDatabase database,
ILogger<RespondToCoauthorInvitationCommandHandler> logger)
: ICommandHandler<RespondToCoauthorInvitationCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RespondToCoauthorInvitationCommand request, CancellationToken cancellationToken)
{
var invitation = await database.TrackCoauthors
.Include(coauthor => coauthor.Track)
.SingleOrDefaultAsync(coauthor => coauthor.Id == request.InvitationId, cancellationToken);
if (invitation == null || invitation.Track.LifeCycleStatus != LifeCycleStatus.Active)
return AppErrors.NotFound("CoauthorInvitation", request.InvitationId);
if (invitation.UserId != request.UserId)
return AppErrors.Forbidden("CoauthorInvitation", request.InvitationId);
if (invitation.Status != CoauthorStatus.Pending)
return AppErrors.Conflict("CoauthorInvitation.NotPending", "The invitation was already answered.");
invitation.Status = request.Accept ? CoauthorStatus.Accepted : CoauthorStatus.Rejected;
invitation.RespondedAt = DateTime.UtcNow;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} coauthor invitation {InvitationId}",
request.UserId, request.Accept ? "accepted" : "rejected", request.InvitationId);
return Result.Success;
}
}
}
@@ -0,0 +1,10 @@
namespace Musify.Application.Coauthors.Responses
{
public record CoauthorInvitationResponse(
Guid Id,
Guid TrackId,
string TrackTitle,
string? Artist,
long OwnerUserId,
DateTime InvitedAt);
}
@@ -1,79 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class AlbumConfiguration public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public const string SectionName = "Album"; public static string SectionName => "Album";
[Required] [Required]
public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
[Required] [Required]
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); public PictureSizes PicturesSizes { get; set; } = new();
}
public class AlbumRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "Albums";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class AlbumPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
} }
} }
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class ApplicationStorageConfiguration public class ApplicationStorageConfiguration : IConfigurationOptions
{ {
public const string SectionName = "ApplicationStorage"; public static string SectionName => "ApplicationStorage";
[Required] [Required]
[StringLength(128, MinimumLength = 1)] [StringLength(128, MinimumLength = 1)]
@@ -2,24 +2,29 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public static class ConfigurationDependencyInjection
{ {
public static IServiceCollection AddValidatedOptions<T>( public static class ConfigurationDependencyInjection
this IServiceCollection services,
IConfiguration configuration,
string sectionName)
where T : class
{ {
services public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
.AddOptionsWithValidateOnStart<T>() where T : class, IConfigurationOptions =>
.Bind(configuration.GetRequiredSection(sectionName)) services.AddValidatedOptions<T>(configuration, T.SectionName);
.ValidateDataAnnotations();
services.AddSingleton(serviceProvider => public static IServiceCollection AddValidatedOptions<T>(
serviceProvider.GetRequiredService<IOptions<T>>().Value); this IServiceCollection services,
IConfiguration configuration,
string sectionName)
where T : class
{
services
.AddOptionsWithValidateOnStart<T>()
.Bind(configuration.GetRequiredSection(sectionName))
.ValidateDataAnnotations();
return services; services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<T>>().Value);
return services;
}
} }
} }
@@ -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)
@@ -0,0 +1,7 @@
namespace Musify.Application.Configuration
{
public interface IConfigurationOptions
{
public static abstract string SectionName { get; }
}
}
@@ -2,20 +2,11 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class MixConfiguration public class MixConfiguration : IConfigurationOptions
{ {
public const string SectionName = "Mix"; public static string SectionName => "Mix";
[Range(1, 20)]
public int ArtistMixCount { get; set; } = 3;
[Range(4, 100)] [Range(4, 100)]
public int ItemsPerMix { get; set; } = 12; public int ItemsPerMix { get; set; } = 12;
[Range(1, 10)]
public int SeedArtistCount { get; set; } = 3;
[Range(1, 100)]
public int YouTubeResultsPerSeed { get; set; } = 12;
} }
} }
@@ -0,0 +1,91 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public enum PictureSize
{
Small,
Medium,
Large
}
public class PictureRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = string.Empty;
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
{
PictureSize.Small => SmallPicturesFolder,
PictureSize.Medium => MediumPicturesFolder,
PictureSize.Large => LargePicturesFolder,
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
});
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
}
public class PictureSizes
{
[Range(1, 1024)]
public int SmallWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargeWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargeHeight { get; set; } = 512;
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
}
public interface IPictureOwnerConfiguration
{
public PictureRoutes Routes { get; }
public PictureSizes PicturesSizes { get; }
}
public static class PictureSizeParser
{
public static PictureSize Parse(string? value) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size
: PictureSize.Medium;
}
}
@@ -1,101 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class PlayListConfiguration public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public const string SectionName = "PlayList"; public static string SectionName => "PlayList";
[Required] [Required]
public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
[Required] [Required]
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); public PictureSizes PicturesSizes { get; set; } = new();
public bool SeedPresetPictures { get; set; } = true;
public bool OverwritePresetPictures { get; set; } = false;
} }
}
public class PlayListRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "PlayLists";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
[Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
[Required]
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
[Required]
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
[Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, SmallPicturesFolder, PresetSmallPicture);
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, MediumPicturesFolder, PresetMediumPicture);
public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, LargePicturesFolder, PresetLargePicture);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class PlayListPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -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 const string SectionName = "Playback";
/// <summary>When false (the default), every stream endpoint requires a signed-in user, exactly public sealed class PlaybackConfiguration : IConfigurationOptions
/// 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 static string SectionName => "Playback";
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,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class StreamGatewayConfiguration public class StreamGatewayConfiguration : IConfigurationOptions
{ {
public const string SectionName = "StreamGateway"; public static string SectionName => "StreamGateway";
[Url] [Url]
[Required] [Required]
@@ -1,38 +1,28 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class TrackConfiguration public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public const string SectionName = "Track"; public static string SectionName => "Track";
[Required] [Required]
public TrackRoutes Routes { get; set; } = new TrackRoutes(); public TrackRoutes Routes { get; set; } = new();
[Required] [Required]
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); public PictureSizes PicturesSizes { get; set; } = new();
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
} }
public class TrackRoutes public class TrackRoutes : PictureRoutes
{ {
[Required] public TrackRoutes()
public string UploadsFolder { get; set; } = "uploads"; {
ParentFolder = "Tracks";
[Required] }
public string ParentFolder { get; set; } = "Tracks";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
[Required] [Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
@@ -50,13 +40,7 @@ namespace Musify.Application.Configuration
public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required] [Required]
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
@@ -64,53 +48,10 @@ namespace Musify.Application.Configuration
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); public string BuildOriginalAudioPath(long userId, string audioName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); public string BuildProcessedAudioPath(string folderName) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
} }
}
public class TrackPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -2,18 +2,18 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration
{ {
public class UploadIntentConfiguration public class UploadIntentConfiguration : IConfigurationOptions
{ {
public const string SectionName = "UploadIntent"; public static string SectionName => "UploadIntent";
[Range(10, 3600)] [Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120; public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)] [Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5; public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
@@ -31,12 +31,12 @@ namespace Musify.Application.Configuration
public int ExpiredIntentsRetentionDays { get; set; } = 7; public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)] [Required, MinLength(1)]
public string TemporalRootPrefix { get; set; } = "temporal"; public string TempRootPrefix { get; set; } = "temp";
[Range(1, 30)] [Range(1, 30)]
public int TemporalUploadsRetentionDays { get; set; } = 3; public int TempUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)] [Range(60, 86400)]
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
} }
} }
@@ -2,8 +2,6 @@ namespace Musify.Application.Contracts
{ {
public interface IAudioTranscoderService public interface IAudioTranscoderService
{ {
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
} }
} }
@@ -1,3 +1,4 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
@@ -5,44 +6,40 @@ namespace Musify.Application.Contracts
{ {
public interface IDatabase public interface IDatabase
{ {
DbSet<User> Users { get; } public DbSet<User> Users { get; }
DbSet<PlayList> PlayLists { get; } public DbSet<PlayList> PlayLists { get; }
DbSet<Album> Albums { get; } public DbSet<Album> Albums { get; }
DbSet<UserAlbum> UserAlbums { get; } public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
DbSet<ExternalAlbum> ExternalAlbums { get; } public DbSet<Track> Tracks { get; }
DbSet<AlbumHasTrack> AlbumHasTracks { get; } public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
DbSet<Track> Tracks { get; } public DbSet<Mix> Mixes { get; }
DbSet<LocalTrack> LocalTracks { get; } public DbSet<MixItem> MixItems { get; }
DbSet<ExternalTrack> ExternalTracks { get; } public DbSet<UploadIntent> UploadIntents { get; }
DbSet<Artist> Artists { get; } public DbSet<ListeningHistory> ListeningHistories { get; }
DbSet<TrackArtist> TrackArtists { get; } public DbSet<TrackLike> TrackLikes { get; }
DbSet<UserHasTrack> UserHasTracks { get; } public DbSet<TrackTag> TrackTags { get; }
DbSet<PlayListHasTrack> PlayListHasTracks { get; } public DbSet<UserFollow> UserFollows { get; }
DbSet<Mix> Mixes { get; } public DbSet<TrackCoauthor> TrackCoauthors { get; }
DbSet<MixItem> MixItems { get; } public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
DbSet<Upload> Uploads { get; } public DbSet<TEntity> Set<TEntity>() where TEntity : class;
DbSet<UploadIntent> UploadIntents { get; } public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
DbSet<ListeningHistory> ListeningHistories { get; } public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
} }
} }
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
{ {
public interface IDatabaseTransaction : IAsyncDisposable public interface IDatabaseTransaction : IAsyncDisposable
{ {
Task CommitAsync(CancellationToken cancellationToken = default); public Task CommitAsync(CancellationToken cancellationToken = default);
} }
} }
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
{ {
public interface IEventBus public interface IEventBus
{ {
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class; public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
} }
} }
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
{ {
public interface IPictureService public interface IPictureService
{ {
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
} }
} }
@@ -2,27 +2,23 @@ namespace Musify.Application.Contracts
{ {
public interface IStorageService public interface IStorageService
{ {
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken); public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
} }
} }
@@ -2,12 +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 public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
/// <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);
} }
public record StreamTicket(string Token, int ExpiresInSeconds); public record StreamTicket(string Token, int ExpiresInSeconds);
@@ -1,9 +0,0 @@
namespace Musify.Application.Contracts
{
public record YouTubeDownloadResult(int ExitCode, string StandardError);
public interface IYouTubeDownloaderService
{
Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken);
}
}
@@ -1,64 +0,0 @@
using ErrorOr;
namespace Musify.Application.Contracts
{
public record YouTubeArtistRef(string? Id, string Name);
public record YouTubeSongResult(
string VideoId,
string Title,
string Artist,
string Album,
int DurationSeconds,
string ThumbnailUrl,
bool IsExplicit,
IReadOnlyList<YouTubeArtistRef> Artists);
public record YouTubeSearchResult(
IReadOnlyList<YouTubeSongResult> Items,
string ContinuationToken);
public record YouTubeAlbumResult(
string AlbumId,
string Title,
string Artist,
string ThumbnailUrl,
int? ReleaseYear,
bool IsSingle,
bool IsEp,
IReadOnlyList<YouTubeArtistRef> Artists);
public record YouTubeAlbumSearchResult(
IReadOnlyList<YouTubeAlbumResult> Items,
string ContinuationToken);
public record YouTubeAlbumTrack(
string VideoId,
string Title,
int DurationSeconds,
int TrackNumber,
bool IsExplicit);
public record YouTubeAlbumDetail(
YouTubeAlbumResult Album,
string Description,
int TotalDurationSeconds,
IReadOnlyList<YouTubeAlbumTrack> Tracks);
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
public interface IYouTubeMusicService
{
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
string ResolveArtworkUrl(string thumbnailUrl);
}
}
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
} }
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
} }
@@ -4,15 +4,13 @@ namespace Musify.Application.Events
{ {
public record CreateTrackResourcesEvent( public record CreateTrackResourcesEvent(
Guid TrackId, Guid TrackId,
Guid PictureIntentId, Guid? PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string? PictureSourceKey,
string PictureDestinationKey, string? PictureDestinationKey,
string AudioSourceKey, string AudioSourceKey,
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
} }
@@ -0,0 +1,6 @@
namespace Musify.Application.Events
{
public record DeleteAlbumEvent(
Guid AlbumId,
long UserId);
}
@@ -1,14 +0,0 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record DownloadYouTubeTrackEvent(
Guid TrackId,
string VideoId,
string ThumbnailUrl,
string Bucket,
string AudioProcessedFolderKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -6,7 +6,5 @@ namespace Musify.Application.Events
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
} }
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
} }
@@ -1,12 +1,10 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events
{ {
public record UpdatePlayListPictureEvent( public record UpdatePlayListPictureEvent(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium, }
ImageSize Large);
}
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
} }
@@ -1,4 +1,4 @@
namespace Musify.Application.Events namespace Musify.Application.Events
{ {
public record UpdateTrackAudioEvent( public record UpdateTrackAudioEvent(
Guid TrackId, Guid TrackId,
@@ -6,4 +6,4 @@
string SourceKey, string SourceKey,
string DestinationBucket, string DestinationBucket,
string DestinationFolderKey); string DestinationFolderKey);
} }
@@ -1,12 +1,10 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events
{ {
public record UpdateTrackPictureEvent( public record UpdateTrackPictureEvent(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium, }
ImageSize Large);
}
@@ -0,0 +1,25 @@
using Mediator;
using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -0,0 +1,30 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -0,0 +1,6 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses
{
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
}
@@ -0,0 +1,6 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses
{
public record GenreResponse(Genre Genre, int TrackCount);
}
@@ -0,0 +1,31 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes
{
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
return await database.TrackLikes
.AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
.OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id)
.Select(like => like.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -0,0 +1,58 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId);
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
if (saved.IsError)
return true;
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
}
}
}
@@ -1,287 +1,93 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes
{ {
public record GenerateMixesForUserCommand(long UserId) public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>; : ICommand;
public class GenerateMixesForUserCommandHandler( public class GenerateMixesForUserCommandHandler(
IDatabase database, IDatabase database,
IYouTubeMusicService youTubeMusicService,
MixConfiguration mixConfiguration, MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger) ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>> : ICommandHandler<GenerateMixesForUserCommand>
{ {
private sealed record SeedArtist(Guid Id, string Name); private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds); public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<MixItem> Items);
private const int TitleLength = 300;
private const int ArtistLength = 300;
private const int ThumbnailUrlLength = 512;
private const int MixTitleLength = 100;
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
{ {
var userId = request.UserId; var userId = request.UserId;
var libraryTrackIds = await database.Tracks
.AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
{
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return Unit.Value;
}
var historyTrackIds = await database.ListeningHistories var historyTrackIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == userId) .Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId) .Select(history => history.TrackId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (historyTrackIds.Count == 0) var historySet = new HashSet<Guid>(historyTrackIds);
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{ {
logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId); drafts.Add(new MixDraft(MixKind.Discovery, discovery));
return new Success();
} }
var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken); var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
if (seedArtists.Count == 0)
{ {
logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId); drafts.Add(new MixDraft(MixKind.Daily, daily));
return new Success();
} }
var knownVideoIds = await database.ExternalTracks
.AsNoTracking()
.Where(track => track.Source == TrackSource.YouTube && historyTrackIds.Contains(track.Id))
.Select(track => track.ExternalId)
.ToListAsync(cancellationToken);
var excludedVideoIds = new HashSet<string>(knownVideoIds, StringComparer.OrdinalIgnoreCase);
var youTubeByArtist = await SearchYouTubeAsync(seedArtists, excludedVideoIds, cancellationToken);
var localByArtist = await GetLocalCandidatesAsync(seedArtists, historyTrackIds, cancellationToken);
var drafts = BuildDrafts(seedArtists, localByArtist, youTubeByArtist);
if (drafts.Count == 0) if (drafts.Count == 0)
{ {
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return new Success(); return Unit.Value;
} }
await ReplaceMixesAsync(userId, drafts, cancellationToken); await ReplaceMixesAsync(userId, drafts, cancellationToken);
try await database.SaveChangesAsync(cancellationToken);
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return new Success(); return Unit.Value;
} }
private async Task<List<SeedArtist>> GetSeedArtistsAsync( private List<Guid> Take(IEnumerable<Guid> trackIds) =>
IReadOnlyList<Guid> historyTrackIds, [.. trackIds.Take(mixConfiguration.ItemsPerMix)];
CancellationToken cancellationToken)
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
{ {
var ranked = await database.TrackArtists var merged = new List<Guid>(first.Count + second.Count);
.AsNoTracking()
.Where(trackArtist => historyTrackIds.Contains(trackArtist.TrackId))
.GroupBy(trackArtist => trackArtist.ArtistId)
.Select(group => new { ArtistId = group.Key, Listens = group.Count() } )
.OrderByDescending(entry => entry.Listens)
.Take(mixConfiguration.SeedArtistCount)
.ToListAsync(cancellationToken);
var artistIds = ranked.Select(entry => entry.ArtistId).ToList();
var names = await database.Artists
.AsNoTracking()
.Where(artist => artistIds.Contains(artist.Id))
.ToDictionaryAsync(artist => artist.Id, artist => artist.Name, cancellationToken);
return [.. ranked
.Where(entry => names.ContainsKey(entry.ArtistId))
.Select(entry => new SeedArtist(entry.ArtistId, names[entry.ArtistId])) ];
}
private async Task<Dictionary<Guid, List<MixItem>> > SearchYouTubeAsync(
IReadOnlyList<SeedArtist> seedArtists,
HashSet<string> excludedVideoIds,
CancellationToken cancellationToken)
{
var byArtist = new Dictionary<Guid, List<MixItem>>();
foreach (var seed in seedArtists)
{
var searchResult = await youTubeMusicService.SearchSongsAsync(seed.Name, string.Empty, cancellationToken);
if (searchResult.IsError)
{
logger.LogWarning("YouTube Music search failed for seed artist {Artist}: {Error}",
seed.Name, searchResult.FirstError.Description);
byArtist[seed.Id] = [];
continue;
}
var songs = new List<MixItem>();
foreach (var song in searchResult.Value.Items.Take(mixConfiguration.YouTubeResultsPerSeed))
{
if (!excludedVideoIds.Add(song.VideoId))
continue;
songs.Add(new MixItem
{
MixId = Guid.Empty,
Source = MixItemSource.YouTube,
VideoId = song.VideoId,
Title = Truncate(song.Title, TitleLength),
Artist = Truncate(song.Artist, ArtistLength),
ThumbnailUrl = Truncate(youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl), ThumbnailUrlLength),
DurationSeconds = song.DurationSeconds,
IsExplicit = song.IsExplicit
} );
}
byArtist[seed.Id] = songs;
}
return byArtist;
}
private async Task<Dictionary<Guid, List<MixItem>> > GetLocalCandidatesAsync(
IReadOnlyList<SeedArtist> seedArtists,
IReadOnlyList<Guid> historyTrackIds,
CancellationToken cancellationToken)
{
var artistIds = seedArtists.Select(seed => seed.Id).ToList();
var links = await database.TrackArtists
.AsNoTracking()
.Where(trackArtist => artistIds.Contains(trackArtist.ArtistId) && !historyTrackIds.Contains(trackArtist.TrackId))
.Select(trackArtist => new { trackArtist.ArtistId, trackArtist.TrackId } )
.ToListAsync(cancellationToken);
var trackIds = links.Select(link => link.TrackId).Distinct().ToList();
var candidates = await LoadCandidatesAsync(trackIds, cancellationToken);
var byArtist = new Dictionary<Guid, List<MixItem>>();
foreach (var seed in seedArtists)
{
byArtist[seed.Id] = [.. links
.Where(link => link.ArtistId == seed.Id)
.Select(link => candidates.GetValueOrDefault(link.TrackId))
.Where(candidate => candidate is not null)
.Select(candidate => ToMixItem(candidate!))];
}
return byArtist;
}
private async Task<Dictionary<Guid, LocalCandidate>> LoadCandidatesAsync(
IReadOnlyList<Guid> trackIds,
CancellationToken cancellationToken)
{
if (trackIds.Count == 0)
return [];
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Include(track => ((ExternalTrack)track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(track => ((LocalTrack)track).Owner)
.ToListAsync(cancellationToken);
return tracks
.Select(track => TrackApplicationResponse.FromEntity(track, 0))
.ToDictionary(
response => response.Id,
response => new LocalCandidate(response.Id, response.Title, response.Artist, response.Duration));
}
private static MixItem ToMixItem(LocalCandidate candidate) => new()
{
MixId = Guid.Empty,
Source = MixItemSource.Musify,
TrackId = candidate.TrackId,
Title = candidate.Title,
Artist = candidate.Artist,
DurationSeconds = candidate.DurationSeconds
};
private List<MixDraft> BuildDrafts(
IReadOnlyList<SeedArtist> seedArtists,
IReadOnlyDictionary<Guid, List<MixItem>> localByArtist,
IReadOnlyDictionary<Guid, List<MixItem>> youTubeByArtist)
{
var drafts = new List<MixDraft>();
var everyYouTube = seedArtists.SelectMany(seed => youTubeByArtist[seed.Id]).ToList();
var everyLocal = seedArtists.SelectMany(seed => localByArtist[seed.Id]).ToList();
var discovery = Take(Shuffle(everyYouTube));
if (discovery.Count > 0)
{
drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones nuevas que todavía no has escuchado.",
discovery));
}
var daily = Take(Interleave(Shuffle(everyLocal), Shuffle(everyYouTube)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa nueva.",
daily));
}
foreach (var seed in seedArtists.Take(mixConfiguration.ArtistMixCount))
{
var items = Take(Interleave(Shuffle(localByArtist[seed.Id]), Shuffle(youTubeByArtist[seed.Id])));
if (items.Count == 0)
continue;
drafts.Add(new MixDraft(
Truncate(seed.Name, MixTitleLength),
$"Canciones de {seed.Name} y de artistas parecidos.",
items));
}
return drafts;
}
private List<MixItem> Take(IEnumerable<MixItem> items) =>
[.. items.Take(mixConfiguration.ItemsPerMix)];
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
private static List<MixItem> Shuffle(IEnumerable<MixItem> items) =>
[.. items.OrderBy(_ => Guid.NewGuid())];
private static List<MixItem> Interleave(IReadOnlyList<MixItem> first, IReadOnlyList<MixItem> second)
{
var merged = new List<MixItem>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++) for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
{ {
if (index < first.Count) if (index < first.Count)
merged.Add(first[index] ); merged.Add(first[index]);
if (index < second.Count) if (index < second.Count)
merged.Add(second[index] ); merged.Add(second[index]);
} }
return merged; return merged;
@@ -295,12 +101,6 @@ namespace Musify.Application.Mixes
if (previousMixes.Count > 0) if (previousMixes.Count > 0)
{ {
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
var previousItems = await database.MixItems
.Where(item => previousIds.Contains(item.MixId))
.ToListAsync(cancellationToken);
database.MixItems.RemoveRange(previousItems);
database.Mixes.RemoveRange(previousMixes); database.Mixes.RemoveRange(previousMixes);
} }
@@ -310,28 +110,19 @@ namespace Musify.Application.Mixes
var mix = new Mix var mix = new Mix
{ {
UserId = userId, UserId = userId,
Title = draft.Title, Kind = draft.Kind,
Subtitle = draft.Subtitle,
Position = position Position = position
}; };
await database.Mixes.AddAsync(mix, cancellationToken); await database.Mixes.AddAsync(mix, cancellationToken);
for (var itemPosition = 0; itemPosition < draft.Items.Count; itemPosition++) for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
{ {
var item = draft.Items[itemPosition];
await database.MixItems.AddAsync(new MixItem await database.MixItems.AddAsync(new MixItem
{ {
MixId = mix.Id, MixId = mix.Id,
Position = itemPosition, Position = itemPosition,
Source = item.Source, TrackId = draft.TrackIds[itemPosition]
TrackId = item.TrackId,
VideoId = item.VideoId,
Title = item.Title,
Artist = item.Artist,
ThumbnailUrl = item.ThumbnailUrl,
DurationSeconds = item.DurationSeconds,
IsExplicit = item.IsExplicit
}, cancellationToken); }, cancellationToken);
} }
} }
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes
{ {
@@ -19,12 +20,13 @@ namespace Musify.Application.Mixes
.Include(mix => mix.Items) .Include(mix => mix.Items)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix is null) if (mix == null)
return Error.NotFound(description: $"Mix {request.MixId} was not found"); return AppErrors.NotFound("Mix", request.MixId);
var items = mix.Items.OrderBy(item => item.Position).ToList(); var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
return MixApplicationResponse.FromEntity(mix, items.Count, items); return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
} }
} }
} }
@@ -1,4 +1,3 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
@@ -7,31 +6,52 @@ using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes
{ {
public record GetMixesByUserIdQuery(long UserId) public record GetMixesByUserIdQuery(long UserId)
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>; : IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database) public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>> : IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{ {
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{ {
var entries = await database.Mixes var mixes = await database.Mixes
.AsNoTracking() .AsNoTracking()
.Where(mix => mix.UserId == request.UserId) .Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position) .OrderBy(mix => mix.Position)
.Select(mix => new .ThenBy(mix => mix.Id)
{
Mix = mix,
ItemCount = mix.Items.Count,
CoverItems = mix.Items
.OrderBy(item => item.Position)
.Take(MixApplicationResponse.CoverItemCount)
.ToList()
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return entries var orderedItemsByMix = mixes.ToDictionary(
.Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems)) mix => mix.Id,
mix => mix.Items.OrderBy(item => item.Position).ToList());
var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync(
database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken);
var visibleItemsByMix = orderedItemsByMix.ToDictionary(
entry => entry.Key,
entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList());
var coverItems = visibleItemsByMix.Values
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
.ToList(); .ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
.GroupBy(response => response.TrackId)
.ToDictionary(group => group.Key, group => group.First());
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId))
.Select(item => coverResponses[item.TrackId])
.ToList();
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
}
return responses;
} }
} }
} }
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes
{
internal static class MixItemMapper
{
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
IDatabase database,
IEnumerable<MixItem> items,
CancellationToken cancellationToken)
{
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
var visible = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
return [.. visible];
}
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
var tracksById = await database.Tracks
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item =>
{
var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse(
item.TrackId,
entry.Track.Title,
entry.Track.Owner.Name,
entry.Track.DurationSeconds,
entry.ListensCount);
})
.ToList();
}
}
}
@@ -1,25 +1,13 @@
using Musify.Domain.Entities; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses namespace Musify.Application.Mixes.Responses
{ {
public record MixApplicationResponse( public record MixApplicationResponse(
Guid Id, Guid Id,
string Title, MixKind Kind,
string? Subtitle,
int ItemCount, int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items) IReadOnlyList<MixItemApplicationResponse> Items)
{ {
public const int CoverItemCount = 4; public const int CoverItemCount = 4;
public static MixApplicationResponse FromEntity(
Mix mix,
int itemCount,
IReadOnlyList<MixItem> items) =>
new(
mix.Id,
mix.Title,
mix.Subtitle,
itemCount,
[.. items.Select(MixItemApplicationResponse.FromEntity)]);
} }
} }
@@ -1,27 +1,9 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses namespace Musify.Application.Mixes.Responses
{ {
public record MixItemApplicationResponse( public record MixItemApplicationResponse(
MixItemSource Source, Guid TrackId,
Guid? TrackId,
string? VideoId,
string Title, string Title,
string? Artist, string? Artist,
string? ThumbnailUrl,
double DurationSeconds, double DurationSeconds,
bool IsExplicit) int ListensCount);
{
public static MixItemApplicationResponse FromEntity(MixItem item) =>
new(
item.Source,
item.TrackId,
item.VideoId,
item.Title ?? string.Empty,
item.Artist,
item.ThumbnailUrl,
item.DurationSeconds,
item.IsExplicit);
}
} }
@@ -0,0 +1,101 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures
{
public enum CoverOwner
{
Track,
Album,
PlayList
}
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
_ => (await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
};
var entity = request.Owner.ToString();
if (source == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
var resizedName = request.Size switch
{
PictureSize.Small => source.Small,
PictureSize.Large => source.Large,
_ => source.Medium
};
var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original)
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
: (null, null);
if (name == null || key == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
}
private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new
{
Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName),
Album = t.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null)
.OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new CoverSource(
albumTrack.Album.OwnerUserId,
albumTrack.Album.Pictures!.OriginalName,
albumTrack.Album.Pictures.SmallName,
albumTrack.Album.Pictures.MediumName,
albumTrack.Album.Pictures.LargeName))
.FirstOrDefault()
})
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return (null, trackConfiguration.Routes);
return string.IsNullOrEmpty(track.Own.Original) && track.Album != null
? (track.Album, albumConfiguration.Routes)
: (track.Own, trackConfiguration.Routes);
}
}
}
@@ -0,0 +1,35 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures
{
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
CancellationToken cancellationToken)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
if (validation.IsError)
return validation.Errors;
var intent = validation.Value;
return new PreparedPicture(
intent,
EntityPictures.Pending(intent.ObjectName),
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes));
}
}
}
@@ -1,6 +1,6 @@
namespace Musify.Application.Albums.Responses namespace Musify.Application.Pictures.Responses
{ {
public record AlbumPictureUploadResponse( public record PictureUploadResponse(
Guid IntentId, Guid IntentId,
string Bucket, string Bucket,
string Key, string Key,
@@ -0,0 +1,81 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
public class AddAlbumToPlayListCommandHandler(
IDatabase database,
ILogger<AddAlbumToPlayListCommandHandler> logger)
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
{
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var albumExists = await database.Albums
.AsNoTracking()
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return AppErrors.NotFound("Album", request.AlbumId);
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
var playListTracksQuery = database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId);
var existingTrackIds = (await playListTracksQuery
.Select(link => link.TrackId)
.ToListAsync(cancellationToken)).ToHashSet();
var newTrackIds = albumTrackIds
.Where(trackId => !existingTrackIds.Contains(trackId))
.ToList();
if (newTrackIds.Count == 0)
return new AddAlbumToPlayListResponse(0);
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in newTrackIds)
{
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved.Errors;
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
newTrackIds.Count, request.AlbumId, request.PlayListId);
return new AddAlbumToPlayListResponse(newTrackIds.Count);
}
}
}
@@ -1,75 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var alreadyAdded = await database.PlayListHasTracks
.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = request.TrackId,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
request.TrackId, request.PlayListId, nextPosition);
return new Success();
}
}
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToPlayListCommandHandler(
IDatabase database,
ILogger<AddTracksToPlayListCommandHandler> logger)
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var existingIds = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var alreadyInPlayList = await database.PlayListHasTracks
.AsNoTracking()
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
var playListTracksQuery = database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId);
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in toAdd)
{
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
return Result.Success;
}
}
}
@@ -1,149 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddYouTubeTrackToPlayListCommand(
long UserId,
Guid PlayListId,
string VideoId)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class AddYouTubeTrackToPlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddYouTubeTrackToPlayListCommand, ErrorOr<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
if (provisionResult.IsError)
return provisionResult.Errors;
var track = provisionResult.Value.Track;
var failed = track.Audio.IsFailed;
var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed;
if (failed || neverQueued)
{
if (failed)
{
track.Audio.TranscodeStatus = ProcessingStatus.Pending;
track.Pictures.ProcessingStatus = ProcessingStatus.Pending;
track.LifeCycleStatus = LifeCycleStatus.Active;
track.Audio.RetryCount += 1;
track.Audio.LastRetryAt = DateTime.UtcNow;
}
track.Audio.DownloadRequested = true;
var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken);
if (thumbnailUrl.IsError)
return thumbnailUrl.Errors;
await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
}
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = track.Id,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}",
request.VideoId, track.Id, request.PlayListId, nextPosition);
var listensCount = await database.ListeningHistories.AsNoTracking()
.CountAsync(lh => lh.TrackId == track.Id, cancellationToken);
return TrackApplicationResponse.FromEntity(track, listensCount);
}
private async Task<ErrorOr<string>> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(song?.ThumbnailUrl))
return youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl);
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
if (songResult.IsError)
return songResult.Errors;
return youTubeMusicService.ResolveArtworkUrl(songResult.Value.ThumbnailUrl);
}
private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken)
{
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
await eventBus.PublishAsync(
new DownloadYouTubeTrackEvent(
trackId,
videoId,
thumbnailUrl,
storageConfiguration.Bucket,
audioProcessedFolderKey,
new ImageSize(
trackConfiguration.Routes.SmallPicturesPath,
trackConfiguration.PicturesSizes.SmallPictureWidth,
trackConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
trackConfiguration.Routes.MediumPicturesPath,
trackConfiguration.PicturesSizes.MediumPictureWidth,
trackConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
trackConfiguration.Routes.LargePicturesPath,
trackConfiguration.PicturesSizes.LargePictureWidth,
trackConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
}
}
}
@@ -3,13 +3,14 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
@@ -17,7 +18,8 @@ namespace Musify.Application.PlayLists
long UserId, long UserId,
string Name, string Name,
string? Description, string? Description,
Guid? PictureIntentId) Guid? PictureIntentId,
PlayListVisibility Visibility = PlayListVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler( public class CreatePlayListCommandHandler(
@@ -25,126 +27,53 @@ namespace Musify.Application.PlayLists
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger, ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration)
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
private sealed record ResolvedPlayListPictures(PlayListPictures Pictures, UploadIntent? Intent);
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
.AsNoTracking() .AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken); .AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null;
if (request.PictureIntentId.HasValue)
{ {
logger.LogWarning("User {UserId} not found", request.UserId); var prepared = await PictureSourceChange.PrepareAsync(
return Error.NotFound(description: $"User {request.UserId} not found"); uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
picture = prepared.Value;
} }
var picturesResult = await ResolvePicturesAsync(request, cancellationToken); var name = request.Name.Trim();
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList var playList = new PlayList
{ {
UserId = request.UserId, OwnerUserId = request.UserId,
Name = request.Name, Name = name,
NormalizedName = request.Name.Trim().ToUpperInvariant(), NormalizedName = TextNormalizer.Normalize(name),
Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures Pictures = picture?.Pictures,
Visibility = request.Visibility
}; };
await database.PlayLists.AddAsync(playList, cancellationToken); await database.PlayLists.AddAsync(playList, cancellationToken);
if (resolvedPictures.Intent != null) if (picture != null)
{
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures.OriginalName);
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
}
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
CreatePlayListCommand request, CancellationToken cancellationToken)
{
if (!request.PictureIntentId.HasValue)
{
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = playListConfiguration.Routes.PresetOriginalPicture,
SmallName = playListConfiguration.Routes.PresetSmallPicture,
MediumName = playListConfiguration.Routes.PresetMediumPicture,
LargeName = playListConfiguration.Routes.PresetLargePicture
}, null);
}
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = validation.Value.ObjectName,
SmallName = playListConfiguration.Routes.PresetSmallPicture,
MediumName = playListConfiguration.Routes.PresetMediumPicture,
LargeName = playListConfiguration.Routes.PresetLargePicture
}, validation.Value);
}
async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{ {
await eventBus.PublishAsync( await eventBus.PublishAsync(
new CreatePlayListResourcesEvent( new CreatePlayListResourcesEvent(
playListId, playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken); cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
} }
} }
} }
@@ -1,9 +1,9 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
@@ -19,44 +19,19 @@ namespace Musify.Application.PlayLists
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList is null) if (found.IsError)
{ return found.Errors;
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
}
var playList = found.Value;
playList.LifeCycleStatus = LifeCycleStatus.Removing; playList.LifeCycleStatus = LifeCycleStatus.Removing;
try await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
{ await database.SaveChangesAsync(cancellationToken);
await eventBus.PublishAsync(
new DeletePlayListEvent(playList.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
try logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
return new Success(); return Result.Success;
} }
} }
} }
@@ -1,12 +1,13 @@
using ErrorOr; using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists.Responses;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
public record GetPlayListByIdQuery(Guid Id) public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>; : IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database) public class GetPlayListByIdQueryHandler(IDatabase database)
@@ -14,24 +15,17 @@ namespace Musify.Application.PlayLists
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{ {
var entry = await database.PlayLists var playList = await database.PlayLists
.AsNoTracking() .AsNoTracking()
.Active()
.VisibleTo(request.RequestingUserId)
.Where(p => p.Id == request.Id) .Where(p => p.Id == request.Id)
.Select(p => new .SelectResponse()
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
if (entry is null) return playList == null
return Error.NotFound(); ? AppErrors.NotFound("PlayList", request.Id)
: playList;
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
} }
} }
} }
@@ -1,49 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures is null)
return Error.NotFound();
var routes = playListConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -2,50 +2,41 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities; using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
public record GetPlayListTracksQuery( public record GetPlayListTracksQuery(
Guid PlayListId, Guid PlayListId,
int PageNumber, int PageNumber,
int PageSize) int PageSize,
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database) public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> > : IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{ {
var playListExists = await database.PlayLists var playListVisible = await database.PlayLists
.AsNoTracking() .AsNoTracking()
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken); .Active()
if (!playListExists) .VisibleTo(request.ViewerId)
return Error.NotFound(); .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
if (!playListVisible)
return AppErrors.NotFound("PlayList", request.PlayListId);
var tracksQuery = database.PlayListHasTracks return await database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Include(plt => ((ExternalTrack)plt.Track).TrackArtists) .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.ThenInclude(trackArtist => trackArtist.Artist) .OrderBy(playListTrack => playListTrack.Position)
.Include(plt => ((LocalTrack)plt.Track).Owner) .ThenBy(playListTrack => playListTrack.Id)
.Where(plt => plt.PlayListId == request.PlayListId); .Select(playListTrack => playListTrack.Track)
.SelectResponse()
var totalCount = await tracksQuery.CountAsync(cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
} }
} }
} }
@@ -1,43 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.PlayLists.Responses;
using X.PagedList;
using X.PagedList.EF;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> >>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,12 +1,8 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
@@ -14,45 +10,32 @@ namespace Musify.Application.PlayLists
long UserId, long UserId,
string? Name, string? Name,
int PageNumber, int PageNumber,
int PageSize) int PageSize,
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >; long? ViewerId = null)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database) public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> > > : IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{ {
var playListsQuery = database.PlayLists var playListsQuery = database.PlayLists
.AsNoTracking() .AsNoTracking()
.Where(p => p.UserId == request.UserId); .Active()
.VisibleTo(request.ViewerId)
.Where(playList => playList.OwnerUserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name)) if (!string.IsNullOrWhiteSpace(request.Name))
{ {
var normalizedName = request.Name.Trim().ToUpperInvariant(); var normalizedName = TextNormalizer.Normalize(request.Name);
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
} }
var totalCount = await playListsQuery.CountAsync(cancellationToken); return await playListsQuery
.OrderBy(playList => playList.CreatedAt)
var pagedEntities = await playListsQuery .ThenBy(playList => playList.Id)
.OrderBy(p => p.CreatedAt) .SelectResponse()
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
} )
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
} }
} }
} }
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
@@ -16,34 +17,30 @@ namespace Musify.Application.PlayLists
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); if (playList.IsError)
if (playList is null) return playList.Errors;
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId) var playListTracks = await database.PlayListHasTracks
{ .Where(plt => plt.PlayListId == request.PlayListId)
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); .OrderBy(plt => plt.Position)
return Error.Unauthorized(); .ToListAsync(cancellationToken);
}
var link = await database.PlayListHasTracks var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); if (link == null)
if (link is null) return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "Track is not in the playlist.");
}
database.PlayListHasTracks.Remove(link); database.PlayListHasTracks.Remove(link);
var position = 0;
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
remaining.Position = position++;
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
return new Success(); return Result.Success;
} }
} }
} }
@@ -1,13 +1,9 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.PlayLists.Responses; using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
@@ -16,90 +12,14 @@ namespace Musify.Application.PlayLists
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler( public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
IDatabase database, : ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
{ await issuer.IssuePictureAsync(
var userExists = await database.Users request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
.AsNoTracking() request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.PlayListPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
} }
} }
@@ -0,0 +1,4 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -1,4 +1,6 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses namespace Musify.Application.PlayLists.Responses
{ {
@@ -9,15 +11,21 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName, string? SmallImageKeyName,
string? MediumImageKeyName, string? MediumImageKeyName,
string? LargeImageKeyName, string? LargeImageKeyName,
PlayListVisibility Visibility,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds,
long OwnerUserId,
int TrackCount,
double DurationSeconds)
{ {
public const int CoverTrackCount = 4; public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity( public static PlayListApplicationResponse FromEntity(
PlayList playList, PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null) IReadOnlyList<Guid>? coverTrackIds = null,
int trackCount = 0,
double durationSeconds = 0)
{ {
return new PlayListApplicationResponse( return new PlayListApplicationResponse(
playList.Id, playList.Id,
@@ -26,9 +34,13 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName, playList.Pictures?.SmallName,
playList.Pictures?.MediumName, playList.Pictures?.MediumName,
playList.Pictures?.LargeName, playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt, playList.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? [],
playList.OwnerUserId,
trackCount,
durationSeconds);
} }
} }
} }
@@ -1,11 +0,0 @@
namespace Musify.Application.PlayLists.Responses
{
public record PlayListPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -0,0 +1,39 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses
{
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
query.Select(playList => new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures!.SmallName,
playList.Pictures!.MediumName,
playList.Pictures!.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
}

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