236 Commits
Author SHA1 Message Date
AdminandClaude Opus 4.8 a5305b8ed8 feat(infra): playlist processing saga + generalize fault handling (phase 3)
Adds the PlayList picture-processing lifecycle saga (correlated by PlayListId)
mirroring the track one, and generalizes the failure plumbing:
- Rename the track fault translator to ProcessingSlipFaultConsumer and use a
  generic Workflow.SubjectId variable + ProcessKind; it now emits typed
  Track{Picture,Audio}/PlayListPicture ProcessingFailed events for all slips
- PlayListProcessingStateMachine (+ EF-persisted PlayListProcessingState,
  migration): starts on CreatePlayListResourcesEvent, finalizes on
  PlayListPictureProcessed, and on failure marks the playlist Failed and
  removes the orphaned final picture (PlayListProcessingFailedConsumer +
  MarkPlayListAsFailedActivity)
- UpdatePlayListPictureActivity publishes PlayListPictureProcessed on success
- Picture slip carries SubjectId + PlayListPicture kind + fault subscription

Verified end-to-end in Docker (RabbitMQ + migrated Postgres): both saga
endpoints, the fault translator and cleanup consumers configure, and an
addressing cross-check confirms all 21 activity execute queues the builders
address exist (caught + fixed a kebab mismatch on mark-play-list-as-failed).

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:40 +02:00
AdminandClaude Opus 4.8 8664bfb800 feat(infra): track saga failure handling + cleanup (phase 2)
On definitive failure of the picture or audio slip, the saga now reaches a
Failed state and recovers end-to-end:
- Picture/audio track slips carry TrackId + ProcessKind variables and a
  subscription to a fault translator (TrackProcessingSlipFaultConsumer) that
  turns RoutingSlipFaulted into a typed Track{Picture,Audio}ProcessingFailed
- The saga stores the final object keys from the start event and, on failure,
  transitions to Failed and publishes TrackProcessingFailed
- TrackProcessingFailedConsumer runs a cleanup slip that removes the orphaned
  final picture/audio copies and marks the track LifeCycleStatus=Failed
  (MarkTrackAsFailedActivity) — the track stays as a visible, recoverable record
- Migration for the new saga columns

Verified: Worker boots against RabbitMQ + Postgres in Docker; the new
endpoints (fault translator, failed consumer, mark-track-as-failed) configure
and the bus starts clean.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:49:33 +02:00
AdminandClaude Opus 4.8 986941166c feat(infra): add Track processing saga (state machine, phase 1)
Introduce a MassTransit saga state machine that correlates by TrackId and
tracks the two parallel processing slips (picture + audio):
- TrackProcessingStateMachine + EF-persisted TrackProcessingState
  (pessimistic concurrency, EntityFrameworkRepository), migration added
- Starts on CreateTrackResourcesEvent; UpdateTrack{Picture,Audio}Activity now
  publish TrackPictureProcessed / TrackAudioProcessed on success; when both
  arrive the saga finalizes (Ready). Failure transitions to a Failed state
  (cleanup wired in the next phase)
- Add LifeCycleStatus.Failed and the four outcome events

Verified: booted the Worker against RabbitMQ + migrated Postgres in Docker;
the saga endpoint 'track-processing-state' is configured and the bus starts
clean.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:41:19 +02:00
AdminandClaude Opus 4.8 3545d71016 refactor(infra): auto-configure MassTransit endpoints via ConfigureEndpoints
Replace ~240 lines of hand-written per-endpoint wiring with MassTransit's
ConfigureEndpoints + KebabCaseEndpointNameFormatter, and apply retry + EF
outbox globally through AddConfigureEndpointsCallback. Adding a consumer or
activity is now a single AddConsumer/AddActivity line.

- Normalize the 4 mismatched activity endpoint names to kebab-case so the
  routing-slip addressing keeps resolving (update-play-list-picture,
  delete/mark/publish play-list variants); cleanup consumer queue is now
  routing-slip-clean-up
- Drop the now-unused QueueName consts from event consumers and trim
  EndpointHelper / delete the manual endpoint-config partial

Verified end-to-end: booted the Worker against RabbitMQ in Docker and
confirmed all 38 endpoints are configured with the exact names the builders
address (19 execute queues + compensations + consumers + routing-slip-clean-up).

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:22:26 +02:00
AdminandClaude Opus 4.8 d08d82a483 fix(infra): repair TransferFiles compensation + tidy MassTransit consistency
Fix (recoverability bug):
- TransferFilesToBucketActivity returned Completed() with no log, so its
  compensation could never delete the transferred files (orphaned objects on
  fault). IStorageService.TransferFilesAsync now returns the real uploaded
  keys; the activity logs them so Compensate can remove them. Also drops the
  Path.Combine key recomputation (produced backslash keys on Windows).

Consistency / DRY:
- Normalize all courier activities to internal + folder-based namespaces
  (several were public and in the flat .Activities namespace)
- Extract the duplicated exponential retry into UseStandardRetry
- Unify endpoint-name logic into a single EndpointHelper (remove the parallel
  MassTransitEndpointNames helper)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:58:27 +02:00
AdminandClaude Opus 4.8 423e06baf9 refactor(application): fold Pagination into Shared, rename assembly marker
- Move PaginatedResponse from the single-file Pagination/ folder into Shared/
  (namespace Musify.Application.Pagination -> Musify.Application.Shared)
- Rename MusifyApplication.cs -> AssemblyInfo.cs (it only holds the
  [assembly: MediatorOptions] attribute)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:38:12 +02:00
AdminandClaude Opus 4.8 38f7094fb0 refactor(di): add AddApplication entry point and generic AddValidatedOptions
- Add Musify.Application.AddApplication(services, config) as the layer's DI
  entry point: registers Mediator, UploadIntentValidator and all app configs
- Collapse the 5 near-identical AddXConfiguration methods into a single
  generic AddValidatedOptions<T>(config, section)
- Move UploadIntentValidator registration out of Infrastructure into the
  Application layer (fixes layering leak); Infra config binding now uses the
  generic helper
- Api calls AddApplication; Worker uses AddValidatedOptions for its configs

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:36:46 +02:00
AdminandClaude Opus 4.8 e802c8565d refactor(application): co-locate each command/query with its handler
One file per use case under the feature folder (Tracks/PlayLists/Users),
dropping the Commands/Queries/Handlers sub-folders and their namespaces.
Each file now holds the record and its handler in namespace
Musify.Application.<Feature>; external usings updated accordingly.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:12:59 +02:00
AdminandClaude Opus 4.8 6e469dbb4a refactor: replace Ardalis.Result with ErrorOr across the app
Ardalis Result<T> carried incoherent success sub-statuses and a clunky
generic converter; ErrorOr models success-or-errors cleanly.

- Application: all commands/queries/handlers now return ErrorOr<T>
  (non-generic Result -> ErrorOr<Success>); errors via Error.NotFound/
  Conflict/Validation/Unauthorized/Failure; success via value or new Success()
- Drop ResultExtensions.As<>(): error propagation is now just return .Errors
- Rewrite HTTP mapping (ErrorOrHttpExtensions) off Error.Type; success maps
  to 200 (value) or 204 (Success); Created still built in the endpoints
- Endpoints/gateway middleware: !IsSuccess -> IsError
- Swap Ardalis.Result package for ErrorOr 2.1.1 (remove unused ref from Worker)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:05:43 +02:00
AdminandClaude Opus 4.8 a3bf796f17 chore(backend): install AI agent skills for the .NET stack
Run autoskills in backend/: detects .NET/C#/ASP.NET Core/Minimal API and
installs 13 skills for universal (.agents) and claude-code (.claude) agents,
with backend/skills-lock.json. Adds ASP.NET Core, Minimal API + OpenAPI,
file-upload and containerization skills on top of the prior C#/.NET set.
Removes the stale root skills from the old layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:48:31 +02:00
AdminandClaude Opus 4.8 8f34ed4874 chore: group .NET into backend/ and drop non-.NET test artifacts
- Move all .NET (6 projects, solution, Directory.*.props, nuget.config)
  into backend/ so the monorepo root can host non-.NET projects later
