Author SHA1 Message Date
AdminandClaude Opus 4.8 573539ea1b cleanup(worker): drop empty Worker BackgroundService; fix gateway ticket guard
- Remove the no-op Worker BackgroundService and its registration; the process
  is kept alive and does its work through the MassTransit bus and the upload
  intent jobs (their own hosted services)
- Fix the gateway middleware guard to reject on string.IsNullOrEmpty(prefix)
  instead of 'is null', which no longer matched TicketValidator returning
  string.Empty for invalid tickets (invalid tickets skipped the 401 and an
  empty object key could slip through)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:13:20 +02:00
Admin e15e587267 Formatting 2026-07-05 22:07:43 +02:00
Admin dece2c77e8 Empty string 2026-07-05 22:06:50 +02:00
AdminandClaude Opus 4.8 5aeca4deb1 refactor(gateway): simplify prefix extraction in TicketValidator
Read the prefix claim via ClaimsIdentity.FindFirst instead of a three-part
ternary over the claims dictionary, and drop the XML summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:59:55 +02:00
AdminandClaude Opus 4.8 45ce66a6ee harden(gateway): prefix-boundary + path-traversal checks; simplify ticket validation
Tighten the stream-ticket authorization in the gateway middleware:
- Enforce a path-segment boundary on the prefix check instead of a bare
  StartsWith, so a ticket for 'users/12' no longer grants 'users/1234/...'
  (exact-match still allowed for single-object grants)
- Reject object keys containing '..'/'.' segments before proxying, closing a
  path-traversal bypass that SeaweedFS would otherwise resolve outside the
  authorized prefix

Also simplify TicketValidator.ValidateTicketAsync from ErrorOr<string> to
string? (null = invalid) since it has a single failure mode; drop the now
unused ErrorOr package reference from the gateway.

Verified: build clean; the authorization predicate passes a table of cases
covering both the escalation and traversal vulnerabilities.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:50:43 +02:00
AdminandClaude Opus 4.8 a40de35a57 refactor(api): OpenAPI response metadata, cached user sync, ProblemDetails
- Document every endpoint's responses (Produces<T>/status codes/validation)
  so the generated OpenAPI/Scalar docs carry response schemas and status codes
  instead of empty responses
- Cache recently-synced user ids (IMemoryCache, 15 min) in the JWT
  TokenValidated handler so user provisioning no longer hits the DB on every
  authenticated request
- Add ProblemDetails + UseExceptionHandler for consistent RFC7807 errors
- Add ToCreatedResult ErrorOr helper and use it in the create endpoints
- Normalize the playlists route to lowercase (/playlists)

Verified: booted the Api against RabbitMQ + Postgres in Docker and inspected
/openapi/v1.json — operations now expose response schemas/status codes and the
route is /playlists.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:04:15 +02:00
AdminandClaude Opus 4.8 1d40abcab7 feat(deploy): consolidated dev stack with auto-provisioned Zitadel
Single docker-compose stack for local development (Postgres, Zitadel,
RabbitMQ, SeaweedFS + bucket init, Jaeger) plus an optional overlay to run
the .NET apps in Docker. up.ps1 orchestrates the full bootstrap: stack up,
stream-ticket keys, EF Core migrations and Zitadel provisioning.

- Zitadel is provisioned automatically (service-account PAT -> project +
  OIDC app with JWT access token); the generated ClientId is wired into
  Musify.Api user-secrets and .env. No manual console steps.
- ZITADEL_EXTERNAL_DOMAIN=host.docker.internal so token validation works
  both with local apps and full-docker.
- App Dockerfiles copy AppSettings*.json to lowercase so config loads on
  case-sensitive Linux; Worker image ships ffmpeg.
- Scripts split by responsibility (common/stream-keys/migrate/provision);
  apps overlay de-duped via YAML anchors.
- Remove the superseded standalone seaweedfs compose; point docs/development
  at the unified stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:32:40 +02:00
Admin d6c269d80f Formatting 2026-07-05 16:04:53 +02:00
AdminandClaude Opus 4.8 431b56a072 refactor(infra): register auditable interceptor via DI
Register AuditableEntityInterceptor as a singleton and attach it through the
AddDbContext options callback instead of newing it up inside
Database.OnConfiguring. The interceptor is stateless, so a single shared
instance is reused across contexts and its wiring lives in the DI layer.
The design-time factory keeps working (interceptor is runtime-only, irrelevant
for migrations).

