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"],
"port": 5111
},
{
"name": "musify-worker",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Worker"],
"port": 5112
},
{
"name": "musify-streaming-gateway",
"runtimeExecutable": "dotnet",
+34 -20
View File
@@ -18,7 +18,7 @@ generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
csharp_style_namespace_declarations = block_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
@@ -33,33 +33,47 @@ csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first.
# Private fields are _camelCase whatever their modifiers (static, readonly, const).
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case
dotnet_naming_rule.private_fields.style = underscore_camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case.capitalization = camel_case
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_rule.private_constants.symbols = private_constants
dotnet_naming_rule.private_constants.style = pascal_case
dotnet_naming_rule.private_constants.severity = warning
dotnet_naming_symbols.private_constants.applicable_kinds = field
dotnet_naming_symbols.private_constants.required_modifiers = const
# Non-private fields (constants, static readonly, etc.) are PascalCase.
dotnet_naming_rule.other_fields.symbols = other_fields
dotnet_naming_rule.other_fields.style = pascal_case
dotnet_naming_rule.other_fields.severity = warning
dotnet_naming_symbols.other_fields.applicable_kinds = field
dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_style.pascal_case.capitalization = pascal_case
# 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.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.CA1000.severity = suggestion
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
# The routing slip builders are stateless services resolved from DI; they stay instance members.
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion
dotnet_diagnostic.CA1711.severity = suggestion
dotnet_diagnostic.CA1861.severity = suggestion
# Test names follow Method_Scenario_Expected.
[Tests/**.cs]
dotnet_diagnostic.CA1707.severity = none
# xUnit collection fixtures are conventionally named ...Collection.
dotnet_diagnostic.CA1711.severity = none
+58
View File
@@ -0,0 +1,58 @@
# Musify: backend
.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`.
## Commands
Run from `backend/`.
```sh
dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005)
dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers)
dotnet format Musify.slnx --verify-no-changes
```
- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit.
- EF migrations: `dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses.
- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean.
- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit.
## Style
- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it).
- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006).
- `ArgumentNullException.ThrowIfNull(x)` without `nameof`.
- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`).
- User-facing strings are in English (the web-player owns translation).
- Trim stored names; normalized columns go through `TextNormalizer.Normalize`.
- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last.
## Application layer
- One file per use case, `<Feature>/<UseCase>.cs`, holding the record and its handler; responses go in `Responses/`.
- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204.
- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows.
- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found).
- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow.
- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`.
## API layer
- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids.
- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise).
- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`.
- `.WithSummary(...)` is sentence case without a trailing period.
## Infrastructure and hosts
- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity<T>`, `DeleteEntityActivity<T>`, `UpdatePicturesActivity<T>`).
- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes.
- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's.
- The StreamingGateway references neither Application nor Infrastructure and keeps its own options.
## Tests
- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051).
- Test names are `Method_Scenario_Expected`.
- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness.
- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`.
@@ -7,9 +7,8 @@ using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
namespace Musify.Application.Albums
{
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
@@ -50,7 +49,6 @@ public class AddTracksToAlbumCommandHandler(
if (toAdd.Count == 0)
return Result.Success;
// Album track numbers are user-visible, so they start at 1.
var nextTrackNumber = (await database.AlbumHasTracks
.Where(link => link.AlbumId == request.AlbumId)
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
@@ -75,3 +73,4 @@ public class AddTracksToAlbumCommandHandler(
return Result.Success;
}
}
}
@@ -12,14 +12,16 @@ using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
@@ -38,6 +40,21 @@ public class CreateAlbumCommandHandler(
if (!userExists)
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(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
@@ -53,11 +70,19 @@ public class CreateAlbumCommandHandler(
NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Type = request.Type,
Pictures = picture.Pictures
};
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(
new CreateAlbumResourcesEvent(
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);
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,8 +6,8 @@ using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>;
@@ -34,3 +34,4 @@ public class DeleteAlbumCommandHandler(
return Result.Success;
}
}
}
@@ -5,8 +5,8 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
@@ -27,3 +27,4 @@ public class GetAlbumByIdQueryHandler(IDatabase database)
: 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,8 +6,8 @@ using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record GetAlbumTracksQuery(
Guid AlbumId,
int PageNumber,
@@ -28,7 +28,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
return await database.AlbumHasTracks
.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)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
@@ -36,3 +36,4 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -4,8 +4,8 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
@@ -31,3 +31,4 @@ public class GetAlbumsQueryHandler(IDatabase database)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -5,8 +5,8 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
@@ -31,3 +31,4 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -3,8 +3,8 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
@@ -20,6 +20,7 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join(
listens,
albumTrack => albumTrack.TrackId,
@@ -49,3 +50,4 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
.ToList();
}
}
}
@@ -5,8 +5,8 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
@@ -43,3 +43,4 @@ public class RemoveTrackFromAlbumCommandHandler(
return Result.Success;
}
}
}
@@ -5,8 +5,8 @@ using Musify.Application.Pictures.Responses;
using Musify.Application.Services;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
namespace Musify.Application.Albums
{
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
@@ -22,3 +22,4 @@ public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer,
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
}
}
@@ -1,7 +1,8 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses;
namespace Musify.Application.Albums.Responses
{
public record AlbumApplicationResponse(
Guid Id,
string Title,
@@ -14,7 +15,8 @@ public record AlbumApplicationResponse(
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
IReadOnlyList<Guid> CoverTrackIds,
AlbumType Type)
{
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
@@ -35,6 +37,8 @@ public record AlbumApplicationResponse(
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? []);
coverTrackIds ?? [],
album.Type);
}
}
}
@@ -1,8 +1,8 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses;
namespace Musify.Application.Albums.Responses
{
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
@@ -17,15 +17,18 @@ public static class AlbumProjections
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count,
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()));
.ToList(),
album.Type));
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
}
@@ -3,31 +3,24 @@ 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;
namespace Musify.Application.Albums
{
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
AlbumType? Type = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
ILogger<UpdateAlbumCommandHandler> logger)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
@@ -41,24 +34,9 @@ public class UpdateAlbumCommandHandler(
album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = request.Description;
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.ReleaseYear = request.ReleaseYear;
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);
}
album.Type = request.Type ?? album.Type;
await database.SaveChangesAsync(cancellationToken);
@@ -71,3 +49,4 @@ public class UpdateAlbumCommandHandler(
.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,8 +3,8 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration;
using Musify.Application.Services;
namespace Musify.Application;
namespace Musify.Application
{
public static class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
@@ -26,3 +26,4 @@ public static class ApplicationDependencyInjection
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,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "Album";
@@ -12,3 +12,4 @@ public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfigurat
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
}
}
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class ApplicationStorageConfiguration : IConfigurationOptions
{
public static string SectionName => "ApplicationStorage";
@@ -10,3 +10,4 @@ public class ApplicationStorageConfiguration : IConfigurationOptions
[StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; }
}
}
@@ -2,8 +2,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
@@ -27,3 +27,4 @@ public static class ConfigurationDependencyInjection
return services;
}
}
}
@@ -1,8 +1,8 @@
using Mediator;
using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
@@ -14,3 +14,4 @@ public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackC
playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds));
}
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public interface IConfigurationOptions
{
public static abstract string SectionName { get; }
}
}
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class MixConfiguration : IConfigurationOptions
{
public static string SectionName => "Mix";
@@ -9,3 +9,4 @@ public class MixConfiguration : IConfigurationOptions
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
}
}
@@ -1,8 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public enum PictureSize
{
Small,
@@ -41,10 +42,10 @@ public class PictureRoutes
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), 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) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
}
public class PictureSizes
@@ -82,9 +83,9 @@ public interface IPictureOwnerConfiguration
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) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size
: PictureSize.Medium;
}
}
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "PlayList";
@@ -12,3 +12,4 @@ public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfigu
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
}
}
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public sealed class PlaybackConfiguration : IConfigurationOptions
{
@@ -15,3 +15,4 @@ public sealed class PlaybackConfiguration : IConfigurationOptions
[Range(1, int.MaxValue)]
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);
}
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class StreamGatewayConfiguration : IConfigurationOptions
{
public static string SectionName => "StreamGateway";
@@ -13,3 +13,4 @@ public class StreamGatewayConfiguration : IConfigurationOptions
[Required]
public string AudioFileName { get; set; } = "audio.m4a";
}
}
@@ -1,8 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "Track";
@@ -48,8 +49,9 @@ public class TrackRoutes : PictureRoutes
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
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) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
}
}
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
namespace Musify.Application.Configuration
{
public class UploadIntentConfiguration : IConfigurationOptions
{
public static string SectionName => "UploadIntent";
@@ -10,10 +10,10 @@ public class UploadIntentConfiguration : IConfigurationOptions
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
@@ -39,3 +39,4 @@ public class UploadIntentConfiguration : IConfigurationOptions
[Range(60, 86400)]
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);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts;
namespace Musify.Application.Contracts
{
public interface IAudioTranscoderService
{
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
}
}
@@ -2,8 +2,8 @@ using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities;
namespace Musify.Application.Contracts;
namespace Musify.Application.Contracts
{
public interface IDatabase
{
public DbSet<User> Users { get; }
@@ -32,9 +32,14 @@ public interface IDatabase
public DbSet<UserFollow> UserFollows { get; }
public DbSet<TrackCoauthor> TrackCoauthors { get; }
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<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
}
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts;
namespace Musify.Application.Contracts
{
public interface IDatabaseTransaction : IAsyncDisposable
{
public Task CommitAsync(CancellationToken cancellationToken = default);
}
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts;
namespace Musify.Application.Contracts
{
public interface IEventBus
{
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 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 Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
@@ -22,3 +21,4 @@ public interface IStorageService
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 StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
}
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);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts;
namespace Musify.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record AlbumProcessingFailed(
Guid AlbumId,
string? Bucket,
string? PictureKey);
}
@@ -1,7 +1,7 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent(
Guid AlbumId,
Guid PictureIntentId,
@@ -9,3 +9,4 @@ public record CreateAlbumResourcesEvent(
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
}
@@ -1,7 +1,7 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record CreatePlayListResourcesEvent(
Guid PlayListId,
Guid PictureIntentId,
@@ -9,3 +9,4 @@ public record CreatePlayListResourcesEvent(
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
}
@@ -1,15 +1,16 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
string? PictureSourceKey,
string? PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
ImageSizes Sizes);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record DeleteAlbumEvent(
Guid AlbumId,
long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record DeletePlayListEvent(
Guid PlayListId,
long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record DeleteTrackEvent(
Guid TrackId,
long UserId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record PlayListProcessingFailed(
Guid PlayListId,
string? Bucket,
string? PictureKey);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,7 +1,8 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record TrackProcessingFailed(
Guid TrackId,
string? Bucket,
string? PictureKey,
string? AudioKey);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent(
Guid AlbumId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
}
@@ -1,7 +1,7 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent(
Guid AlbumId,
Guid PictureIntentId,
@@ -9,3 +9,4 @@ public record UpdateAlbumPictureSourceEvent(
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record UpdatePlayListPictureEvent(
Guid PlayListId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
}
@@ -1,7 +1,7 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record UpdatePlayListPictureSourceEvent(
Guid PlayListId,
Guid PictureIntentId,
@@ -9,3 +9,4 @@ public record UpdatePlayListPictureSourceEvent(
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent(
Guid TrackId,
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationFolderKey);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
namespace Musify.Application.Events
{
public record UpdateTrackPictureEvent(
Guid TrackId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
}
@@ -1,9 +1,9 @@
using Mediator;
using Musify.Domain.ValueObjects;
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
@@ -11,7 +11,7 @@ public class GetAvailableGenresQueryHandler
{
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
.Select(genre => new AvailableGenreResponse(
@@ -22,3 +22,4 @@ public class GetAvailableGenresQueryHandler
return ValueTask.FromResult(result);
}
}
}
@@ -1,11 +1,11 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
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)
@@ -15,7 +15,7 @@ public class GetGenresQueryHandler(IDatabase database)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
@@ -27,3 +27,4 @@ public class GetGenresQueryHandler(IDatabase database)
.ToList();
}
}
}
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
namespace Musify.Application.Genres.Responses
{
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
}
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
namespace Musify.Application.Genres.Responses
{
public record GenreResponse(Genre Genre, int TrackCount);
}
@@ -5,8 +5,8 @@ using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes;
namespace Musify.Application.Likes
{
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
@@ -20,7 +20,7 @@ public class GetLikedTracksQueryHandler(IDatabase database)
{
return await database.TrackLikes
.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)
.ThenBy(like => like.Id)
.Select(like => like.Track)
@@ -28,3 +28,4 @@ public class GetLikedTracksQueryHandler(IDatabase database)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -7,8 +7,8 @@ using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes;
namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>;
@@ -21,7 +21,7 @@ public class ToggleTrackLikeCommandHandler(
{
var trackExists = await database.Tracks
.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)
return AppErrors.NotFound("Track", request.TrackId);
@@ -44,7 +44,6 @@ public class ToggleTrackLikeCommandHandler(
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(
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
if (saved.IsError)
@@ -56,3 +55,4 @@ public class ToggleTrackLikeCommandHandler(
return nowLiked;
}
}
}
@@ -6,8 +6,8 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes;
namespace Musify.Application.Mixes
{
public record GenerateMixesForUserCommand(long UserId)
: ICommand;
@@ -25,7 +25,7 @@ public class GenerateMixesForUserCommandHandler(
var libraryTrackIds = await database.Tracks
.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)
.ToListAsync(cancellationToken);
@@ -79,7 +79,7 @@ public class GenerateMixesForUserCommandHandler(
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. 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);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
@@ -128,3 +128,4 @@ public class GenerateMixesForUserCommandHandler(
}
}
}
}
@@ -5,8 +5,8 @@ using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes;
namespace Musify.Application.Mixes
{
public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>;
@@ -29,3 +29,4 @@ public class GetMixByIdQueryHandler(IDatabase database)
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
}
}
}
@@ -3,8 +3,8 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes;
namespace Musify.Application.Mixes
{
public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>;
@@ -25,7 +25,13 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
mix => mix.Id,
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))
.ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
@@ -35,7 +41,7 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = orderedItemsByMix[mix.Id];
var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId))
@@ -48,3 +54,4 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
return responses;
}
}
}
@@ -4,11 +4,27 @@ using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes;
namespace Musify.Application.Mixes
{
internal static class MixItemMapper
{
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
IDatabase database,
IEnumerable<MixItem> items,
CancellationToken cancellationToken)
{
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
var visible = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
return [.. visible];
}
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
@@ -19,7 +35,7 @@ internal static class MixItemMapper
var tracksById = await database.Tracks
.AsNoTracking()
.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) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
@@ -38,3 +54,4 @@ internal static class MixItemMapper
.ToList();
}
}
}
@@ -1,7 +1,7 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse(
Guid Id,
MixKind Kind,
@@ -10,3 +10,4 @@ public record MixApplicationResponse(
{
public const int CoverItemCount = 4;
}
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse(
Guid TrackId,
string Title,
string? Artist,
double DurationSeconds,
int ListensCount);
}
@@ -7,8 +7,8 @@ using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
namespace Musify.Application.Pictures
{
public enum CoverOwner
{
Track,
@@ -35,10 +35,7 @@ public class GetCoverQueryHandler(
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => (await database.Tracks.AsNoTracking()
.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.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
@@ -60,7 +57,6 @@ public class GetCoverQueryHandler(
_ => source.Medium
};
// The resized files only exist once processing has finished; until then serve the original.
var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original)
@@ -72,4 +68,34 @@ public class GetCoverQueryHandler(
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,9 +5,8 @@ using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
namespace Musify.Application.Pictures
{
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
@@ -33,3 +32,4 @@ public static class PictureSourceChange
owner.PicturesSizes.ToImageSizes(owner.Routes));
}
}
}
@@ -1,5 +1,5 @@
namespace Musify.Application.Pictures.Responses;
namespace Musify.Application.Pictures.Responses
{
public record PictureUploadResponse(
Guid IntentId,
string Bucket,
@@ -8,3 +8,4 @@ public record PictureUploadResponse(
string ContentType,
int ExpiresInSeconds,
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,9 +7,8 @@ using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
namespace Musify.Application.PlayLists
{
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
@@ -28,7 +27,7 @@ public class AddTracksToPlayListCommandHandler(
var existingIds = await database.Tracks
.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)
.ToListAsync(cancellationToken);
@@ -46,10 +45,12 @@ public class AddTracksToPlayListCommandHandler(
if (toAdd.Count == 0)
return Result.Success;
// Playlist positions are internal indexes, so they start at 0.
var nextPosition = (await database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId)
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
var playListTracksQuery = database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId);
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in toAdd)
{
@@ -71,3 +72,4 @@ public class AddTracksToPlayListCommandHandler(
return Result.Success;
}
}
}
@@ -12,8 +12,8 @@ using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record CreatePlayListCommand(
long UserId,
string Name,
@@ -39,10 +39,10 @@ public class CreatePlayListCommandHandler(
return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId)
if (request.PictureIntentId.HasValue)
{
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)
return prepared.Errors;
@@ -76,3 +76,4 @@ public class CreatePlayListCommandHandler(
return PlayListApplicationResponse.FromEntity(playList);
}
}
}
@@ -6,8 +6,8 @@ using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>;
@@ -34,3 +34,4 @@ public class DeletePlayListCommandHandler(
return Result.Success;
}
}
}
@@ -5,8 +5,8 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
@@ -28,3 +28,4 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
: playList;
}
}
}
@@ -7,8 +7,8 @@ using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
@@ -31,7 +31,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
return await database.PlayListHasTracks
.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)
.ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
@@ -39,3 +39,4 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -4,8 +4,8 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record GetPlayListsByUserIdQuery(
long UserId,
string? Name,
@@ -38,3 +38,4 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -5,8 +5,8 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
@@ -43,3 +43,4 @@ public class RemoveTrackFromPlayListCommandHandler(
return Result.Success;
}
}
}
@@ -5,8 +5,8 @@ using Musify.Application.Pictures.Responses;
using Musify.Application.Services;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
namespace Musify.Application.PlayLists
{
public record RequestPlayListPictureUploadCommand(
long UserId,
string FileType,
@@ -22,3 +22,4 @@ public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issue
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -2,8 +2,8 @@ using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses;
namespace Musify.Application.PlayLists.Responses
{
public record PlayListApplicationResponse(
Guid Id,
string Name,
@@ -43,3 +43,4 @@ public record PlayListApplicationResponse(
durationSeconds);
}
}
}
@@ -1,8 +1,8 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses;
namespace Musify.Application.PlayLists.Responses
{
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
@@ -25,13 +25,15 @@ public static class PlayListProjections
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
}
@@ -2,32 +2,24 @@ 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;
namespace Musify.Application.PlayLists
{
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlayListVisibility? NewVisibility = null)
PlayListVisibility NewVisibility)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
ILogger<UpdatePlayListCommandHandler> logger)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
@@ -38,34 +30,10 @@ public class UpdatePlayListCommandHandler(
var playList = found.Value;
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playList.Name = request.NewName.Trim();
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();
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);
}
playList.Visibility = request.NewVisibility;
await database.SaveChangesAsync(cancellationToken);
@@ -78,3 +46,4 @@ public class UpdatePlayListCommandHandler(
.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