- Remove throwaway test artifacts: player.html, pl.json
- Trim dead Flutter rules from .gitignore

Build from backend/Musify.slnx: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:43:52 +02:00
AdminandClaude Opus 4.8 d9c8c17b21 refactor: central package management, shared build props, security fix
- Add Directory.Packages.props (CPM): all package versions centralized,
  remove Version attributes from every PackageReference
- Add Directory.Build.props: shared TargetFramework/Nullable/ImplicitUsings
- Drop redundant per-project RootNamespace/AssemblyName (default = project name)
  and stale NuGet metadata (PackageId/Version/Authors/RepositoryUrl)
- Bump Microsoft.OpenApi to 2.9.0 (CVE-2026-49451, was transitive 2.0.0);
  add explicit reference in Musify.Api which uses it directly
- Add repo-scoped nuget.config pinning nuget.org for reproducible restore

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:40:05 +02:00
AdminandClaude Opus 4.8 f6d668d201 refactor: standardize project names to Musify.* and flatten layout
- Flatten nested per-repo folders into flat Musify.* projects at root
- Rename assemblies/csproj: Domain->Musify.Domain, Application->Musify.Application,
  Infrastructure->Musify.Infrastructure, WebApi->Musify.Api, Worker->Musify.Worker,
  StreamingGateway->Musify.StreamingGateway