Verified: build + ef migrations list (design-time context) OK; the Worker host
builds and validates the full DI graph (reaches endpoint configuration) with no
captive-dependency or interceptor error.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:14:03 +02:00
AdminandClaude Opus 4.8 c2258569a0 refactor(infra): S3 client singleton, ffmpeg pipe ordering, tidy streams/jobs
- Register IAmazonS3 as a singleton instead of scoped: the AWS SDK client is
  thread-safe and manages its own connection pool, so one instance per DI scope
  (per request / per job iteration) was wasteful and risked socket exhaustion
- AudioTranscoderService: start reading stdout/stderr before writing stdin to
  remove the latent pipe-buffer deadlock shape
- Dispose the resized-picture MemoryStream in ResizePictureActivity
- Background jobs use PeriodicTimer instead of while + Task.Delay

Verified: Worker boots against RabbitMQ + Postgres in Docker with no captive-
dependency error (dev scope validation) and both jobs start.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:07:49 +02:00
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
770 changed files with 2041 additions and 70076 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
-115
View File
@@ -1,115 +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: Migrate database, generate keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d wait-for-postgres keys-init 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.
-2
View File
@@ -8,5 +8,3 @@
.claude/ .claude/
**/DesignSettings.json **/DesignSettings.json
*.user *.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 })
.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);
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,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 })
.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)
.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 })
.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,54 +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);
if (userId != null)
{
var newListeningHistory = new ListeningHistory
{
UserId = userId.Value,
TrackId = trackId,
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
}
private long? AnonymousFragmentBytes(long? userId)
{
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
return null;
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
}
}
@@ -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))
.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)
.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 })
.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,53 +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;
namespace Musify.Application.Tracks
{
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
: 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));
}
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 })
.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,33 +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)
{
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());
}
}
@@ -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,50 +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
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId)
.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,63 +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) : 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);
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);
return new UserProfileResponse(
user.Id,
user.Name,
user.FirstName,
user.SecondName,
user.ProfilePictureUrl,
user.CreatedAt,
followersCount,
followingCount);
}
}
}
@@ -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,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,28 +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;
[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,54 +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 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,37 +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()
.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,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());
}
}
}
@@ -1,22 +0,0 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class CreateAlbumConsumer(
IBus bus,
AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreateAlbumResourcesEvent>
{
public async Task Consume(ConsumeContext<CreateAlbumResourcesEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -1,22 +0,0 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class UpdateAlbumPictureConsumer(
IBus bus,
PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureEvent>
{
public async Task Consume(ConsumeContext<UpdateAlbumPictureEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -1,22 +0,0 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class UpdateAlbumPictureSourceConsumer(
IBus bus,
AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureSourceEvent>
{
public async Task Consume(ConsumeContext<UpdateAlbumPictureSourceEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -1,6 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record DownloadFileFromUrlLog(
string SourceUrl,
string DestinationFilePath);
}
@@ -1,9 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdateAlbumPictureLog(
Guid AlbumId,
string PreviousOriginalPictureKey,
string PreviousSmallPictureKey,
string PreviousMediumPictureKey,
string PreviousLargePictureKey);
}
@@ -1,9 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdateTrackPictureLog(
Guid TrackId,
string? PreviousOriginalPictureKey,
string? PreviousSmallPictureKey,
string? PreviousMediumPictureKey,
string? PreviousLargePictureKey);
}
@@ -1,85 +0,0 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Albums;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class AlbumPictureSourceRoutingSlipBuilder
{
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId)
=> BuildSlip(
message.AlbumId,
message.PictureIntentId,
message.Bucket,
message.PictureSourceKey,
message.PictureDestinationKey,
message.Small,
message.Medium,
message.Large,
correlationId);
public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId)
=> BuildSlip(
message.AlbumId,
message.PictureIntentId,
message.Bucket,
message.PictureSourceKey,
message.PictureDestinationKey,
message.Small,
message.Medium,
message.Large,
correlationId);
static RoutingSlipBuilder BuildSlip(
Guid albumId,
Guid pictureIntentId,
string bucket,
string pictureSourceKey,
string pictureDestinationKey,
ImageSize small,
ImageSize medium,
ImageSize large,
Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.CopyPictureToFinal,
EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName),
new CopyFileInBucketArguments(
bucket,
pictureSourceKey,
bucket,
pictureDestinationKey));
routingSlipBuilder.AddActivity(
ActivityNames.ConsumeUploadIntents,
EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName),
new ConsumeUploadIntentsArguments([pictureIntentId]));
routingSlipBuilder.AddActivity(
ActivityNames.PublishAlbumPictureProcessingEvent,
EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName),
new PublishAlbumPictureProcessingEventArguments(
albumId,
bucket,
pictureDestinationKey,
small,
medium,
large));
return routingSlipBuilder;
}
}
}
@@ -1,19 +0,0 @@
using MassTransit;
namespace Musify.Infrastructure.MassTransit.Sagas
{
public class AlbumProcessingState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = null!;
public string? Bucket { get; set; }
public string? PictureKey { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}
@@ -1,51 +0,0 @@
using MassTransit;
using Musify.Application.Events;
namespace Musify.Infrastructure.MassTransit.Sagas
{
public sealed class AlbumProcessingStateMachine : MassTransitStateMachine<AlbumProcessingState>
{
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<CreateAlbumResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<AlbumPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<AlbumPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public AlbumProcessingStateMachine()
{
InstanceState(state => state.CurrentState);
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId));
Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.AlbumId));
Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId));
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
} )
.TransitionTo(Processing));
During(Processing,
When(PictureProcessed).Finalize(),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new AlbumProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey)));
During(Failed,
Ignore(PictureProcessed),
Ignore(PictureFailed));
SetCompletedWhenFinalized();
}
}
}
@@ -1,216 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using MassTransit;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Sagas;
using AppIDatabase = Musify.Application.Contracts.IDatabase;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Persistence
{
public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase
{
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
var trackProcessing = modelBuilder.Entity<TrackProcessingState>();
trackProcessing.HasKey(state => state.CorrelationId);
trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
trackProcessing.Property(state => state.CurrentState).HasMaxLength(64);
modelBuilder.Entity<Track>()
.OwnsOne(track => track.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus");
});
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
modelBuilder.Entity<Track>()
.OwnsOne(track => track.Audio, audio =>
{
audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64);
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus");
audio.Property(a => a.DownloadRequested).HasColumnName("DownloadRequested");
audio.Property(a => a.RetryCount).HasColumnName("RetryCount");
audio.Property(a => a.LastRetryAt).HasColumnName("LastRetryAt");
});
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
modelBuilder.Entity<Track>()
.HasOne(track => track.Owner)
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PlayList>()
.Property(playList => playList.Visibility)
.HasDefaultValue(PlaylistVisibility.Private);
modelBuilder.Entity<PlayList>()
.OwnsOne(playList => playList.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
});
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
modelBuilder.Entity<Album>()
.OwnsOne(album => album.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
});
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
modelBuilder.Entity<Album>()
.HasOne(album => album.Owner)
.WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.AlbumId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Track)
.WithMany(track => track.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>()
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.IsUnique();
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Mix)
.WithMany(mix => mix.Items)
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
modelBuilder.Entity<TrackLike>()
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<TrackTag>()
.HasOne(trackTag => trackTag.Track)
.WithMany(track => track.Tags)
.HasForeignKey(trackTag => trackTag.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackTag>()
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
.HasForeignKey(follow => follow.FollowerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Followed)
.WithMany()
.HasForeignKey(follow => follow.FollowedId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
.IsUnique();
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
playListProcessing.Property(state => state.CurrentState).HasMaxLength(64);
var albumProcessing = modelBuilder.Entity<AlbumProcessingState>();
albumProcessing.HasKey(state => state.CorrelationId);
albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
albumProcessing.Property(state => state.CurrentState).HasMaxLength(64);
}
public DbSet<User> Users => Set<User>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new DatabaseTransaction(transaction);
}
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken) =>
inner.CommitAsync(cancellationToken);
public ValueTask DisposeAsync() => inner.DisposeAsync();
}
}
}
@@ -1,56 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
public partial class AddListeningHistory : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ListeningHistory",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
ListenedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ListeningHistory", x => x.Id);
table.ForeignKey(
name: "FK_ListeningHistory_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ListeningHistory_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ListeningHistory_TrackId",
table: "ListeningHistory",
column: "TrackId");
migrationBuilder.CreateIndex(
name: "IX_ListeningHistory_UserId",
table: "ListeningHistory",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ListeningHistory");
}
}
}

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