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 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 4b708aa987 backend: add endpoint to add a whole album to a playlist 2026-09-24 12:04:28 +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 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 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 1df7f6ccf6 web-player: share form limits between client and server and extract form parsers 2026-09-24 05:19:41 +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 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 b2f450dcf7 web-player: add Vitest, unit tests and pure queue operations 2026-09-24 04:54:09 +02:00
775 changed files with 34520 additions and 17651 deletions
+6
View File
@@ -14,6 +14,12 @@
"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", "name": "musify-streaming-gateway",
"runtimeExecutable": "dotnet", "runtimeExecutable": "dotnet",
+34 -20
View File
@@ -18,7 +18,7 @@ generated_code = true
[*.cs] [*.cs]
indent_size = 4 indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning csharp_style_namespace_declarations = block_scoped:warning
csharp_using_directive_placement = outside_namespace:warning csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false dotnet_separate_import_directive_groups = false
@@ -33,33 +33,47 @@ csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match: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.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case dotnet_naming_rule.private_fields.style = underscore_camel_case
dotnet_naming_rule.private_fields.severity = warning dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers = dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_style.camel_case.capitalization = camel_case dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_rule.private_constants.symbols = private_constants # Non-private fields (constants, static readonly, etc.) are PascalCase.
dotnet_naming_rule.private_constants.style = pascal_case dotnet_naming_rule.other_fields.symbols = other_fields
dotnet_naming_rule.private_constants.severity = warning dotnet_naming_rule.other_fields.style = pascal_case
dotnet_naming_symbols.private_constants.applicable_kinds = field dotnet_naming_rule.other_fields.severity = warning
dotnet_naming_symbols.private_constants.required_modifiers = const 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_style.pascal_case.capitalization = pascal_case
# Analyzer rules that would need behaviour or API changes; downgraded in P0, revisit in P15. 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.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1305.severity = suggestion
dotnet_diagnostic.CA1707.severity = suggestion
dotnet_diagnostic.CA1859.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion dotnet_diagnostic.CA1716.severity = suggestion
dotnet_diagnostic.CA1000.severity = suggestion # The routing slip builders are stateless services resolved from DI; they stay instance members.
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion # Test names follow Method_Scenario_Expected.
dotnet_diagnostic.CA1711.severity = suggestion [Tests/**.cs]
dotnet_diagnostic.CA1861.severity = suggestion 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`.
@@ -7,17 +7,16 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary> public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class AddTracksToAlbumCommandHandler( public class AddTracksToAlbumCommandHandler(
IDatabase database, IDatabase database,
ILogger<AddTracksToAlbumCommandHandler> logger) ILogger<AddTracksToAlbumCommandHandler> logger)
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>> : ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -50,7 +49,6 @@ public class AddTracksToAlbumCommandHandler(
if (toAdd.Count == 0) if (toAdd.Count == 0)
return Result.Success; return Result.Success;
// Album track numbers are user-visible, so they start at 1.
var nextTrackNumber = (await database.AlbumHasTracks var nextTrackNumber = (await database.AlbumHasTracks
.Where(link => link.AlbumId == request.AlbumId) .Where(link => link.AlbumId == request.AlbumId)
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
@@ -74,4 +72,5 @@ public class AddTracksToAlbumCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -12,24 +12,26 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record CreateAlbumCommand( public record CreateAlbumCommand(
long UserId, long UserId,
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(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger, ILogger<CreateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration) AlbumConfiguration albumConfiguration)
: 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)
{ {
var userExists = await database.Users var userExists = await database.Users
@@ -38,6 +40,21 @@ public class CreateAlbumCommandHandler(
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId); return AppErrors.NotFound("User", request.UserId);
var trackIds = (request.TrackIds ?? []).Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
.ToListAsync(cancellationToken);
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( var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError) if (prepared.IsError)
@@ -53,11 +70,19 @@ public class CreateAlbumCommandHandler(
NormalizedTitle = TextNormalizer.Normalize(title), NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description, Description = request.Description,
ReleaseYear = request.ReleaseYear, ReleaseYear = request.ReleaseYear,
Type = request.Type,
Pictures = picture.Pictures Pictures = picture.Pictures
}; };
await database.Albums.AddAsync(album, cancellationToken); await database.Albums.AddAsync(album, cancellationToken);
for (var index = 0; index < trackIds.Count; index++)
{
await database.AlbumHasTracks.AddAsync(
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
cancellationToken);
}
await eventBus.PublishAsync( await eventBus.PublishAsync(
new CreateAlbumResourcesEvent( new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
@@ -65,8 +90,15 @@ public class CreateAlbumCommandHandler(
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0); 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)]);
}
} }
} }
@@ -6,17 +6,17 @@ using Musify.Application.Events;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record DeleteAlbumCommand(long UserId, Guid AlbumId) public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler( public class DeleteAlbumCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, 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 found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -33,4 +33,5 @@ public class DeleteAlbumCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumByIdQuery(Guid Id) public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>; : IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database) public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>> : IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{ {
var album = await database.Albums var album = await database.Albums
@@ -26,4 +26,5 @@ public class GetAlbumByIdQueryHandler(IDatabase database)
? AppErrors.NotFound("Album", request.Id) ? AppErrors.NotFound("Album", request.Id)
: album; : album;
} }
}
} }
@@ -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);
}
}
}
@@ -6,17 +6,17 @@ using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumTracksQuery( public record GetAlbumTracksQuery(
Guid AlbumId, Guid AlbumId,
int PageNumber, int PageNumber,
int PageSize) int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database) public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>> : IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
{ {
var albumExists = await database.Albums var albumExists = await database.Albums
@@ -28,11 +28,12 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
return await database.AlbumHasTracks return await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id) .ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track) .Select(albumTrack => albumTrack.Track)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -4,14 +4,14 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<AlbumApplicationResponse>>; : IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler(IDatabase database) public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>> : IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
{ {
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{ {
var albumsQuery = database.Albums var albumsQuery = database.Albums
@@ -30,4 +30,5 @@ public class GetAlbumsQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database) public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> : IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
@@ -30,4 +30,5 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; 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<IReadOnlyList<AlbumApplicationResponse>>; : IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>> : IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{ {
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{ {
var listens = database.ListeningHistories var listens = database.ListeningHistories
@@ -20,6 +20,7 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join( .Join(
listens, listens,
albumTrack => albumTrack.TrackId, albumTrack => albumTrack.TrackId,
@@ -48,4 +49,5 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
.Select(entry => albumsById[entry.AlbumId]) .Select(entry => albumsById[entry.AlbumId])
.ToList(); .ToList();
} }
}
} }
@@ -5,16 +5,16 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler( public class RemoveTrackFromAlbumCommandHandler(
IDatabase database, IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger) ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>> : ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -42,4 +42,5 @@ public class RemoveTrackFromAlbumCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record RequestAlbumPictureUploadCommand( public record RequestAlbumPictureUploadCommand(
long UserId, long UserId,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>> : ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync( await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
}
} }
@@ -1,8 +1,9 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses; namespace Musify.Application.Albums.Responses
{
public record AlbumApplicationResponse( public record AlbumApplicationResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Description, string? Description,
@@ -14,8 +15,9 @@ public record AlbumApplicationResponse(
string? LargeImageKeyName, string? LargeImageKeyName,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds,
{ AlbumType Type)
{
public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public static AlbumApplicationResponse FromEntity( public static AlbumApplicationResponse FromEntity(
@@ -35,6 +37,8 @@ public record AlbumApplicationResponse(
album.Pictures?.LargeName, album.Pictures?.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? [],
album.Type);
}
} }
} }
@@ -1,10 +1,10 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses; namespace Musify.Application.Albums.Responses
public static class AlbumProjections
{ {
public static class AlbumProjections
{
public const int CoverTrackCount = 4; public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) => public static IQueryable<Album> Active(this IQueryable<Album> query) =>
@@ -17,15 +17,18 @@ public static class AlbumProjections
album.Description, album.Description,
album.ReleaseYear, album.ReleaseYear,
album.OwnerUserId, album.OwnerUserId,
album.AlbumTracks.Count, album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName, album.Pictures!.SmallName,
album.Pictures!.MediumName, album.Pictures!.MediumName,
album.Pictures!.LargeName, album.Pictures!.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
album.AlbumTracks album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount) .Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToList())); .ToList(),
album.Type));
}
} }
@@ -0,0 +1,4 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
}
@@ -3,33 +3,26 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record UpdateAlbumCommand( public record UpdateAlbumCommand(
long UserId, long UserId,
Guid AlbumId, Guid AlbumId,
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,
AlbumConfiguration albumConfiguration)
: 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 found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -41,24 +34,9 @@ public class UpdateAlbumCommandHandler(
album.Title = title; album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title); 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;
if (request.NewPictureIntentId is { } intentId)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, 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); await database.SaveChangesAsync(cancellationToken);
@@ -70,4 +48,5 @@ public class UpdateAlbumCommandHandler(
.SelectResponse() .SelectResponse()
.SingleAsync(cancellationToken); .SingleAsync(cancellationToken);
} }
}
} }
@@ -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);
}
}
}
@@ -3,10 +3,10 @@ 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 class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddMediator(); services.AddMediator();
@@ -25,4 +25,5 @@ public static class ApplicationDependencyInjection
return services; return services;
} }
}
} }
@@ -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,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "Album"; public static string SectionName => "Album";
[Required] [Required]
@@ -11,4 +11,5 @@ public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfigurat
[Required] [Required]
public PictureSizes PicturesSizes { get; set; } = new(); public PictureSizes PicturesSizes { get; set; } = new();
}
} }
@@ -1,12 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class ApplicationStorageConfiguration : IConfigurationOptions
{ {
public class ApplicationStorageConfiguration : IConfigurationOptions
{
public static string SectionName => "ApplicationStorage"; public static string SectionName => "ApplicationStorage";
[Required] [Required]
[StringLength(128, MinimumLength = 1)] [StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; } public required string Bucket { get; set; }
}
} }
@@ -2,10 +2,10 @@ 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 class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
where T : class, IConfigurationOptions => where T : class, IConfigurationOptions =>
services.AddValidatedOptions<T>(configuration, T.SectionName); services.AddValidatedOptions<T>(configuration, T.SectionName);
@@ -26,4 +26,5 @@ public static class ConfigurationDependencyInjection
return services; return services;
} }
}
} }
@@ -1,16 +1,17 @@
using Mediator; using Mediator;
using Musify.Application.Configuration.Responses; using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{ {
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse( ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening, playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds)); playbackConfiguration.AnonymousFragmentSeconds));
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public interface IConfigurationOptions
{ {
public interface IConfigurationOptions
{
public static abstract string SectionName { get; } public static abstract string SectionName { get; }
}
} }
@@ -1,11 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class MixConfiguration : IConfigurationOptions
{ {
public class MixConfiguration : IConfigurationOptions
{
public static string SectionName => "Mix"; public static string SectionName => "Mix";
[Range(4, 100)] [Range(4, 100)]
public int ItemsPerMix { get; set; } = 12; public int ItemsPerMix { get; set; } = 12;
}
} }
@@ -1,17 +1,18 @@
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 enum PictureSize
{ {
public enum PictureSize
{
Small, Small,
Medium, Medium,
Large Large
} }
public class PictureRoutes public class PictureRoutes
{ {
[Required] [Required]
public string UploadsFolder { get; set; } = "uploads"; public string UploadsFolder { get; set; } = "uploads";
@@ -41,14 +42,14 @@ public class PictureRoutes
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => public string BuildOriginalPicturePath(long userId, string pictureName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName); StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
} }
public class PictureSizes public class PictureSizes
{ {
[Range(1, 1024)] [Range(1, 1024)]
public int SmallWidth { get; set; } = 128; public int SmallWidth { get; set; } = 128;
@@ -71,20 +72,20 @@ public class PictureSizes
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
} }
public interface IPictureOwnerConfiguration public interface IPictureOwnerConfiguration
{ {
public PictureRoutes Routes { get; } public PictureRoutes Routes { get; }
public PictureSizes PicturesSizes { get; } public PictureSizes PicturesSizes { get; }
} }
public static class PictureSizeParser public static class PictureSizeParser
{ {
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
public static PictureSize Parse(string? value) => public static PictureSize Parse(string? value) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size) Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size ? size
: PictureSize.Medium; : PictureSize.Medium;
}
} }
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "PlayList"; public static string SectionName => "PlayList";
[Required] [Required]
@@ -11,4 +11,5 @@ public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfigu
[Required] [Required]
public PictureSizes PicturesSizes { get; set; } = new(); public PictureSizes PicturesSizes { get; set; } = new();
}
} }
@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public sealed class PlaybackConfiguration : IConfigurationOptions
{ {
public sealed class PlaybackConfiguration : IConfigurationOptions
{
public static string SectionName => "Playback"; public static string SectionName => "Playback";
public bool AllowAnonymousListening { get; set; } public bool AllowAnonymousListening { get; set; }
@@ -14,4 +14,5 @@ public sealed class PlaybackConfiguration : IConfigurationOptions
[Range(1, int.MaxValue)] [Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
} }
@@ -1,4 +1,5 @@
namespace Musify.Application.Configuration.Responses; namespace Musify.Application.Configuration.Responses
{
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); }
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class StreamGatewayConfiguration : IConfigurationOptions
{ {
public class StreamGatewayConfiguration : IConfigurationOptions
{
public static string SectionName => "StreamGateway"; public static string SectionName => "StreamGateway";
[Url] [Url]
@@ -12,4 +12,5 @@ public class StreamGatewayConfiguration : IConfigurationOptions
[Required] [Required]
public string AudioFileName { get; set; } = "audio.m4a"; public string AudioFileName { get; set; } = "audio.m4a";
}
} }
@@ -1,10 +1,11 @@
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 : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "Track"; public static string SectionName => "Track";
[Required] [Required]
@@ -14,10 +15,10 @@ public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfigurat
public PictureSizes PicturesSizes { get; set; } = new(); public PictureSizes PicturesSizes { get; set; } = new();
PictureRoutes IPictureOwnerConfiguration.Routes => Routes; PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
} }
public class TrackRoutes : PictureRoutes public class TrackRoutes : PictureRoutes
{ {
public TrackRoutes() public TrackRoutes()
{ {
ParentFolder = "Tracks"; ParentFolder = "Tracks";
@@ -48,8 +49,9 @@ public class TrackRoutes : PictureRoutes
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string BuildOriginalAudioPath(long userId, string audioName) => public string BuildOriginalAudioPath(long userId, string audioName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName); StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
public string BuildProcessedAudioPath(string folderName) => public string BuildProcessedAudioPath(string folderName) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
}
} }
@@ -1,19 +1,19 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class UploadIntentConfiguration : IConfigurationOptions
{ {
public class UploadIntentConfiguration : IConfigurationOptions
{
public static 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;
@@ -38,4 +38,5 @@ public class UploadIntentConfiguration : IConfigurationOptions
[Range(60, 86400)] [Range(60, 86400)]
public int TempCleanupJobIntervalSeconds { get; set; } = 3600; public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
}
} }
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IAudioTranscoderService
{ {
public interface IAudioTranscoderService
{
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
}
} }
@@ -2,10 +2,10 @@ using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IDatabase
{ {
public interface IDatabase
{
public DbSet<User> Users { get; } public DbSet<User> Users { get; }
public DbSet<PlayList> PlayLists { get; } public DbSet<PlayList> PlayLists { get; }
@@ -32,9 +32,14 @@ public interface IDatabase
public DbSet<UserFollow> UserFollows { get; } public DbSet<UserFollow> UserFollows { get; }
public DbSet<TrackCoauthor> TrackCoauthors { get; }
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
public Task<int> SaveChangesAsync(CancellationToken cancellationToken); public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IDatabaseTransaction : IAsyncDisposable
{ {
public interface IDatabaseTransaction : IAsyncDisposable
{
public Task CommitAsync(CancellationToken cancellationToken = default); public Task CommitAsync(CancellationToken cancellationToken = default);
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IEventBus
{ {
public interface IEventBus
{
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class; public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IPictureService
{ {
public interface IPictureService
{
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
} }
@@ -1,8 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IStorageService
{ {
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary> public interface IStorageService
{
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
@@ -21,4 +20,5 @@ public interface IStorageService
CancellationToken cancellationToken); CancellationToken cancellationToken);
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
} }
@@ -1,8 +1,9 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IStreamTicketService
{ {
public interface IStreamTicketService
{
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
} }
public record StreamTicket(string Token, int ExpiresInSeconds); public record StreamTicket(string Token, int ExpiresInSeconds);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record ObjectMetaData(string ContentType, long ContentLength); public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModifiedUtc); public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId); public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId); public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumProcessingFailed( public record AlbumProcessingFailed(
Guid AlbumId, Guid AlbumId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent( public record CreateAlbumResourcesEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreatePlayListResourcesEvent( public record CreatePlayListResourcesEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,15 +1,16 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; 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,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeleteAlbumEvent( public record DeleteAlbumEvent(
Guid AlbumId, Guid AlbumId,
long UserId); long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeletePlayListEvent( public record DeletePlayListEvent(
Guid PlayListId, Guid PlayListId,
long UserId); long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeleteTrackEvent( public record DeleteTrackEvent(
Guid TrackId, Guid TrackId,
long UserId); long UserId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListPictureProcessed(Guid PlayListId); public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListPictureProcessingFailed(Guid PlayListId); public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListProcessingFailed( public record PlayListProcessingFailed(
Guid PlayListId, Guid PlayListId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackAudioProcessed(Guid TrackId); public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackAudioProcessingFailed(Guid TrackId); public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackPictureProcessed(Guid TrackId); public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackPictureProcessingFailed(Guid TrackId); public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,7 +1,8 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackProcessingFailed( public record TrackProcessingFailed(
Guid TrackId, Guid TrackId,
string? Bucket, string? Bucket,
string? PictureKey, string? PictureKey,
string? AudioKey); string? AudioKey);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent( public record UpdateAlbumPictureEvent(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent( public record UpdateAlbumPictureSourceEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,9 +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,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdatePlayListPictureSourceEvent( public record UpdatePlayListPictureSourceEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent( public record UpdateTrackAudioEvent(
Guid TrackId, Guid TrackId,
string SourceBucket, string SourceBucket,
string SourceKey, string SourceKey,
string DestinationBucket, string DestinationBucket,
string DestinationFolderKey); string DestinationFolderKey);
}
@@ -1,9 +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,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,17 +1,17 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses; using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres; namespace Musify.Application.Genres
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{ {
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{ {
var all = Enum.GetValues<Genre>(); var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
IReadOnlyList<AvailableGenreResponse> result = all IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse( .Select(genre => new AvailableGenreResponse(
@@ -21,4 +21,5 @@ public class GetAvailableGenresQueryHandler
return ValueTask.FromResult(result); return ValueTask.FromResult(result);
} }
}
} }
@@ -1,21 +1,21 @@
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses; using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres; namespace Musify.Application.Genres
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{ {
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) public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{ {
var rows = await database.TrackTags var rows = await database.TrackTags
.AsNoTracking() .AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag) .GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -26,4 +26,5 @@ public class GetGenresQueryHandler(IDatabase database)
.Select(row => new GenreResponse(row.Genre, row.TrackCount)) .Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList(); .ToList();
} }
}
} }
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses; namespace Musify.Application.Genres.Responses
{
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith); public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
}
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses; namespace Musify.Application.Genres.Responses
{
public record GenreResponse(Genre Genre, int TrackCount); public record GenreResponse(Genre Genre, int TrackCount);
}
@@ -5,26 +5,27 @@ using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes; namespace Musify.Application.Likes
{
public record GetLikedTracksQuery( public record GetLikedTracksQuery(
long UserId, long UserId,
int PageNumber, int PageNumber,
int PageSize) int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>; : IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database) public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>> : IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{ {
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{ {
return await database.TrackLikes return await database.TrackLikes
.AsNoTracking() .AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
.OrderByDescending(like => like.CreatedAt) .OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id) .ThenBy(like => like.Id)
.Select(like => like.Track) .Select(like => like.Track)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -7,21 +7,21 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes; namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(long UserId, Guid TrackId) public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>; : ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler( public class ToggleTrackLikeCommandHandler(
IDatabase database, IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger) ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>> : ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{ {
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{ {
var trackExists = await database.Tracks var trackExists = await database.Tracks
.AsNoTracking() .AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
if (!trackExists) if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId); return AppErrors.NotFound("Track", request.TrackId);
@@ -44,7 +44,6 @@ public class ToggleTrackLikeCommandHandler(
nowLiked = true; nowLiked = true;
} }
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
var saved = await database.TrySaveChangesAsync( var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
if (saved.IsError) if (saved.IsError)
@@ -55,4 +54,5 @@ public class ToggleTrackLikeCommandHandler(
return nowLiked; return nowLiked;
} }
}
} }
@@ -6,17 +6,17 @@ using Musify.Application.Contracts;
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; : ICommand;
public class GenerateMixesForUserCommandHandler( public class GenerateMixesForUserCommandHandler(
IDatabase database, IDatabase database,
MixConfiguration mixConfiguration, MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger) ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand> : ICommandHandler<GenerateMixesForUserCommand>
{ {
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds); private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
@@ -25,7 +25,7 @@ public class GenerateMixesForUserCommandHandler(
var libraryTrackIds = await database.Tracks var libraryTrackIds = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id) .Select(track => track.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -79,7 +79,7 @@ public class GenerateMixesForUserCommandHandler(
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) => private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())]; [.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second) private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
{ {
var merged = new List<Guid>(first.Count + second.Count); var merged = new List<Guid>(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++)
@@ -127,4 +127,5 @@ public class GenerateMixesForUserCommandHandler(
} }
} }
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GetMixByIdQuery(long UserId, Guid MixId) public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>; : IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database) public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>> : IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{ {
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
{ {
var mix = await database.Mixes var mix = await database.Mixes
@@ -28,4 +28,5 @@ public class GetMixByIdQueryHandler(IDatabase database)
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
} }
}
} }
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GetMixesByUserIdQuery(long UserId) public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>; : IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database) public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>> : IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{ {
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{ {
var mixes = await database.Mixes var mixes = await database.Mixes
@@ -25,7 +25,13 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
mix => mix.Id, mix => mix.Id,
mix => mix.Items.OrderBy(item => item.Position).ToList()); mix => mix.Items.OrderBy(item => item.Position).ToList());
var coverItems = orderedItemsByMix.Values 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)) .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
.ToList(); .ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
@@ -35,7 +41,7 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
var responses = new List<MixApplicationResponse>(mixes.Count); var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes) foreach (var mix in mixes)
{ {
var orderedItems = orderedItemsByMix[mix.Id]; var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount) .Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId)) .Where(item => coverResponses.ContainsKey(item.TrackId))
@@ -47,4 +53,5 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
return responses; return responses;
} }
}
} }
@@ -4,11 +4,27 @@ using Musify.Application.Mixes.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
internal static class MixItemMapper
{ {
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( public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database, IDatabase database,
IReadOnlyList<MixItem> orderedItems, IReadOnlyList<MixItem> orderedItems,
@@ -19,7 +35,7 @@ internal static class MixItemMapper
var tracksById = await database.Tracks var tracksById = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Include(track => track.Owner) .Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken); .ToDictionaryAsync(x => x.Track.Id, cancellationToken);
@@ -37,4 +53,5 @@ internal static class MixItemMapper
}) })
.ToList(); .ToList();
} }
}
} }
@@ -1,12 +1,13 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse( public record MixApplicationResponse(
Guid Id, Guid Id,
MixKind Kind, MixKind Kind,
int ItemCount, int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items) IReadOnlyList<MixItemApplicationResponse> Items)
{ {
public const int CoverItemCount = 4; public const int CoverItemCount = 4;
}
} }
@@ -1,8 +1,9 @@
namespace Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse( public record MixItemApplicationResponse(
Guid TrackId, Guid TrackId,
string Title, string Title,
string? Artist, string? Artist,
double DurationSeconds, double DurationSeconds,
int ListensCount); int ListensCount);
}
@@ -7,38 +7,35 @@ using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures; namespace Musify.Application.Pictures
public enum CoverOwner
{ {
public enum CoverOwner
{
Track, Track,
Album, Album,
PlayList PlayList
} }
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>; : IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType); public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler( public class GetCoverQueryHandler(
IDatabase database, IDatabase database,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration, TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration, AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration) PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>> : IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{ {
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
{ {
var (source, routes) = request.Owner switch var (source, routes) = request.Owner switch
{ {
CoverOwner.Track => (await database.Tracks.AsNoTracking() CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
CoverOwner.Album => (await database.Albums.AsNoTracking() CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) .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)) .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
@@ -60,7 +57,6 @@ public class GetCoverQueryHandler(
_ => source.Medium _ => source.Medium
}; };
// The resized files only exist once processing has finished; until then serve the original.
var (name, key) = !string.IsNullOrEmpty(resizedName) var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) ? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original) : !string.IsNullOrEmpty(source.Original)
@@ -72,4 +68,34 @@ public class GetCoverQueryHandler(
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); 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);
}
}
} }
@@ -5,13 +5,12 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures; namespace Musify.Application.Pictures
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{ {
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync( public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
Guid intentId, Guid intentId,
@@ -32,4 +31,5 @@ public static class PictureSourceChange
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes)); owner.PicturesSizes.ToImageSizes(owner.Routes));
} }
}
} }
@@ -1,6 +1,6 @@
namespace Musify.Application.Pictures.Responses; namespace Musify.Application.Pictures.Responses
{
public record PictureUploadResponse( public record PictureUploadResponse(
Guid IntentId, Guid IntentId,
string Bucket, string Bucket,
string Key, string Key,
@@ -8,3 +8,4 @@ public record PictureUploadResponse(
string ContentType, string ContentType,
int ExpiresInSeconds, int ExpiresInSeconds,
string UploadUrl); string UploadUrl);
}
@@ -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);
}
}
}
@@ -7,17 +7,16 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary> public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class AddTracksToPlayListCommandHandler( public class AddTracksToPlayListCommandHandler(
IDatabase database, IDatabase database,
ILogger<AddTracksToPlayListCommandHandler> logger) ILogger<AddTracksToPlayListCommandHandler> logger)
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>> : ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -28,7 +27,7 @@ public class AddTracksToPlayListCommandHandler(
var existingIds = await database.Tracks var existingIds = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id) .Select(track => track.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -46,10 +45,12 @@ public class AddTracksToPlayListCommandHandler(
if (toAdd.Count == 0) if (toAdd.Count == 0)
return Result.Success; return Result.Success;
// Playlist positions are internal indexes, so they start at 0. var playListTracksQuery = database.PlayListHasTracks
var nextPosition = (await database.PlayListHasTracks .Where(link => link.PlayListId == request.PlayListId);
.Where(link => link.PlayListId == request.PlayListId)
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in toAdd) foreach (var trackId in toAdd)
{ {
@@ -70,4 +71,5 @@ public class AddTracksToPlayListCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -12,9 +12,9 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record CreatePlayListCommand( public record CreatePlayListCommand(
long UserId, long UserId,
string Name, string Name,
string? Description, string? Description,
@@ -22,14 +22,14 @@ public record CreatePlayListCommand(
PlayListVisibility Visibility = PlayListVisibility.Private) PlayListVisibility Visibility = PlayListVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler( public class CreatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger, ILogger<CreatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration) PlayListConfiguration playListConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
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
@@ -39,10 +39,10 @@ public class CreatePlayListCommandHandler(
return AppErrors.NotFound("User", request.UserId); return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null; PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId) if (request.PictureIntentId.HasValue)
{ {
var prepared = await PictureSourceChange.PrepareAsync( var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError) if (prepared.IsError)
return prepared.Errors; return prepared.Errors;
@@ -75,4 +75,5 @@ public class CreatePlayListCommandHandler(
return PlayListApplicationResponse.FromEntity(playList); return PlayListApplicationResponse.FromEntity(playList);
} }
}
} }
@@ -6,17 +6,17 @@ using Musify.Application.Events;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record DeletePlayListCommand(long UserId, Guid PlayListId) public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler( public class DeletePlayListCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger) ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>> : ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{ {
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -33,4 +33,5 @@ public class DeletePlayListCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>; : IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database) public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>> : IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists var playList = await database.PlayLists
@@ -27,4 +27,5 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
? AppErrors.NotFound("PlayList", request.Id) ? AppErrors.NotFound("PlayList", request.Id)
: playList; : playList;
} }
}
} }
@@ -7,18 +7,18 @@ using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
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) 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 playListVisible = await database.PlayLists var playListVisible = await database.PlayLists
@@ -31,11 +31,12 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
return await database.PlayListHasTracks return await database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position) .OrderBy(playListTrack => playListTrack.Position)
.ThenBy(playListTrack => playListTrack.Id) .ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track) .Select(playListTrack => playListTrack.Track)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -4,9 +4,9 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListsByUserIdQuery( public record GetPlayListsByUserIdQuery(
long UserId, long UserId,
string? Name, string? Name,
int PageNumber, int PageNumber,
@@ -14,9 +14,9 @@ public record GetPlayListsByUserIdQuery(
long? ViewerId = null) long? ViewerId = null)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>; : IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database) public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>> : IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
{ {
public async ValueTask<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
@@ -37,4 +37,5 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -5,16 +5,16 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler( public class RemoveTrackFromPlayListCommandHandler(
IDatabase database, IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger) ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>> : ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
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.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -42,4 +42,5 @@ public class RemoveTrackFromPlayListCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record RequestPlayListPictureUploadCommand( public record RequestPlayListPictureUploadCommand(
long UserId, long UserId,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>> : ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync( await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
}
} }
@@ -0,0 +1,4 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -2,9 +2,9 @@ using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses; namespace Musify.Application.PlayLists.Responses
{
public record PlayListApplicationResponse( public record PlayListApplicationResponse(
Guid Id, Guid Id,
string Name, string Name,
string? Description, string? Description,
@@ -18,7 +18,7 @@ public record PlayListApplicationResponse(
long OwnerUserId, long OwnerUserId,
int TrackCount, int TrackCount,
double DurationSeconds) double DurationSeconds)
{ {
public const int CoverTrackCount = PlayListProjections.CoverTrackCount; public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity( public static PlayListApplicationResponse FromEntity(
@@ -42,4 +42,5 @@ public record PlayListApplicationResponse(
trackCount, trackCount,
durationSeconds); durationSeconds);
} }
}
} }
@@ -1,10 +1,10 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses; namespace Musify.Application.PlayLists.Responses
public static class PlayListProjections
{ {
public static class PlayListProjections
{
public const int CoverTrackCount = 4; public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) => public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
@@ -25,13 +25,15 @@ public static class PlayListProjections
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt, playList.UpdatedAt,
playList.PlayListTracks playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position) .OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount) .Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId) .Select(playListTrack => playListTrack.TrackId)
.ToList(), .ToList(),
playList.OwnerUserId, playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
playList.PlayListTracks playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds))); .Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
} }
@@ -2,34 +2,26 @@ 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.Contracts; using Musify.Application.Contracts;
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.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record UpdatePlayListCommand( public record UpdatePlayListCommand(
long UserId, long UserId,
Guid PlayListId, Guid PlayListId,
string? NewName, string NewName,
string? NewDescription, string? NewDescription,
Guid? NewPictureIntentId, PlayListVisibility NewVisibility)
PlayListVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler( public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, ILogger<UpdatePlayListCommandHandler> logger)
ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{ {
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -38,34 +30,10 @@ public class UpdatePlayListCommandHandler(
var playList = found.Value; var playList = found.Value;
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playList.Name = request.NewName.Trim(); playList.Name = request.NewName.Trim();
playList.NormalizedName = TextNormalizer.Normalize(request.NewName); playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
}
// null means "unchanged"; a blank description clears it.
if (request.NewDescription != null)
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
playList.Visibility = request.NewVisibility;
if (request.NewVisibility is { } visibility)
playList.Visibility = visibility;
if (request.NewPictureIntentId is { } intentId)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
playList.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
}
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
@@ -77,4 +45,5 @@ public class UpdatePlayListCommandHandler(
.SelectResponse() .SelectResponse()
.SingleAsync(cancellationToken); .SingleAsync(cancellationToken);
} }
}
} }
@@ -0,0 +1,59 @@
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.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListPictureCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListPictureCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListPictureCommand request, CancellationToken cancellationToken)
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = found.Value;
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
playList.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated picture of playlist {PlayListId}", playList.Id);
return await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
}
}
}

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