- Rename WebApi.* namespaces to Musify.Api.*
- Update all ProjectReference paths and the solution file
- Consolidate stray per-repo dotfiles; add root .gitattributes
- Move flutter client out to _archive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:32:48 +02:00
AdminandClaude Opus 4.8 8ab4d27287 chore: move flutter client out of monorepo into _archive
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:26:36 +02:00
AdminandClaude Opus 4.8 4b80c7dbb0 chore: bring flutter client, docs, deploy and tools into monorepo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:22:24 +02:00
AdminandClaude Opus 4.8 8c0519d125 feat: add unified Musify solution spanning all projects
Groups Domain/Application/Infrastructure under Core and
WebApi/Worker/StreamingGateway under Hosts. Removes the per-repo
.slnx files now superseded by the single solution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:21:32 +02:00
Admin 27922f2d14 chore: import Musify.StreamingGateway into monorepo (preserving history)
git-subtree-dir: Musify.StreamingGateway
git-subtree-mainline: 28426571e6
git-subtree-split: a8ff05892d
2026-07-05 03:20:50 +02:00
Admin 28426571e6 chore: import Musify.Worker into monorepo (preserving history)
git-subtree-dir: Musify.Worker
git-subtree-mainline: c3cdd3bc0f
git-subtree-split: 868860fff9
2026-07-05 03:20:48 +02:00
Admin c3cdd3bc0f chore: import Musify.WebApi into monorepo (preserving history)
git-subtree-dir: Musify.WebApi
git-subtree-mainline: 9d433868cc
git-subtree-split: ca8750c3b4
2026-07-05 03:20:47 +02:00
Admin 9d433868cc chore: import MusifyBackend into monorepo (preserving history)
git-subtree-dir: MusifyBackend
git-subtree-mainline: 6829e769ed
git-subtree-split: ed7b00c2cc
2026-07-05 03:20:43 +02:00
AdminandClaude Opus 4.8 6829e769ed chore: initialize Musify monorepo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:20:11 +02:00
AdminandClaude Opus 4.8 ca8750c3b4 chore: preserve local dev config before monorepo import
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:19:56 +02:00
Admin ed7b00c2cc Adjust 2026-06-03 00:32:37 +02:00
Admin e3b7a0353e Set Content-Type on transferred objects so .m4a is served as audio/mp4 2026-06-02 19:26:45 +02:00
Admin a8ff05892d Serve audio .m4a by range; remove DASH manifest rewriting 2026-06-02 19:05:19 +02:00
Admin 47c3a431c2 Point streaming config to single .m4a; drop DASH ffmpeg settings 2026-06-02 19:05:18 +02:00
Admin b737d59cbe Migrate audio transcoding from DASH to a single AAC (.m4a) file 2026-06-02 19:05:11 +02:00
Admin 2978b69654 Fix bugs 2026-06-01 22:48:42 +02:00
Admin 69eeefd58f Simplify user sync: rely on EF change tracking instead of manual hasChanges flag 2026-06-01 22:11:48 +02:00
Admin 30f57ff50e Auto-set CreatedAt/UpdatedAt via EF interceptor; standardize timestamp names across entities 2026-06-01 22:04:52 +02:00
Admin 55c73cbb3d Changes 2026-06-01 20:50:46 +02:00
Admin e59a5fa8ef Remove checks 2026-06-01 20:41:34 +02:00
Admin bd010e0330 Link created track to its owner via UserHasTrack 2026-06-01 19:07:33 +02:00
Admin 45999af9f8 Add endpoint to list paginated tracks of a playlist 2026-06-01 18:54:26 +02:00
Admin ca4d6c5ed2 Add paginated query for tracks of a playlist; make PlayListHasTrack.Track navigation non-nullable 2026-06-01 18:54:25 +02:00
Admin 58fda0ba9a Extract storage key building into a shared StorageKey helper 2026-06-01 16:45:37 +02:00
Admin 032906de6b Sync user profile (incl. picture) from token claims on validation 2026-06-01 01:47:47 +02:00
Admin 5c5fab86a5 Add user profile picture URL synced from the identity provider on login 2026-06-01 01:47:47 +02:00
Admin ba093e7fd5 Add playlist endpoints to add and remove tracks 2026-06-01 01:20:18 +02:00
Admin 842dd13efa Add and remove tracks from playlists (commands + handlers with ownership checks) 2026-06-01 01:20:17 +02:00
Admin 55549818af Cleanup 2026-06-01 00:24:41 +02:00
Admin 868860fff9 Initial commit: Musify.Worker background worker (MassTransit consumers for picture/audio processing) 2026-06-01 00:19:45 +02:00
Admin 3fea58cd72 Remove unused usings 2026-05-31 19:28:08 +02:00
Admin 03d9fa054f Allow any CORS origin in Development 2026-05-31 19:25:15 +02:00
Admin c1fc0ed1a1 Return 400 for validation errors without identifier and enable permissive CORS in Development 2026-05-31 19:25:15 +02:00
Admin 253a67aabf Fix audio DASH workflow: dispose source stream before delete and store S3 folder key as AudioFolderName 2026-05-31 19:25:15 +02:00
Admin e4452843e7 Use long 2026-05-31 18:25:40 +02:00
Admin 69cc73c374 Use long 2026-05-31 18:25:39 +02:00
Admin 9772879e67 Fixes on scalar 2026-05-30 03:44:32 +02:00
Admin bd92928366 Proxy 2026-05-30 03:30:32 +02:00
Admin 46985bf062 Proxy 2026-05-30 03:29:21 +02:00
Admin 834d7a25c1 Use development 2026-05-30 02:30:18 +02:00
Admin dc1d3c2269 Remove unused using 2026-05-30 02:27:12 +02:00
Admin 67d340fcaa BugFix 2026-05-30 02:26:33 +02:00
Admin 47bff00268 Changes 2026-05-30 02:24:55 +02:00
Admin f20391391a Add project files. 2026-05-30 02:16:39 +02:00
Admin f9aaa1a1fe Add .gitattributes and .gitignore. 2026-05-30 02:16:37 +02:00
Admin b6754a0e76 Update endpoint 2026-05-29 22:42:48 +02:00
Admin 3313560e6c ?? 2026-05-29 22:37:51 +02:00
Admin 8455033e52 Use if condition 2026-05-29 22:37:32 +02:00
Admin 7cb1851b0d Use null 2026-05-29 22:36:33 +02:00
Admin 036dcf72a7 Current User 2026-05-29 22:29:54 +02:00
Admin 39be51304b Added auth 2026-05-29 16:02:34 +02:00
Admin 335e1a785e Remove tests 2026-05-29 15:47:38 +02:00
Admin 5751b9ea08 Remove docs and agents 2026-05-29 15:45:53 +02:00
Admin 5765982a4d Remove tests 2026-05-29 15:45:31 +02:00
Admin ee18fe2ee1 Remove tests 2026-05-29 15:44:20 +02:00
Admin fd41053030 Small changes 2026-05-29 13:49:49 +02:00
Admin af2cf03fb4 Added tests 2026-05-29 13:49:44 +02:00
Admin 1efdbc237a Added tests 2026-05-29 13:37:34 +02:00
Admin be3f832e11 PascalCase 2026-05-29 02:28:35 +02:00
Admin 275c001f1f PascalCase 2026-05-29 02:26:37 +02:00
Admin 285668efc7 Rename 2026-05-29 01:57:28 +02:00
Admin 10124f0184 Rename 2026-05-29 01:57:27 +02:00
Admin b6dea8b041 Changes 2026-05-29 01:48:48 +02:00
Admin 585af60371 Changes 2026-05-29 01:48:46 +02:00
Admin 4dbd87f5d9 Remove unused usings 2026-05-29 00:25:26 +02:00
Admin 05c53058da Fixes and config 2026-05-28 23:49:09 +02:00
Admin 99d202f143 Remove keycloak 2026-05-28 23:16:35 +02:00
Admin de66cb0f48 Remove keycloak 2026-05-28 23:16:30 +02:00
Admin 52a561d52a Change 2026-05-28 23:14:32 +02:00
Admin 3b79b399ce Change on filter 2026-05-28 22:06:09 +02:00
Admin 64653f4fcd validators 2026-05-28 21:53:46 +02:00
Admin 9a3e7d4bb5 Remove guid as is unvalid 2026-05-28 21:49:18 +02:00
Admin 9ecb16f888 Update endpoints 2026-05-28 21:44:28 +02:00
Admin 036786bd59 Update packages 2026-05-28 21:37:46 +02:00
Admin f761405771 Add project files. 2026-05-28 21:35:31 +02:00
Admin f268bc2294 Add .gitattributes and .gitignore. 2026-05-28 21:35:29 +02:00
Admin 130f23f841 Change on files 2026-05-28 21:07:49 +02:00
Admin d35e8eb5f7 PreventOverwrite 2026-05-28 21:04:09 +02:00
Admin 94bf9c61a3 Update structure 2026-05-28 20:42:38 +02:00
Admin ffa3a73bb8 Update structure 2026-05-28 20:42:35 +02:00
Admin 25accf3456 Update structure 2026-05-28 20:41:03 +02:00
Admin 2769c7f38f 1 2026-05-28 18:37:54 +02:00
Admin 6fa7c24516 Changes 2026-05-14 22:22:47 +02:00
Admin e64aa4e90a Compilation fixes 2026-05-14 22:15:26 +02:00
Admin 4cb308b78d Fixes and a lot of changes 2026-05-14 22:13:22 +02:00
Admin 348f5cb813 Changes 2026-05-14 21:42:31 +02:00
Admin deb4bfda90 Generated response 2026-05-14 19:53:26 +02:00
Admin 7aef20fe71 Update plans 2026-05-14 18:48:00 +02:00
Admin 5a00a2478e Update delete workflows 2026-05-14 18:24:20 +02:00
Admin f2febb898e A lot of changes 2026-05-14 14:32:19 +02:00
Admin 7160266763 Bump version 2026-05-14 01:32:29 +02:00
Admin 1ed2ec8d93 Update migrations 2026-05-14 01:06:30 +02:00
Admin 0e65103af2 Renames 2026-05-14 01:05:07 +02:00
Admin f6ac45cb79 Safe code and changes 2026-05-14 01:00:44 +02:00
Admin 1ecf4a872e Remove processingStatus 2026-05-13 21:56:34 +02:00
Admin fa6eaf5a2b Reworks 2026-05-13 02:20:22 +02:00
Admin 57a5358701 Remove keycloak and add missing functions 2026-05-09 19:36:49 +02:00
Admin b28edbbd3a Update routes and changes on queries 2026-05-09 19:09:10 +02:00
Admin be2bbea4cc Changes on routes 2026-05-09 18:57:16 +02:00
Admin 4da960d9a1 AsNoTracking 2026-05-09 18:49:39 +02:00
Admin e5099343d6 Fix stringLength 2026-05-09 18:47:57 +02:00
Admin db78df7f0e Change 2026-05-09 02:56:43 +02:00
Admin 3f10613df0 Update grafana 2026-05-08 21:48:31 +02:00
Admin 5aa8e1db60 Version bump 2026-04-01 09:50:53 +02:00
Admin 049e4723cd UTC 2026-04-01 09:50:30 +02:00
Admin 9585d8f34c Missing configuration DI 2026-03-30 15:55:44 +02:00
Admin c30dfd6923 Version 2026-03-30 10:20:32 +02:00
Admin 7a219e4dea Rename 2026-03-30 10:12:51 +02:00
Admin c6e0c45a50 Remove unused file 2026-03-30 10:07:08 +02:00
Admin ce278dc935 Configure packages 2026-03-30 09:51:59 +02:00
Admin 093b84e660 Remove 2026-03-30 09:43:53 +02:00
Admin 795f8fdab0 Configuration mediator 2026-03-29 20:37:40 +02:00
Admin b9f03eb03c Remove DispatchR and use Mediator 2026-03-29 20:17:32 +02:00
Admin 9e4d5158e8 Fixes 2026-03-29 19:10:24 +02:00
Admin 12aa104d80 Fix and missing service 2026-03-29 18:59:50 +02:00
Admin 164fb5f29a Configurations 2026-03-29 18:31:21 +02:00
Admin f92b1b93ea Rename 2026-03-29 18:26:44 +02:00
Admin e983f5af4d Format 2026-03-29 18:23:13 +02:00
Admin b433060deb Map 2026-03-29 18:21:56 +02:00
Admin 3e54b5db43 Experimental changes 2026-03-29 17:15:39 +02:00
Admin 7d4265d47c Spaces 2026-03-29 00:59:27 +01:00
Admin 2518101a52 Changes on messages 2026-03-28 21:26:45 +01:00
Admin 4fa341d2c8 Rewrite handlers 2026-03-28 20:47:54 +01:00
Admin 06ef70cb3b Renames and changes 2026-03-28 15:32:36 +01:00
Admin d2a1807883 Rename variables 2026-03-28 03:27:31 +01:00
Admin ffaf9990df Changes 2026-03-28 02:57:08 +01:00
Admin c78ec50036 Ops 2026-03-28 02:56:18 +01:00
Admin b9ad1a87a8 Fix 2026-03-28 02:56:08 +01:00
Admin bc9fdad098 Renames 2026-03-27 23:07:05 +01:00
Admin 32b83e84a6 Fixed routes and bugs 2026-03-27 23:06:17 +01:00
Admin 075ff3c7b6 Changes on routes 2026-03-27 21:16:38 +01:00
Admin 9b1ae1a7a1 TransferFiles 2026-03-27 18:07:17 +01:00
Admin 7ffd63ed9a Use variable 2026-03-26 19:59:44 +01:00
Admin 606fa26c3f Missing log 2026-03-26 18:24:11 +01:00
Admin 0384de792b Upload file activity 2026-03-26 18:23:59 +01:00
Admin 6c413f5174 Update paths 2026-03-25 19:40:07 +01:00
Admin b201a4a7af Fix namespaces 2026-03-25 18:12:08 +01:00
Admin cbea2ad9dc Missing files 2026-03-25 18:09:37 +01:00
Admin d3dbde4fec File ignored 2026-03-25 10:41:23 +01:00
Admin 3dea06b0cc Fixes 2026-03-25 02:13:47 +01:00
Admin 6e06d7673d Rename 2026-03-25 00:35:23 +01:00
Admin 10a66c70c8 Delete temporal folders and some changes 2026-03-24 20:52:44 +01:00
Admin 0355bd22a6 Format 2026-03-24 19:55:31 +01:00
Admin bf23224a51 Renames and migrations 2026-03-24 19:47:32 +01:00
Admin 01093abced Changes 2026-03-24 19:32:43 +01:00
Admin 22a65b7efa Changes 2026-03-24 14:45:20 +01:00
Admin 4f5230ddf6 Changes 2026-03-24 14:45:04 +01:00
Admin 510f52a034 TempPath 2026-03-23 19:30:20 +01:00
Admin 0872b8df6f Change on routes 2026-03-23 19:17:15 +01:00
Admin 7a81341218 Logger 2026-03-23 17:22:47 +01:00
Admin 75cb09dbf2 Changes 2026-03-23 17:16:01 +01:00
Admin d62d3441db Refactor audioTranscoder 2026-03-23 17:14:49 +01:00
Admin f1b7191ccc Cambios 2026-03-23 16:53:23 +01:00
Admin 788fb6fa5c Use variables 2026-03-23 16:28:02 +01:00
Admin fab98098c8 Collection expression 2026-03-23 16:13:26 +01:00
Admin 4e089c4da8 Download once 2026-03-23 09:48:15 +01:00
Admin 7c7f118a43 Comments 2026-03-23 09:38:02 +01:00
Admin 9eb6fa826e Changes but failing 2026-03-23 03:13:08 +01:00
Admin d771c6c0a7 Missing file 2026-03-22 04:02:50 +01:00
Admin ff64f1f136 Staged 2026-03-22 04:02:30 +01:00
Admin 4215fb483a First iteration 2026-03-22 04:02:17 +01:00
Admin d4af39f53b Audio Transcoder 2026-03-21 22:23:31 +01:00
Admin 951e42d21a Handler 2026-03-21 21:01:57 +01:00
Admin a98969ec3a Refactor 2026-03-21 20:38:13 +01:00
Admin 2c3b84a233 Move 2026-03-21 20:33:15 +01:00
Admin 801bf08337 Queries y algunos cambios 2026-03-21 04:00:12 +01:00
Admin 1bff5e62ea A lot of changes 2026-03-21 03:45:29 +01:00
Admin df4f4bdf66 Routes 2026-03-21 00:04:19 +01:00
Admin 088016f986 Message 2026-03-20 23:26:49 +01:00
Admin 32c1e3b842 Nullable 2026-03-20 23:24:32 +01:00
Admin 285b93a9be Grafana 2026-03-20 22:28:32 +01:00
Admin 8ce3e68b81 OT 2026-03-20 22:18:37 +01:00
Admin 1bd8c8779d Config 2026-03-20 21:45:36 +01:00
Admin b34a711d2d Rework jobs 2026-03-20 21:40:05 +01:00
Admin 93a975e9b5 Last consumer 2026-03-20 20:33:56 +01:00
Admin 62283bd403 Renames 2026-03-20 20:24:46 +01:00
Admin eda9bef828 Refactor 2026-03-20 20:24:10 +01:00
Admin 9756672160 Changes 2026-03-20 20:09:36 +01:00
Admin af061bc6fc Rename 2026-03-20 12:27:45 +01:00
Admin b5955fda5b Changes 2026-03-20 12:22:07 +01:00
Admin 17944b5344 Refactor 2026-03-20 12:10:54 +01:00
Admin dac5096998 Error message 2026-03-20 12:01:08 +01:00
Admin eb822ff1bf Rename 2026-03-20 11:57:16 +01:00
Admin 031fdb7f46 Refactor 2026-03-20 11:11:45 +01:00
Admin 0b8257a346 Fix job 2026-03-20 10:43:52 +01:00
Admin 676910e462 IEventBus 2026-03-20 09:53:54 +01:00
Admin 18da740e08 Keycloak service 2026-03-20 09:42:05 +01:00
Admin 3c21e0ea1a Rename 2026-03-20 09:41:43 +01:00
Admin a036454220 MaxLength 2026-03-20 09:32:42 +01:00
Admin 535b6e457b Spaces 2026-03-20 01:59:48 +01:00
Admin 4343542dce JobExecution 2026-03-20 01:58:45 +01:00
Admin 58b0974bec [NotMapped] 2026-03-20 01:37:51 +01:00
Admin f3edcd0af4 States 2026-03-20 01:37:34 +01:00
Admin a7203dff02 Missing if 2026-03-20 01:36:22 +01:00
Admin 4387aa146e QOL Changes 2026-03-20 01:35:12 +01:00
Admin 716558c085 A lot of changes 2026-03-19 20:21:02 +01:00
Admin e89fc5b45c Update playlist and some changes 2026-03-19 19:50:15 +01:00
Admin 9f90151fc5 Changes on config and DI 2026-03-19 17:22:27 +01:00
Admin cc720e2c08 Config changes 2026-03-19 17:12:08 +01:00
Admin 4996fd019e Changes 2026-03-19 10:41:32 +01:00
Admin d9cb715622 Add DesignSettings 2026-03-19 09:41:48 +01:00
Admin bc751a467b Fix 2026-03-19 09:33:39 +01:00
Admin f5643c0ccd . 2026-03-19 09:27:22 +01:00
Admin 0c04ddf316 Remove files consumer 2026-03-19 01:20:05 +01:00
Admin 14ae57ead8 jobs and others 2026-03-19 01:09:02 +01:00
Admin f41a6c2d77 Some changes 2026-03-18 21:05:06 +01:00
Admin 28b46e3cc4 Change name 2026-03-18 20:52:11 +01:00
Admin 41b8cbfc9c Small changes 2026-03-18 19:07:01 +01:00
Admin 4f74192cb6 Changes 2026-03-18 18:59:41 +01:00
Admin 9f14bb2c86 Changes 2026-03-18 18:48:01 +01:00
Admin b1ac38d377 Changes 2026-03-18 18:08:53 +01:00
Admin afcdaea4fd username and password 2026-03-18 17:42:16 +01:00
Admin 9147c2c799 Dependency injection 2026-03-18 17:17:37 +01:00
Admin 463d1a6600 Rename 2026-03-18 16:43:34 +01:00
Admin dee4bd53ab Rename to picture 2026-03-18 16:29:19 +01:00
Admin 6ffec218b3 Rename 2026-03-18 16:15:50 +01:00
Admin b1a0eeeb22 Changes 2026-03-18 16:06:57 +01:00
Admin 9816e23401 Config 2026-03-18 15:50:18 +01:00
Admin 076f048efe Changes and fixes 2026-03-18 10:52:00 +01:00
Admin 92bd06751d Upload playlist image 2026-03-18 10:26:40 +01:00
Admin 63c89c1946 Using 2026-03-18 02:22:44 +01:00
Admin 3a45cc27d1 Uploads 2026-03-18 02:19:36 +01:00
Admin 82ddf7dea6 PictureHandler 2026-03-18 01:24:16 +01:00
Admin 28719fe4d1 First Commit 2026-03-18 01:14:49 +01:00
801 changed files with 1556 additions and 75187 deletions
-24
View File
@@ -1,24 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "musify-web",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "web-player", "run", "dev"],
"port": 3000,
"autoPort": true
},
{
"name": "musify-api",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
"port": 5111
},
{
"name": "musify-streaming-gateway",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"],
"port": 8081
}
]
}
-7
View File
@@ -4,13 +4,6 @@
# Force LF for shell scripts # Force LF for shell scripts
*.sh text eol=lf *.sh text eol=lf
# Env templates get `cp`-ed straight into deploy/.env / deploy/.env.prod,
# which containers (e.g. zitadel-init in deploy/compose.yml) parse with
# plain shell text tools — keep them LF so a Windows checkout can't
# reintroduce CRLF into a value that then looks non-empty but isn't.
.env.example text eol=lf
.env.prod.example text eol=lf
# Treat common binaries as binary # Treat common binaries as binary
*.png binary *.png binary
*.jpg binary *.jpg binary
-127
View File
@@ -1,127 +0,0 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+4
View File
@@ -0,0 +1,4 @@
# Copilot Instructions
## Project Guidelines
- No crear pruebas/tests si el usuario no lo solicita explícitamente; evitar cambios no pedidos.
+1 -13
View File
@@ -7,9 +7,6 @@ obj/
*.userosscache *.userosscache
.vs/ .vs/
artifacts/ artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS # Rider / VS
.idea/ .idea/
@@ -18,14 +15,5 @@ TestResults/
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Environment files (deploy/.env, deploy/.env.prod, web-player/.env, ...) # Secrets / local
.env
.env.*
*.env *.env
!.env.example
!.env.prod.example
# Deploy secrets and runtime state
deploy/keys/
deploy/secrets/
deploy/zitadel/.output/
-55
View File
@@ -1,55 +0,0 @@
# Musify
A music streaming app. Upload your own tracks, organize them into playlists
and albums, and stream them back with a web player built around your
listening habits: auto-generated mixes, a home feed, search, likes, and an
Explore page for finding something new.
## What it does
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
- **Playlists**, likes, and a "recently played" history.
- **Mixes**, playlists generated automatically from what you actually listen to.
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
- **Accounts via OIDC login**, with an optional anonymous listening mode.
- Light and dark themes, and a layout that works on both desktop and mobile.
## How it's built
**Backend**: .NET 10, split into a REST API, a background worker, and a
dedicated streaming gateway.
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
- Zitadel for login (OIDC/OAuth2).
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
The session lives on the server, so the backend access token never reaches the browser.
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
shared infrastructure that lives in a separate `Infrastructure` git
repository, used by Musify and by other projects that need the same OIDC
provider and object storage.
## Project layout
```
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
web-player/ the SvelteKit web client
deploy/ Docker Compose stacks for dev and production
docs/ architecture notes, one file per area
```
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
layout. They live in the separate `Infrastructure` repository instead.
## Digging deeper
[docs/](docs/README.md) covers how each piece works and why: architecture,
authentication, storage, streaming, media processing, and the local dev
setup. [deploy/README.md](deploy/README.md) has the full instructions for
running the stack, in development or on a production server.
-12
View File
@@ -1,12 +0,0 @@
**/bin/
**/obj/
**/.vs/
**/.idea/
.git/
.github/
.agents/
.claude/
**/DesignSettings.json
*.user
# Test projects: never shipped, no app Dockerfile needs them in its build context.
Tests/
@@ -1,82 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.Conflict(description: "Track is already in the album.");
}
var albumTracksQuery = database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
var nextTrackNumber = hasTracks
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
: 1;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = request.TrackId,
TrackNumber = nextTrackNumber
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
request.TrackId, request.AlbumId, nextTrackNumber);
return new Success();
}
}
}
@@ -1,128 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
var intent = pictureIntent.Value;
var title = request.Title.Trim();
var album = new Album
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Pictures = new AlbumPictures
{
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
};
await database.Albums.AddAsync(album, cancellationToken);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
}
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
}
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
}
}
}
}
@@ -1,50 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
database.Albums.Remove(album);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
}
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return new Success();
}
}
}
@@ -1,38 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.Where(a => a.Id == request.Id)
.Select(a => new
{
Album = a,
TrackCount = a.AlbumTracks.Count,
CoverTrackIds = a.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
if (album == null)
return Error.NotFound();
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
}
}
}
@@ -1,49 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = albumConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -1,49 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
public record GetAlbumTracksQuery(
Guid AlbumId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
{
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return Error.NotFound();
var tracksQuery = database.AlbumHasTracks
.AsNoTracking()
.Include(albumTrack => albumTrack.Track.Owner)
.Include(albumTrack => albumTrack.Track.Tags)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -1,60 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<ErrorOr<AlbumsSearchResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
{
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{
var albumsQuery = database.Albums
.AsNoTracking()
.AsQueryable();
if (!string.IsNullOrEmpty(request.Title))
{
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
}
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
return new AlbumsSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
}
}
}
@@ -1,53 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var albumsQuery = database.Albums
.AsNoTracking()
.Where(album => album.OwnerUserId == request.UserId);
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
}
}
}
@@ -1,61 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Join(
listenedTrackIds,
albumTrack => albumTrack.TrackId,
history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt)
.Take(request.Limit)
.ToListAsync(cancellationToken);
if (ranked.Count == 0)
return new List<AlbumApplicationResponse>();
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albums = await database.Albums
.AsNoTracking()
.Where(album => albumIds.Contains(album.Id))
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList();
}
}
}
@@ -1,59 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ToListAsync(cancellationToken);
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
database.AlbumHasTracks.Remove(link);
var trackNumber = 1;
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
remaining.TrackNumber = trackNumber++;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return new Success();
}
}
}
@@ -1,105 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.AlbumPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new AlbumPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
}
@@ -1,43 +0,0 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses
{
public record AlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public static AlbumApplicationResponse FromEntity(
Album album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? []);
}
}
}
@@ -1,11 +0,0 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -1,7 +0,0 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
}
}
@@ -1,12 +0,0 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
}
@@ -1,150 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = title.ToUpperInvariant();
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
album.Pictures = new AlbumPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
}
}
}
}
@@ -1,79 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class AlbumConfiguration
{
public const string SectionName = "Album";
[Required]
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
[Required]
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
}
public class AlbumRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "Albums";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class AlbumPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -1,17 +0,0 @@
using Mediator;
using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration
{
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds));
}
}
@@ -1,12 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class MixConfiguration
{
public const string SectionName = "Mix";
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
}
}
@@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public sealed class PlaybackConfiguration
{
public const string SectionName = "Playback";
public bool AllowAnonymousListening { get; set; }
[Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
[Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
}
@@ -1,5 +0,0 @@
namespace Musify.Application.Configuration.Responses
{
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
}
@@ -1,4 +0,0 @@
namespace Musify.Application.Contracts
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,4 +0,0 @@
namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,4 +0,0 @@
namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,7 +0,0 @@
namespace Musify.Application.Events
{
public record AlbumProcessingFailed(
Guid AlbumId,
string? Bucket,
string? PictureKey);
}
@@ -1,14 +0,0 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,12 +0,0 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent(
Guid AlbumId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,14 +0,0 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,26 +0,0 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -1,31 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -1,43 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Likes
{
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
var likesQuery = database.TrackLikes
.AsNoTracking()
.Include(like => like.Track.Owner)
.Include(like => like.Track.Tags)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -1,56 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
}
}
}
@@ -1,153 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes
{
public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>;
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
{
var userId = request.UserId;
var libraryTrackIds = await database.UserHasTracks
.AsNoTracking()
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(userTrack => userTrack.TrackId)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
{
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return new Success();
}
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
var historySet = new HashSet<Guid>(historyTrackIds);
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{
drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones de tu biblioteca que todavía no has escuchado.",
discovery));
}
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
daily));
}
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return new Success();
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return new Success();
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
{
var merged = new List<Guid>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
{
if (index < first.Count)
merged.Add(first[index]);
if (index < second.Count)
merged.Add(second[index]);
}
return merged;
}
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
{
var previousMixes = await database.Mixes
.Where(mix => mix.UserId == userId)
.ToListAsync(cancellationToken);
if (previousMixes.Count > 0)
{
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
var previousItems = await database.MixItems
.Where(item => previousIds.Contains(item.MixId))
.ToListAsync(cancellationToken);
database.MixItems.RemoveRange(previousItems);
database.Mixes.RemoveRange(previousMixes);
}
for (var position = 0; position < drafts.Count; position++)
{
var draft = drafts[position];
var mix = new Mix
{
UserId = userId,
Title = draft.Title,
Subtitle = draft.Subtitle,
Position = position
};
await database.Mixes.AddAsync(mix, cancellationToken);
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
{
await database.MixItems.AddAsync(new MixItem
{
MixId = mix.Id,
Position = itemPosition,
TrackId = draft.TrackIds[itemPosition]
}, cancellationToken);
}
}
}
}
}
@@ -1,31 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes
{
public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
{
var mix = await database.Mixes
.AsNoTracking()
.Include(mix => mix.Items)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null)
return Error.NotFound(description: $"Mix {request.MixId} was not found");
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
}
}
}
@@ -1,37 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes
{
public record GetMixesByUserIdQuery(long UserId)
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{
var mixes = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ToListAsync(cancellationToken);
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var coverItems = await MixItemMapper.ToResponsesAsync(
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
}
return responses;
}
}
}
@@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Mixes
{
internal static class MixItemMapper
{
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
var tracksById = await database.Tracks
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item =>
{
var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse(
item.TrackId,
entry.Track.Title,
entry.Track.Owner.Name,
entry.Track.DurationSeconds,
entry.ListensCount);
})
.ToList();
}
}
}
@@ -1,12 +0,0 @@
namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse(
Guid Id,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{
public const int CoverItemCount = 4;
}
}
@@ -1,9 +0,0 @@
namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse(
Guid TrackId,
string Title,
string? Artist,
double DurationSeconds,
int ListensCount);
}
@@ -1,42 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists.Responses;
using Mediator;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{
var entry = await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == request.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
if (entry == null)
return Error.NotFound();
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
}
}
}
@@ -1,49 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = playListConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -1,43 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.PlayLists.Responses;
using X.PagedList;
using X.PagedList.EF;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,64 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists
{
public record GetPlayListsByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize,
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
if (request.OnlyPublic)
{
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
}
var totalCount = await playListsQuery.CountAsync(cancellationToken);
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,37 +0,0 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses
{
public record PlayListApplicationResponse(
Guid Id,
string Name,
string? Description,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
PlaylistVisibility Visibility,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
}
}
}
@@ -1,37 +0,0 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Musify.Application.Serialization;
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType == JsonTokenType.String
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return reader.TokenType == JsonTokenType.String
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
else
writer.WriteNullValue();
}
}
@@ -1,56 +0,0 @@
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Services;
public sealed class TrackStreamIssuer(
IDatabase database,
IStreamTicketService ticketService,
TrackConfiguration trackConfiguration,
StreamGatewayConfiguration streamGatewayConfiguration,
PlaybackConfiguration playbackConfiguration)
{
public async Task<TrackStreamResponse> IssueAsync(
Guid trackId,
string audioFolderName,
long? userId,
CancellationToken cancellationToken)
{
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
var keyPrefix = $"{folderPath}/";
var maxBytes = AnonymousFragmentBytes(userId);
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
var manifestUrl = string.Join('/',
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
"media",
folderPath,
streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
if (userId != null)
{
var newListeningHistory = new ListeningHistory
{
UserId = userId.Value,
TrackId = trackId,
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
listenId = newListeningHistory.Id;
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
}
private long? AnonymousFragmentBytes(long? userId)
{
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
return null;
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
}
}
@@ -1,33 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
database.ListeningHistories.RemoveRange(stale);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count;
}
}
}
@@ -1,30 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks
{
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
{
var track = await database.ListeningHistories
.AsNoTracking()
.Include(t => t.Track.Owner)
.Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId)
.OrderByDescending(t => t.ListenedAt)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.FirstOrDefaultAsync(cancellationToken);
return track == null
? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
: track;
}
}
}
@@ -1,40 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks
{
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
public class GetListeningHistoryQueryHandler(
IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
{
public const int ListSize = 15;
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
{
var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId && l.IsCounted)
.GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key)
.Take(ListSize)
.ToListAsync(cancellationToken);
var tracksById = await database.Tracks
.AsNoTracking()
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds
.Select(id => tracksById[id])
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
}
}
}
@@ -1,53 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record GetTrackCoverQuery(Guid TrackId, string Size)
: IQuery<ErrorOr<TrackCoverLocation>>;
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
public class GetTrackCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
{
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
var routes = trackConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)),
"large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)),
_ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName))
};
if (string.IsNullOrEmpty(name) || key == null)
return Error.NotFound();
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
? "image/webp"
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
? "image/png"
: "image/jpeg";
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -1,57 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
{
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.AsQueryable();
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
.ToList();
return new TracksSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
}
}
}
@@ -1,14 +0,0 @@
namespace Musify.Application.Tracks
{
public static class ListeningRules
{
public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) =>
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
}
}
@@ -1,44 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record RecordListeningProgressCommand(
long UserId,
Guid ListenId,
double PlayedSeconds)
: ICommand<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
{
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories
.Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
if (listen == null)
return Error.NotFound();
var now = DateTime.UtcNow;
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
listen.LastProgressAt = now;
listen.IsCounted = listen.IsCounted
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
await database.SaveChangesAsync(cancellationToken);
return Result.Success;
}
}
}
@@ -1,35 +0,0 @@
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using System.Text.Json.Serialization;
namespace Musify.Application.Tracks.Responses
{
public record TrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
ProcessingStatus AudioStatus,
double Duration,
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
track.Id,
track.Title,
track.Owner.Name,
track.Audio.TranscodeStatus,
track.DurationSeconds,
listensCount,
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
}
}
@@ -1,7 +0,0 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackSearchItemResponse(TrackApplicationResponse Track)
{
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
}
}
@@ -1,12 +0,0 @@
namespace Musify.Application.Tracks.Responses
{
public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
}
@@ -1,50 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Users
{
public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
public class FollowUserCommandHandler(
IDatabase database,
ILogger<FollowUserCommandHandler> logger)
: ICommandHandler<FollowUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(FollowUserCommand request, CancellationToken cancellationToken)
{
if (request.FollowerId == request.FollowedId)
return Error.Validation(description: "A user cannot follow themselves.");
var followedExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
if (!followedExists)
{
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
return Error.NotFound(description: $"User {request.FollowedId} not found");
}
var alreadyFollowing = await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (alreadyFollowing)
return new Success();
await database.UserFollows.AddAsync(new UserFollow
{
FollowerId = request.FollowerId,
FollowedId = request.FollowedId
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
}
}
}
@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public static class FollowVisibility
{
public static async Task<bool> CanViewFollowersAsync(
IDatabase database,
long? viewerId,
long userId,
CancellationToken cancellationToken)
{
if (viewerId is not { } viewer)
return false;
if (viewer == userId)
return true;
var mutualRows = await database.UserFollows
.AsNoTracking()
.CountAsync(f =>
(f.FollowerId == viewer && f.FollowedId == userId) ||
(f.FollowerId == userId && f.FollowedId == viewer),
cancellationToken);
return mutualRows == 2;
}
}
}
@@ -1,51 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
public class GetListeningStatsQueryHandler(IDatabase database)
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
{
public async ValueTask<ListeningStatsResponse> Handle(GetListeningStatsQuery request, CancellationToken cancellationToken)
{
var weekAgo = DateTime.UtcNow.AddDays(-7);
var thisWeekHistory = database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory
.Where(l => l.IsCounted)
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId && l.IsCounted)
.Select(l => l.ListenedAt.Date)
.Distinct()
.ToListAsync(cancellationToken);
var listenedDateSet = listenedDates.ToHashSet();
var streakDays = 0;
var day = DateTime.UtcNow.Date;
while (listenedDateSet.Contains(day))
{
streakDays++;
day = day.AddDays(-1);
}
return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays);
}
}
}
@@ -1,46 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowersQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
return Error.Forbidden();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowedId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Follower.Id,
f.Follower.Name,
f.Follower.FirstName,
f.Follower.SecondName,
f.Follower.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -1,43 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowingQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowerId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Followed.Id,
f.Followed.Name,
f.Followed.FirstName,
f.Followed.SecondName,
f.Followed.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -1,75 +0,0 @@
using System.Text.Json.Serialization;
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Serialization;
namespace Musify.Application.Users
{
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
{
public async ValueTask<ErrorOr<UserProfileResponse>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{
var user = await database.Users
.AsNoTracking()
.Where(u => u.Id == request.UserId)
.Select(u => new
{
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
u.CreatedAt
})
.SingleOrDefaultAsync(cancellationToken);
if (user == null)
return Error.NotFound();
var followersCount = await database.UserFollows
.AsNoTracking()
.CountAsync(f => f.FollowedId == request.UserId, cancellationToken);
var followingCount = await database.UserFollows
.AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
database, request.ViewerId, request.UserId, cancellationToken);
return new UserProfileResponse(
user.Id,
user.Name,
user.FirstName,
user.SecondName,
user.ProfilePictureUrl,
user.CreatedAt,
followersCount,
followingCount,
isFollowing,
canViewFollowers);
}
}
}
@@ -1,45 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{
var usersQuery = database.Users
.AsNoTracking()
.AsQueryable();
if (!string.IsNullOrEmpty(request.UsernameSearch))
{
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername));
}
var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery
.OrderBy(u => u.Name)
.Select(u => new UserSummaryResponse(
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
}
}
}
@@ -1,17 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery<bool>;
public class IsFollowingUserQueryHandler(IDatabase database)
: IQueryHandler<IsFollowingUserQuery, bool>
{
public ValueTask<bool> Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) =>
new(database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken));
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.Users.Responses
{
public record UserSummaryResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
bool IsFollowedByViewer);
}
@@ -1,32 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
public class UnfollowUserCommandHandler(
IDatabase database,
ILogger<UnfollowUserCommandHandler> logger)
: ICommandHandler<UnfollowUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(UnfollowUserCommand request, CancellationToken cancellationToken)
{
var follow = await database.UserFollows
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (follow == null)
return new Success();
database.UserFollows.Remove(follow);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
}
}
}
-8
View File
@@ -1,8 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Musify.Domain</AssemblyName>
<RootNamespace>Musify.Domain</RootNamespace>
</PropertyGroup>
</Project>
@@ -1,46 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
public class Album : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public required string Title { get; set; }
[Required]
[MaxLength(200)]
public required string NormalizedTitle { get; set; }
[MaxLength(256)]
public string? Description { get; set; }
public int? ReleaseYear { get; set; }
public AlbumPictures? Pictures { get; set; }
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
public User Owner { get; set; }
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
}
}
@@ -1,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("AlbumHasTrack")]
public class AlbumHasTrack
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required Guid AlbumId { get; set; }
[Required]
public required Guid TrackId { get; set; }
[Required]
public int TrackNumber { get; set; } = 1;
[Required]
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(AlbumId))]
public Album Album { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; }
}
}
@@ -1,34 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("ListeningHistory")]
public class ListeningHistory
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
public required Guid TrackId { get; set; }
[Required]
public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
public double? PlayedSeconds { get; set; }
public DateTime? LastProgressAt { get; set; }
public bool IsCounted { get; set; }
[ForeignKey(nameof(UserId) )]
public User User { get; set; }
[ForeignKey(nameof(TrackId)) ]
public Track Track { get; set; }
}
}
@@ -1,39 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("Mixes")]
public class Mix : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
[MaxLength(100)]
public required string Title { get; set; }
[MaxLength(256)]
public string? Subtitle { get; set; }
[Required]
public int Position { get; set; } = 0;
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId))]
public User User { get; set; }
public ICollection<MixItem> Items { get; set; } = new List<MixItem>();
}
}
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("MixItems")]
public class MixItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required Guid MixId { get; set; }
[Required]
public int Position { get; set; } = 0;
[Required]
public required Guid TrackId { get; set; }
[ForeignKey(nameof(MixId))]
public Mix Mix { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; }
}
}
@@ -1,57 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
public class Track : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public required string Title { get; set; }
[Required]
[MaxLength(200)]
public required string NormalizedTitle { get; set; }
[Required]
public double DurationSeconds { get; set; }
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
public required User Owner { get; set; }
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public TrackPictures Pictures { get; set; } = new();
public TrackAudio Audio { get; set; } = new();
public ICollection<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>();
public ICollection<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>();
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
}
}
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("TrackLikes")]
public class TrackLike
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
public required Guid TrackId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId))]
public User User { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; }
}
}
@@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
public class TrackTag
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required Guid TrackId { get; set; }
[Required]
public required Genre Tag { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; }
}
}
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("UserFollows")]
public class UserFollow
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long FollowerId { get; set; }
[Required]
public required long FollowedId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(FollowerId))]
public User Follower { get; set; }
[ForeignKey(nameof(FollowedId))]
public User Followed { get; set; }
}
}
@@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Domain.ValueObjects
{
public class AlbumPictures
{
[Required]
public required string OriginalName { get; set; }
[Required]
public required string SmallName { get; set; }
[Required]
public required string MediumName { get; set; }
[Required]
public required string LargeName { get; set; }
}
}
@@ -1,32 +0,0 @@
namespace Musify.Domain.ValueObjects
{
public enum Genre
{
Pop = 0,
Rock = 1,
HipHop = 2,
RnB = 3,
Jazz = 4,
Blues = 5,
Classical = 6,
Electronic = 7,
House = 8,
Techno = 9,
Trance = 10,
Dubstep = 11,
DrumAndBass = 12,
Metal = 13,
Punk = 14,
Reggae = 15,
Reggaeton = 16,
Country = 17,
Folk = 18,
Indie = 19,
KPop = 20,
Latin = 21,
Soul = 22,
Funk = 23,
Ambient = 24,
Lofi = 25
}
}
@@ -1,48 +0,0 @@
namespace Musify.Domain.ValueObjects
{
public static class GenreCompatibility
{
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
private static HashSet<(Genre, Genre)> BuildIncompatiblePairs()
{
var pairs = new HashSet<(Genre, Genre)>();
void Add(Genre a, Genre b)
{
pairs.Add((a, b));
pairs.Add((b, a));
}
var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass };
var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi };
foreach (var intenseGenre in intense)
foreach (var mellowGenre in mellow)
Add(intenseGenre, mellowGenre);
return pairs;
}
public static bool AreCompatible(Genre a, Genre b) =>
a == b || !IncompatiblePairs.Contains((a, b));
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
{
var distinctTags = tags.Distinct().ToList();
var conflicts = new List<(Genre, Genre)>();
for (var i = 0; i < distinctTags.Count; i++)
{
for (var j = i + 1; j < distinctTags.Count; j++)
{
if (!AreCompatible(distinctTags[i], distinctTags[j]))
conflicts.Add((distinctTags[i], distinctTags[j]));
}
}
return conflicts;
}
}
}
@@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Domain.ValueObjects
{
public class PlayListPictures
{
[Required]
public required string OriginalName { get; set; }
[Required]
public required string SmallName { get; set; }
[Required]
public required string MediumName { get; set; }
[Required]
public required string LargeName { get; set; }
}
}
@@ -1,8 +0,0 @@
namespace Musify.Domain.ValueObjects
{
public enum PlaylistVisibility
{
Private = 0,
Public = 1
}
}
@@ -1,27 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace Musify.Domain.ValueObjects
{
#pragma warning disable CS8618
public class TrackAudio
{
public string? OriginalName { get; set; }
public string? FolderName { get; set; }
public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending;
public bool DownloadRequested { get; set; }
public int RetryCount { get; set; }
public DateTime? LastRetryAt { get; set; }
[MemberNotNullWhen(true, nameof(FolderName))]
public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed;
public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed;
public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing;
}
}
@@ -1,23 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace Musify.Domain.ValueObjects
{
#pragma warning disable CS8618
public class TrackPictures
{
public string? OriginalName { get; set; }
public string? SmallName { get; set; }
public string? MediumName { get; set; }
public string? LargeName { get; set; }
public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending;
[MemberNotNullWhen(true, nameof(SmallName))]
[MemberNotNullWhen(true, nameof(MediumName))]
[MemberNotNullWhen(true, nameof(LargeName))]
public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed;
}
}
@@ -1,38 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Mixes;
namespace Musify.Infrastructure.Jobs
{
public class DailyMixGenerationJob(
IDatabase database,
IMediator mediator,
ILogger<DailyMixGenerationJob> logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count);
foreach (var userId in userIds)
{
var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken);
if (result.IsError)
{
logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}",
userId, result.FirstError.Description);
}
}
logger.LogInformation("Finished daily mix generation");
}
}
}
@@ -1,13 +0,0 @@
using Mediator;
using Musify.Application.Tracks;
namespace Musify.Infrastructure.Jobs
{
public class ListeningHistoryCleanupJob(IMediator mediator)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
}
}
}
@@ -1,32 +0,0 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Albums
{
internal class MarkAlbumAsFailedActivity(
IDatabase database,
ILogger<MarkAlbumAsFailedActivity> logger)
: IExecuteActivity<MarkAlbumAsFailedArguments>
{
public const string ExecuteEndpointName = "mark-album-as-failed";
public async Task<ExecutionResult> Execute(ExecuteContext<MarkAlbumAsFailedArguments> executeContext)
{
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
if (album != null)
{
album.LifeCycleStatus = LifeCycleStatus.Failed;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId);
}
return executeContext.Completed();
}
}
}
@@ -1,34 +0,0 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Albums
{
internal class PublishAlbumPictureProcessingEventActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishAlbumPictureProcessingEventActivity> logger)
: IExecuteActivity<PublishAlbumPictureProcessingEventArguments>
{
public const string ExecuteEndpointName = "publish-album-picture-processing-event";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishAlbumPictureProcessingEventArguments> executeContext)
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdateAlbumPictureEvent(
arguments.AlbumId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Small,
arguments.Medium,
arguments.Large),
executeContext.CancellationToken);
logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId);
return executeContext.Completed();
}
}
}
@@ -1,69 +0,0 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
internal class DownloadFileFromUrlActivity(
IHttpClientFactory httpClientFactory,
ILogger<DownloadFileFromUrlActivity> logger)
: IActivity<DownloadFileFromUrlArguments, DownloadFileFromUrlLog>
{
public const string ExecuteEndpointName = "download-file-from-url";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromUrlArguments> executeContext)
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
try
{
var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
executeContext.Arguments.SourceUrl,
HttpCompletionOption.ResponseHeadersRead,
executeContext.CancellationToken);
response.EnsureSuccessStatusCode();
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
await using (var destinationStream = File.Create(destinationPath))
{
await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken);
}
logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}",
executeContext.Arguments.SourceUrl, destinationPath);
return executeContext.Completed(new DownloadFileFromUrlLog(
executeContext.Arguments.SourceUrl,
destinationPath));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl);
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromUrlLog> compensateContext)
{
try
{
if (File.Exists(compensateContext.Log.DestinationFilePath))
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}",
compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
}
@@ -1,113 +0,0 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Pictures
{
internal class UpdateAlbumPictureActivity(
IDatabase database,
IPublishEndpoint publishEndpoint,
ILogger<UpdateAlbumPictureActivity> logger)
: IActivity<UpdateAlbumPictureArguments, UpdateAlbumPictureLog>
{
public const string ExecuteEndpointName = "update-album-picture";
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateAlbumPictureArguments> executeContext)
{
var smallResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.SmallPictureVariable);
ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable));
var mediumResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.MediumPictureVariable);
ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable));
var largeResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.LargePictureVariable);
ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable));
try
{
var album = await database.Albums.FindAsync(
[executeContext.Arguments.AlbumId],
cancellationToken: executeContext.CancellationToken);
if (album == null)
{
logger.LogWarning("Album {AlbumId} not found",
executeContext.Arguments.AlbumId);
throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found");
}
if (album.Pictures == null)
{
logger.LogWarning("Album {AlbumId} has no pending picture upload",
executeContext.Arguments.AlbumId);
throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload");
}
var log = new UpdateAlbumPictureLog(
album.Id,
album.Pictures.OriginalName,
album.Pictures.SmallName,
album.Pictures.MediumName,
album.Pictures.LargeName);
album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey);
album.Pictures.SmallName = Path.GetFileName(smallResizedVariable);
album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
album.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
database.Albums.Update(album);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated album {AlbumId} pictures",
executeContext.Arguments.AlbumId);
await publishEndpoint.Publish(
new AlbumPictureProcessed(executeContext.Arguments.AlbumId),
executeContext.CancellationToken);
return executeContext.Completed();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId} pictures",
executeContext.Arguments.AlbumId);
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdateAlbumPictureLog> compensateContext)
{
try
{
var album = await database.Albums.FindAsync(
[compensateContext.Log.AlbumId],
cancellationToken: compensateContext.CancellationToken);
if (album == null)
{
return compensateContext.Compensated();
}
album.Pictures = new AlbumPictures
{
OriginalName = compensateContext.Log.PreviousOriginalPictureKey,
SmallName = compensateContext.Log.PreviousSmallPictureKey,
MediumName = compensateContext.Log.PreviousMediumPictureKey,
LargeName = compensateContext.Log.PreviousLargePictureKey
};
database.Albums.Update(album);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId);
return compensateContext.Failed(exception);
}
}
}
}
@@ -1,6 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record DownloadFileFromUrlArguments(
string SourceUrl,
string DestinationFilePathVariable);
}
@@ -1,4 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record MarkAlbumAsFailedArguments(Guid AlbumId);
}
@@ -1,12 +0,0 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishAlbumPictureProcessingEventArguments(
Guid AlbumId,
string Bucket,
string FinalPictureKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,9 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdateAlbumPictureArguments(
Guid AlbumId,
string OriginalPictureKey,
string SmallPictureVariable,
string MediumPictureVariable,
string LargePictureVariable);
}
@@ -1,36 +0,0 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Albums;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer<AlbumProcessingFailed>
{
public async Task Consume(ConsumeContext<AlbumProcessingFailed> context)
{
var message = context.Message;
var builder = new RoutingSlipBuilder(NewId.NextGuid());
builder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey))
builder.AddActivity(
ActivityNames.RemoveAlbumOriginalPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey));
builder.AddActivity(
ActivityNames.MarkAlbumAsFailed,
EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName),
new MarkAlbumAsFailedArguments(message.AlbumId));
await bus.Execute(builder.Build());
}
}
}

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