77 KiB
Plan: Backend hardening, cleanup and deduplication
Work plan for agents/AI with limited context (~128k). Based on a full read of every non-migration
.cs,.csprojandappsettings*.jsonfile underbackend/(2026-09-24, branchmasterat87c3f97), plus the web-player call sites of the API.
How to use this document
- Always read section A (Common context). It's short and every part assumes it.
- Pick one part (P0 through P15). Each part lists: objective, prerequisites, files to read, tasks, and closing criteria.
- One part equals one commit/PR. Don't mix parts. P0 is a pure formatting commit: never mix formatting with behaviour changes.
- If a part needs a shared helper from section B and it doesn't exist yet, create it yourself exactly as specified in B, so any part can create it identically.
- When you finish a part, check its box in section D (Log) and fill in the date and notes.
- Section E maps every audit finding to the part that fixes it. Nothing in E may be left without a part; if you discover a new finding, add it to E and to the right part.
Recommended order (arrows are soft dependencies, see point 4):
P0 (format + guardrails)
├→ P1 (security) → P2 (pipeline bugs) → P3 (query/API bugs)
└→ P4 (dead code)
↓
P5 (config) → P6 (domain) → P7 (application helpers) → P8 (pictures feature)
↘ P9 (responsibilities)
P10 (infra services) → P11 (MassTransit) → P12 (API layer) → P13 (Gateway/Worker)
→ P14 (tests) → P15 (final sweep)
P1 must land before anything else that touches endpoints: it closes real data leaks.
A. Common context (always read)
A.1 Solution layout
| Project | Path | Role |
|---|---|---|
| Domain | Core/Musify.Domain |
Entities (EF data-annotated), value objects, enums |
| Application | Core/Musify.Application |
Mediator commands/queries (<Feature>/<UseCase>.cs = record + handler), responses, contracts (IDatabase, IStorageService, …), configuration POCOs |
| Infrastructure | Core/Musify.Infrastructure |
EF Database, S3 StorageService, ffmpeg, ImageSharp, MassTransit (activities, routing slips, sagas, consumers), jobs |
| Api | Hosts/Musify.Api |
Minimal API endpoints, DTOs, FluentValidation validators, JWT auth |
| StreamingGateway | Hosts/Musify.StreamingGateway |
YARP proxy to SeaweedFS with signed stream tickets |
| Worker | Hosts/Musify.Worker |
MassTransit consumers + Hangfire + hosted jobs |
| Tests | Tests/* |
xUnit v3. Application tests use SQLite in-memory (TestDatabase), Infrastructure tests use Testcontainers (need Docker) |
Libraries: Mediator (source generator, scoped), ErrorOr, EF Core 10 + Npgsql, MassTransit 8 (RabbitMQ, EF outbox, courier routing slips, saga state machines), Hangfire, FluentValidation, X.PagedList (to be removed in P7), YARP.
A.2 Verifying each part
From backend/:
dotnet build Musify.slnx
dotnet test Musify.slnx
- The build must stay at 0 warnings (after P0, warnings are errors).
Infrastructure.Testsneed Docker running. If Docker isn't available, say so in the Log; don't skip silently.- A part that changes an HTTP contract (route, request/response shape, status code) must
also: run the API locally, regenerate the web-player client (
npm run gen:apiinweb-player/, needs the API onhttp://localhost:5111), fix the web-player call sites, and runnpm run checkinweb-player/. List the contract changes in the Log. - A part that changes the EF model must add a migration from
backend/:
dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure
Also update Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs, which mirrors part
of the model configuration by hand.
- A part that changes a MassTransit message contract (anything in
Application/EventsorInfrastructure/MassTransit/Arguments|Logs) must note in the Log that queues have to be drained before deploying (in-flight messages with the old shape will fail).
A.3 Known gotchas (don't reintroduce them)
- Outbox. The API uses the EF bus outbox (
UseBusOutbox).eventBus.PublishAsynconly writes to the outbox; the message leaves whenSaveChangesAsynccommits. So "publish, then save" is correct and atomic. Don't wrap the publish in its own try/catch. - Worker DI.
AddMediator()registers every handler, including ones whose dependencies the Worker doesn't have. Today that's hidden byValidateOnBuild = false. P13 fixes it; until then, don't add handler dependencies that the Worker can't resolve if the Worker sends that command. - Private IDs as strings.
longuser ids are serialized as strings (JS precision). Keep that when moving the converter (P9). - Track pictures vs album/playlist pictures. New tracks point at shared preset images until
processed;
DeleteTrackRoutingSlipBuilderonly deletes resized files whenPictures.IsProcessed, so presets are never deleted. Keep that guard. - Deploy overrides.
deploy/compose.ymloverrides config via env vars (Section__Key). When renaming a config key, grepdeploy/too. - The web-player is the only client. Endpoints it doesn't call (see E.2) can change freely; the others need the regeneration step in A.2.
A.4 Coordination with the web-player plan
web-player/docs/frontend-audit-plan.md has parts marked 🔗 that need this backend. They are
scheduled here so both plans converge:
| Web-player part | Needs from the backend | Done in |
|---|---|---|
| P3.1 playlist permissions | ownerUserId in PlayListApplicationResponse |
P7.9 |
| P8.1 performance | trackCount and durationSeconds in PlayListApplicationResponse |
P7.9 |
| P8.4 add album to playlist | bulk insert: add many tracks to a playlist in one request | P9.4 |
P7.1 shared LIMITS |
the real validator limits (playlist description is 256 here, track title 200) | P12.10 |
When one of these lands, tick it in both Logs.
A.5 Measured baseline (2026-09-24)
| Metric | Value | Target |
|---|---|---|
Non-migration .cs lines (excluding tests) |
12,687 | −25–30 % |
| Build warnings | 0 (no analyzers, no style rules) | 0 with analyzers + style enforced |
| Files with block namespace / file-scoped | 249 / 50 | 0 / all |
| Files with UTF-8 BOM | 31 (2 with a BOM mid-file) | 0 |
| Files without final newline | 61 | 0 |
Stray spaces (} );, nameof(X) )], {Id} )) |
43 | 0 |
ThrowIfNull(x, nameof(x)) |
19 | 0 |
new ImageSize( |
15 | 3 (inside PictureSizes) |
| Cover-ids projection copies | 9 | 1 per entity |
ListeningHistories.Count(l => l.IsCounted) copies |
9 | 1 |
new StaticPagedList |
7 | 0 |
Error.Unauthorized() used for "not owner" |
10 | 0 |
Recompute with the script in P15 and record in D.1.
B. Shared specs (single source of truth)
B.1 Style guardrails
B.1.1 backend/.editorconfig (create in P0)
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{csproj,props,targets,slnx,xml}]
indent_size = 2
[*.json]
indent_size = 4
[**/Migrations/**.cs]
generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_diagnostic.IDE0005.severity = warning
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case.capitalization = camel_case
dotnet_naming_rule.private_constants.symbols = private_constants
dotnet_naming_rule.private_constants.style = pascal_case
dotnet_naming_rule.private_constants.severity = warning
dotnet_naming_symbols.private_constants.applicable_kinds = field
dotnet_naming_symbols.private_constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
Private fields are camelCase without underscore (matches primary-constructor parameters,
which are the majority of the code). Constants and static readonly are PascalCase.
B.1.2 backend/Directory.Build.props (update in P0)
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
</Project>
GenerateDocumentationFile is only there so IDE0005 (unused usings) is reported on build.
If latest-recommended raises rules that would force behaviour changes in P0, set those
specific rules to suggestion in .editorconfig with a comment naming the part that will fix
them, and remove the override in that part.
B.2 Coding conventions (apply in every part you touch)
- File layout.
Application/<Feature>/<UseCase>.csholds the command/query record and its handler. Responses live inApplication/<Feature>/Responses/. No response records declared inside handler files, no mapping methods on commands. - Errors (via
AppErrors, B.3.6):- missing, or exists but not visible to the caller →
NotFound - exists but caller isn't the owner →
Forbidden(403). NeverUnauthorizedfor that;Unauthorized(401) is only for "not signed in". - duplicate/state clash →
Conflict; bad input →Validation. - Every error has a code (
"Album.NotFound") and a description.
- missing, or exists but not visible to the caller →
- Success value:
Result.Success. - No try/catch around
SaveChangesAsyncorPublishAsyncin handlers. Unexpected exceptions go to the globalUseExceptionHandler. Catch only what you can turn into a domain error (e.g. unique-constraint violation →Conflict, see B.3.7). - Logging. Handlers log state changes at
Information(created/updated/deleted). Not-found and forbidden aren't logged (the HTTP log already shows them). Infrastructure code either handles an exception or rethrows it; never log-and-rethrow. - Queries never write. If a read needs a side effect, it's a command.
- Return types. A handler that can't fail returns the plain type (
ValueTask<T>), notErrorOr<T>. A handler that can fail returnsErrorOr<T>. Never return a lazyIEnumerable. - Lists are paginated with
PageRequest(B.3.4) and have a deterministic order withIdas the final tiebreaker. Public reads only returnLifeCycleStatus.Activerows. - Text input: stored names/titles are trimmed; normalized columns use
TextNormalizer.Normalize(B.3.5). - Entities: no
[Required]on value types, no[Required]next torequiredon non-nullable strings, no initializers equal to the default (= 0,Id = Guid.NewGuid()in object initializers). Navigations:public User Owner { get; set; } = null!;(no#pragma warning disable CS8618, norequiredon navigations). - Naming:
PlayListeverywhere (types, variables, route parameters:{playListId}→ see P12 for the route style).…Configurationfor config POCOs. Injected services are named after their type (storageService, notstorageHandler). ArgumentNullException.ThrowIfNull(x)withoutnameof.- User-facing strings (mix titles, error descriptions) are English; the web-player owns translation.
B.3 Shared helpers (exact paths and APIs)
B.3.1 Pictures configuration (Application/Configuration/Pictures.cs)
Replaces AlbumRoutes, PlayListRoutes, the picture half of TrackRoutes, and the three
*PicturesSizes classes.
namespace Musify.Application.Configuration;
public enum PictureSize { Small, Medium, Large }
public class PictureRoutes
{
[Required] public string UploadsFolder { get; set; } = "uploads";
[Required] public string ParentFolder { get; set; } = string.Empty;
[Required] public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required] public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required] public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required] public string LargePicturesFolder { get; set; } = "LargePictures";
public string FolderPath(PictureSize size); // ParentFolder/<size folder>
public string BuildPicturePath(PictureSize size, string name); // FolderPath(size)/name
public string BuildOriginalPicturePath(long userId, string name); // uploads/{userId}/ParentFolder/OriginalPictures/name
public string BuildTempPath(string tempRootPrefix, long userId, string objectName);
}
public class PictureSizes
{
[Range(1, 1024)] public int SmallWidth { get; set; } = 128;
[Range(1, 1024)] public int SmallHeight { get; set; } = 128;
[Range(1, 1024)] public int MediumWidth { get; set; } = 256;
[Range(1, 1024)] public int MediumHeight { get; set; } = 256;
[Range(1, 1024)] public int LargeWidth { get; set; } = 512;
[Range(1, 1024)] public int LargeHeight { get; set; } = 512;
public ImageSizes ToImageSizes(PictureRoutes routes);
}
public interface IPictureOwnerConfiguration
{
PictureRoutes Routes { get; }
PictureSizes PicturesSizes { get; }
}
AlbumConfigurationandPlayListConfigurationbecome{ PictureRoutes Routes; PictureSizes PicturesSizes; }implementingIPictureOwnerConfiguration.TrackConfiguration.RoutesbecomesTrackRoutes : PictureRoutesadding only the preset and audio members (PresetSmallPicture…,OriginalAudiosFolder,ProcessedAudiosFolder,BuildOriginalAudioPath(userId, name),BuildProcessedAudioPath(folder)).BuildTempAudioPathis deleted (it was identical to the picture one): useBuildTempPath.- Config keys change:
ParentFolders→ParentFolder(Album/PlayList),ProcessedAudioFolder→ProcessedAudiosFolder,SmallPictureWidth→SmallWidthetc. Update everyappsettings*.jsonand grepdeploy/. - The un-prefixed
BuildOriginalPicturePath(string name)overloads and the…PicturesPathproperties that nobody needs after P8 are removed.
B.3.2 ImageSizes (Application/Shared/ImageSize.cs)
public record ImageSize(string SavePath, int Width, int Height);
public record ImageSizes(ImageSize Small, ImageSize Medium, ImageSize Large);
Events and routing-slip arguments carry one ImageSizes Sizes instead of three ImageSize
parameters (message contract change, see A.2).
B.3.3 EntityPictures (Domain/ValueObjects/EntityPictures.cs)
Replaces AlbumPictures and PlayListPictures (identical today). Same four string properties.
Column names are mapped explicitly in Database.cs, so no migration is needed; verify with
dotnet ef migrations add producing an empty migration, then delete it.
Also add a factory used by create/update handlers:
public static EntityPictures Pending(string objectName);
See P3 for why "pending" must not point the resized names at the original file.
B.3.4 Pagination (Application/Shared/Pagination.cs)
Replaces X.PagedList (remove X.PagedList and X.PagedList.EF packages at the end of P7).
public record PageRequest(int PageNumber = 1, int PageSize = 20)
{
public const int MaxPageSize = 100;
}
public record PaginatedResponse<T>(
IReadOnlyList<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage);
public static class PaginationExtensions
{
public static Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken);
}
ToPaginatedAsyncclampsPageNumber >= 1and1 <= PageSize <= MaxPageSize, doesCountAsync+Skip/Takeon the same filtered query.- The query passed in must already be ordered and projected (B.3.5).
- The JSON shape of
PaginatedResponsestays exactly as today (same property names and order). - API side: endpoints bind
[AsParameters] PageRequest page;PageRequestValidatorrejectsPageNumber < 1andPageSizeoutside1..100with a 400.
B.3.5 Projections and text (Application/<Feature>/Responses/*Projections.cs, Application/Shared/TextNormalizer.cs)
public static class TrackProjections
{
public static IQueryable<Track> Active(this IQueryable<Track> query);
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query);
}
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query);
}
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query);
}
public static class TextNormalizer
{
public static string Normalize(string value); // value.Trim().ToUpperInvariant()
}
SelectResponseis a single EF-translatableSelect(...)building the response directly (owner name, tag list,ListeningHistories.Count(l => l.IsCounted), track count, cover ids). NoInclude, no anonymous intermediate types, noFromEntityin queries.- Keep
FromEntityonly where a handler already has a tracked entity in memory (create/update). VisibleTo(viewerId):Visibility == Public || OwnerUserId == viewerId.- Join-table queries (album tracks, playlist tracks, likes) order the join rows, then
Select(x => x.Track)and callSelectResponse().
B.3.6 Errors and ownership (Application/Shared/AppErrors.cs, Application/Shared/OwnershipExtensions.cs)
public static class AppErrors
{
public static Error NotFound(string entity, object id); // code "<Entity>.NotFound"
public static Error Forbidden(string entity, object id); // code "<Entity>.Forbidden"
public static Error Conflict(string code, string description);
}
public interface IOwnedEntity
{
Guid Id { get; }
long OwnerUserId { get; }
}
public static class OwnershipExtensions
{
public static Task<ErrorOr<T>> FindOwnedAsync<T>(
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
where T : class, IOwnedEntity;
}
Track, Album and PlayList implement IOwnedEntity. PlayList.UserId is renamed to
OwnerUserId with [Column("UserId")] (no migration).
B.3.7 Unique-violation mapping (Application/Contracts/IDatabase.cs)
Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
Implemented in Database by catching DbUpdateException whose inner exception is
PostgresException { SqlState: "23505" } (and the SQLite equivalent in TestDatabase). Used by
like, follow, add-to-playlist/album.
B.3.8 Lifecycle (Domain/Abstractions/IHasLifeCycle.cs)
public interface IHasLifeCycle
{
Guid Id { get; }
LifeCycleStatus LifeCycleStatus { get; set; }
}
Implemented by Track, Album, PlayList. Used by the generic MassTransit activities (P11).
B.3.9 Routing slips (Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs)
Replaces the preamble copied in 10 builders/consumers and Consumers/EndpointHelper.cs.
internal static class RoutingSlips
{
public static RoutingSlipBuilder Create(Guid? correlationId); // new id + CorrelationId variable + clean-up subscription
public static RoutingSlipBuilder AddStep(this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments);
public static RoutingSlipBuilder TrackFaults(this RoutingSlipBuilder builder, Guid subjectId, string processKind); // SubjectId + ProcessKind + fault subscription
}
RoutingSlipVariableNames moves to namespace Musify.Infrastructure.MassTransit.RoutingSlip
(it lives in that folder already).
C. Parts
P0: Formatting and guardrails (pure formatting commit)
Objective: make style mechanical so later parts don't argue about it.
Tasks
- Create
backend/.editorconfig(B.1.1) and updateDirectory.Build.props(B.1.2). - Remove BOMs from all
.csfiles (including the two mid-file ones inApplication/Events/UpdatePlayListPictureEvent.csandUpdateTrackPictureEvent.cs). - From
backend/:
dotnet format Musify.slnx
then fix by hand whatever dotnet format can't: the 43 stray spaces (} );, } ));,
nameof(X) )], (PID: {ProcessId} ), prefix: {Prefix} ), if (a == null|| …)), blank
lines right after { of a namespace/class, and missing private modifiers.
4. Build with warnings as errors. For analyzer rules that need behaviour changes, downgrade them
in .editorconfig with a # fixed in Pn comment (see B.1.2).
Don't: rename anything, change any logic, touch migrations.
Closing criteria: git diff --stat shows only whitespace/namespace/using changes (review
with git diff -w); 0 warnings; tests green; metrics in A.5 for namespaces, BOM, final newline,
stray spaces are at target.
P1: Security and privacy fixes
Objective: close data leaks and abuse paths. Small, surgical, test each one.
Files to read: Hosts/Musify.Api/Endpoints/UserEndpoints.cs, PlayListEndpoints.cs,
Application/PlayLists/*.cs, Application/Services/UploadIntentValidator.cs,
Application/Tracks/CreateTrack.cs, Application/Albums/CreateAlbum.cs,
Application/Albums/UpdateAlbum.cs, Application/PlayLists/CreatePlayList.cs,
Application/PlayLists/UpdatePlayList.cs, the upload validators in Hosts/Musify.Api/Validators,
Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs.
Tasks
- Delete
POST /users(anonymous, lets anyone create a user with any id). RemoveCreateUserCommand,CreateUserRequest,CreateUserRequestValidatorand their tests. Users are provisioned only bySyncUserCommandon token validation. The web-player doesn't call it. - Private playlists:
GET /playlists(all users' playlists, not used by the web-player): delete it andGetPlayListsQuery.GET /playlists/users/{userId}: remove theonlyPublicparameter; the handler getsViewerIdand appliesVisibleTo(viewerId)(owner sees everything, others only public). Contract change: web-playeruser/[id]/+page.server.tsstops sendingonlyPublic.GET /playlists/{id}/tracks: takeCurrentUser, returnNotFoundwhen the playlist isn't visible to the viewer (same rule asGetPlayListById).GET /playlists/{id}/cover: decision (recommended default): leave public. The web-player proxies covers without the user's token (web-player/src/lib/server/image.ts) and ids are unguessable GUIDs. Write that decision in the Log. If you choose to protect it, the proxy must forward the session token and the cache header must becomeprivate.
- Upload intents:
ValidateAndLoadAsynctakes the expectedUploadIntentPurposeand returnsValidationif it doesn't match.CreateTrackrejectsPictureIntentId == AudioIntentId. - Upload request validation (both picture validators and the track one):
FileType: letters/digits only, 1–10 characters, from an allow-list (jpg, jpeg, png, webpfor pictures;mp3, wav, flac, ogg, m4a, aacfor audio). Put the lists in one static class inHosts/Musify.Api/Validators/Uploads.csso the three validators share them.ContentType: must match the allow-list (image/*subset,audio/*subset).ExpectedSizeBytes:> 0and<= UploadIntentConfiguration.MaxUploadBytes.
- Gateway range clamp: when a ticket has
maxBytes, an unparsableRangeheader or a multi-range request must be replaced bybytes=0-{maxBytes-1}(not passed through). Handle suffix ranges (bytes=-N) by rejecting them with 416 whenmaxBytesis set. - Listening data privacy — decision (recommended default): require auth and self only for
GET /users/{id}/listening-stats(the web-player only reads it for the current user; check before changing). Leavelistening-historyandlast-listened-trackpublic (the profile page shows them) and write that in the Log.
Tests to add: anonymous POST /users → 404/405; other user's private playlist via
/playlists/users/{id} and /playlists/{id}/tracks → hidden; intent purpose mismatch;
same intent twice in CreateTrack; gateway middleware with multi-range and invalid range when
maxBytes is set.
Closing criteria: tests above green; web-player regenerated and npm run check clean.
P2: Processing pipeline bugs (Worker side)
Files to read: Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs,
RoutingSlip/Builders/*.cs, Sagas/*.cs, Activities/Audio/*.cs, both appsettings.json
(Api, Worker), Application/Configuration/UploadIntentConfiguration.cs.
Tasks
ProcessingSlipFaultConsumer: add theAlbumPicturecase publishingAlbumPictureProcessingFailed. Today album sagas stay inProcessingforever on failure.- Config keys: the appsettings use
TempRootPrefix,TempUploadsRetentionDays,TempCleanupJobIntervalSeconds; the class expectsTemporalRootPrefix,TemporalUploadsRetentionDays,TemporalCleanUpJobIntervalSeconds, so they're silently ignored. Rename the class properties toTempRootPrefix,TempUploadsRetentionDays,TempCleanupJobIntervalSeconds(shorter, and what ops already configured), keep the value"temp". Check that no object with thetemporal/prefix is left in the bucket (the clean-up job only lists the configured prefix); if there are, note it in the Log for a one-off clean-up. - The create-track, create-album and create-playlist slips (copy to final + consume intents +
publish processing event) have no fault subscription: if they fault the entity stays
Pendingforever. AddTrackFaults(...)with new process kindsTrackCreation,AlbumCreation,PlayListCreationthat publish the entity's*ProcessingFailedevent directly (so the existing failed-consumers mark it as failed and remove files). - Duration precision:
TranscodeAudioActivitystores(int)Math.Round(...); store thedoubleTotalSeconds. UpdateTrackAudioActivity: when the folder key is empty it publishesTrackAudioProcessedwithout marking the audio as completed. Throw instead (it's a programming error) so the slip faults and the saga marks the track failed.- Sagas and picture updates:
*PictureProcessed/*PictureProcessingFailedfor an update arrive at a saga that was finalized after creation. ConfigureOnMissingInstance(m => m.Discard())on those events in the three state machines, and verify against a local RabbitMQ that updating a picture no longer puts messages in*_errorqueues. Record the before/after in the Log (this was "to verify" in the audit).
Closing criteria: manual run of the stack (deploy/compose.dev.yml): create album with a
corrupt image → album ends Failed; update a playlist picture → no _error messages.
P3: Query and API bugs
Files to read: Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs, all
Application/**/Get*.cs, Application/PlayLists/UpdatePlayList.cs, CreatePlayList.cs,
Application/Albums/CreateAlbum.cs, UpdateAlbum.cs, DeleteAlbum.cs, Application/Likes/*.cs,
Application/Users/FollowUser.cs, Application/*/AddTrackTo*.cs,
Infrastructure/Persistence/Database.cs.
Tasks
- 403 for "not owner": replace the 10
Error.Unauthorized()withAppErrors.Forbidden(B.3.6). InErrorOrHttpExtensions, mapForbiddentoResults.Problem(statusCode: 403), and makeNotFound/Conflictreturn ProblemDetails too (today they return a bare string). Update.Produces(401)to.Produces(403)on owner-only endpoints. Contract change. - Pagination bounds: introduce
PageRequest+ validator (B.3.4) on every paginated endpoint (todaypageSize=0→ 500,pageSize=1000000→ unbounded). Boundlimiton/albums/recentto1..50. Remove the ad-hoc clamp inGetTracksByUserId. GetPlayListsByUserId:totalCountis computed before theActivefilter; move the filter before counting (P7 will replace this withToPaginatedAsyncanyway).- Lifecycle filter: public reads (
GetTracks,GetTracksByUserId,GetTrackById,GetAlbums*,GetAlbumById,GetAlbumTracks,GetPlayListTracks,GetLikedTracks,GetGenres,MixItemMapper,GetListeningHistory,GetRecentlyListenedAlbums) only returnActiverows. Stream onlyActivetracks. - Covers before processing:
CreateAlbum/UpdateAlbum/CreatePlayList/UpdatePlayListsetSmall/Medium/LargeName = objectName, so/cover?size=smalllooks for…/SmallPictures/{objectName}, which doesn't exist until processing ends → S3 throws → 500. Fix: resized names staynulluntil the worker sets them (EntityPictures.Pendingin B.3.3 sets onlyOriginalName), and the cover query falls back to the original key while the resized one is missing. Also make the cover endpoints return 404 (not 500) when the object doesn't exist (HeadObjectAsyncor catchNoSuchKeyin the storage service →null). - Races → 500: use
TrySaveChangesAsync(B.3.7) inToggleTrackLike,FollowUser,AddTrackToPlayList,AddTrackToAlbum, mapping the unique violation to a no-op success (like/follow) orConflict(add track). Add a unique index onPlayListHasTrack (PlayListId, TrackId)(migration; dedupe existing rows in the migration withDELETE … USINGkeeping the lowest position). - Track/position numbering: compute next position with
MaxAsync(x => (int?)x.Position) ?? -1in one query. Decide one base for both playlist positions and album track numbers (recommended: playlists 0-based internal position, album track numbers 1-based because they're user-visible; document it in the Log) and renumber on removal in both (playlists currently leave gaps). UpdatePlayList: trim the name; allow clearing the description (""→null;nullmeans "unchanged"); validateNewVisibilitywithIsInEnum().CreatePlayList: trim the name. SameIsInEnum()onCreatePlayListRequest.Visibility.GetMixesByUserIdN+1: one query for all cover items of all mixes.GetLastTrackListenedByUserIdcounts uncounted listens while the history only shows counted ones: filterIsCountedfor consistency, and return 204 (not 404) when there's none (check the web-player call site in(app)/+page.server.tsor wherever it's read).GetListeningStatsstreak: count the streak ending today or yesterday (a streak isn't broken until the day is over).DeleteAlbumremoves the row directly and leaves its pictures in storage. Give it the same shape as playlists: markRemoving, publishDeleteAlbumEvent, and aDeleteAlbumRoutingSlipBuilderthat removes the four pictures and the row. (If P11 already landed, build it with the generic activities.)
Closing criteria: each task has a test (Application tests for handlers, Api tests for status
codes); web-player regenerated; npm run check clean.
P4: Dead code removal
Objective: delete what nothing uses, before refactoring it.
Tasks
Infrastructure/Services/SingleFlightCache.cs+ its test +Microsoft.Extensions.Caching.MemoryfromInfrastructure.csproj(the API getsIMemoryCachefromAddMemoryCache).IStorageService.GetUrlAsyncandRemoveFolderAsync(only tests use the latter) + tests.IAudioTranscoderService.IsValidAudioFileAsync+ implementation +BuildValidateAudioArguments.DownloadFileFromUrlActivity,DownloadFileFromUrlArguments,DownloadFileFromUrlLog, its registration,ActivityNames.DownloadThumbnail, andservices.AddHttpClient()in the Worker.TrackAudio.DownloadRequested,RetryCount,LastRetryAtand their mapping (migrationDropTrackAudioRetryColumns).Uploadentity,UploadStateenum,IDatabase.Uploads, theUploadwrites inStorageService(migrationDropUploadTable). Updatedocs/architecture.md(mentionsUpload).DatabaseDesignTimeFactory: remove the unusedoptionsBuilder.Database.cs: remove theAppIDatabasealias.- Unused locals (
var response =inStorageService),.AsNoTracking().AsQueryable(), redundantdatabase.X.Update(entity)on tracked entities, the always-trueExpiredIntentsRetentionDays >= 0, the duplicatedif (pictureIntent != null && …)inUpdateAlbum/UpdatePlayList,AddPictureService's unusedconfigurationparameter,AddUploadIntentConfiguration(already done byAddApplication; see P13 for the Worker). Newtonsoft.JsonfromApi.csprojandWorker.csproj; package references inApi.csprojandWorker.csprojthat come transitively from Infrastructure (AWSSDK.S3, MassTransit., Npgsql, EF, OpenTelemetry., MimeMapping, ImageSharp, X.PagedList.EF). Keep only what the host uses directly. Rename the WorkerUserSecretsIdprefix (dotnet-PictureWorker-…) only if nobody relies on it locally (ask; otherwise leave and note).appsettings.jsonof the API: removeAudioTranscoderandWorkers(Worker-only). Worker: removeAudioTranscoder.Routes.WorkingDirectory(doesn't exist).CurrentUser.HasClaim/FindClaim/Principalif unused after grep.
Closing criteria: grep proves each symbol is gone; migrations added; tests green.
P5: Configuration unification
Prerequisites: P2 (config key rename there).
Tasks
- Create
Pictures.cs(B.3.1) andImageSizes(B.3.2); rewriteAlbumConfiguration,PlayListConfiguration,TrackConfigurationon top of them. Update every call site (BuildSmallPicturePath(x)→BuildPicturePath(PictureSize.Small, x)). - Update all
appsettings*.jsonwith the renamed keys and grepdeploy/. - One way to register options:
AddValidatedOptions<T>(move it toApplication/Configuration/ConfigurationDependencyInjection.csif not there, public). ReplaceRegisterValidatedOptionsin MassTransit DI and the inline copies inAddDatabase,AddStorageService,AddObservability,AddAudioTranscoder,AddStreamTicketService,AddAuthenticationConfiguration. Addpublic interface IConfigurationOptions { static abstract string SectionName { get; } }, implement it on every config class, and add the overloadAddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration) where T : class, IConfigurationOptionsso call sites stop repeating the section name. - Parameter name
serviceseverywhere (notserviceDescriptors). - Gateway: rename
StreamTicketValidationOptions→StreamTicketValidationConfigurationand inject the POCO like the rest (noIOptions<T>in constructors).
Closing criteria: grep -r "AddOptionsWithValidateOnStart" backend finds only
AddValidatedOptions; new ImageSize( appears only in PictureSizes.ToImageSizes.
P6: Domain cleanup
Tasks
EntityPictures(B.3.3) replacesAlbumPicturesandPlayListPictures.IHasLifeCycle(B.3.8) andIOwnedEntity(B.3.6); renamePlayList.UserId→OwnerUserIdwith[Column("UserId")].- Apply B.2 rule 10 to every entity: drop redundant
[Required], redundant initializers,#pragma warning disable CS8618(use= null!on navigations),requiredonTrack.Owner(thenCreateTrackno longer needs to load theUsertracked; it setsOwnerUserIdonly). - Rename
PlaylistVisibility→PlayListVisibility(enum type name only; the DB stores ints, JSON stores names, so no migration and no contract change — verify the OpenAPI schema name change doesn't break web-player types; regenerate). - Table names: decision (recommended default): don't rename tables (pure churn + risky
migration). Instead make every entity declare
[Table("…")]explicitly with its current name so the inconsistency is visible and frozen. Note in the Log. UploadIntent: moveIsExpired's clock read out of the entity (IsExpiredAt(DateTime now)) so it's testable.
Closing criteria: dotnet ef migrations add Check produces an empty migration (delete it);
tests green.
P7: Application shared helpers
Prerequisites: P6.
Tasks
- Create
Pagination.cs(B.3.4),*Projections.cs+TextNormalizer(B.3.5),AppErrors+OwnershipExtensions(B.3.6),TrySaveChangesAsync(B.3.7) if P3 didn't. - Rewrite every list query with
…Active().VisibleTo(…).OrderBy(…).SelectResponse().ToPaginatedAsync(page, ct). Delete everyStaticPagedList,ToPagedListAsync,Includeused only for mapping, and theX.PagedList*package references. - Delete
TracksSearchResponse,TrackSearchItemResponse,AlbumsSearchResponse,AlbumSearchItemResponse;GetTracksandGetAlbumsreturnPaginatedResponse<…>. Contract change: web-playerexplore/+page.svelteusesitems.map((item) => item.track)and the album equivalent; update to use items directly. - Replace the load/exists/owner/log boilerplate in every command with
FindOwnedAsync. - Apply B.2 rules 2–5 and 7 in every handler:
AppErrors,Result.Success, no try/catch around save/publish, logging policy, plain return types for handlers that can't fail (GetUsers,GetLikedTracks,GetRecentlyListenedAlbums,GetMixesByUserId…),GetListeningHistoryreturnsIReadOnlyList. - Move inline responses to
Responses/:UserProfileResponse,ListeningStatsResponse,GenreResponse,AvailableGenreResponse, the three*CoverLocation(which become oneCoverLocationin P8). - Use
TextNormalizer.Normalizefor every normalized column and search term. - Deterministic ordering with
Idtiebreaker everywhere;GetTracksByUserIdorders byCreatedAtdescending like the rest. PlayListProjections.SelectResponseaddsOwnerUserId(string-serialized, A.3.3),TrackCountandDurationSeconds(sum of active tracks) toPlayListApplicationResponse(additive contract change, needed by web-player P3.1 and P8.1; see A.4).
Closing criteria: A.5 metrics for cover-ids projection, listens-count projection and
StaticPagedList at target; line count of Application down ≥ 20 %.
P8: Pictures feature deduplication
Prerequisites: P5, P6, P7.
Objective: Album and PlayList picture flows are copies of each other (and of the picture half of Track). Make them one implementation.
Tasks
Application/Pictures/RequestPictureUpload.cs: one internal servicePictureUploadIssuer.IssueAsync(userId, purpose, IPictureOwnerConfiguration, fileType, contentType, expectedSize, ct)returningErrorOr<PictureUploadResponse>;RequestAlbumPictureUploadandRequestPlayListPictureUploadbecome thin commands calling it.AlbumPictureUploadResponseandPlayListPictureUploadResponsemerge intoPictureUploadResponse(same shape; OpenAPI name changes → regenerate web-player types).RequestTrackUploadUrlsreuses the intent-creation part for both of its intents. Also: issue the pre-signed URL after the quota check (today it's issued first and wasted on rejection).Application/Pictures/GetCover.cs: oneGetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size)(enum CoverOwner { Track, Album, PlayList }), oneCoverLocation, content type fromMimeUtilityfor all three (Track currently hand-rolls it). Size is parsed in the API withEnum.TryParse(defaultMedium).- Create/Update album and playlist: extract
PictureSourceChange.PrepareAsync(intentId, userId, IPictureOwnerConfiguration, purpose)returning theEntityPicturesand the event payload; handlers shrink to entity changes + publish. - Events:
CreateAlbumResourcesEvent/CreatePlayListResourcesEventandUpdateAlbumPictureSourceEvent/UpdatePlayListPictureSourceEventkeep separate types (the sagas correlate on them) but carryImageSizes Sizes(B.3.2). Message contract change (A.2). UploadIntentValidator: stop receivingUploadIntentConfigurationas a method parameter; inject it. Quota: compute count and bytes in one aggregate query instead of loading rows, use the audio default for audio intents, and ignore intents whoseExpiresAtalready passed.
Closing criteria: diff between album and playlist handlers is only names; the picture
handlers in Albums/ and PlayLists/ are each ≤ 40 lines.
P9: Responsibilities
Tasks
- Queries don't write: split
GetTrackStreamQueryintoStartListeningCommand(creates theListeningHistory, returns stream ticket +listenId).TrackStreamIssueronly issues the ticket and builds the URL. Same HTTP route and response (no contract change). - Serialization belongs to the API: move
LongAsStringConverter/NullableLongAsStringConvertertoHosts/Musify.Api/Serialization/, remove[JsonConverter]attributes from Application responses, and register both converters globally inConfigureHttpJsonOptions. That is safe because the onlylongvalues the API returns are user ids (ExpectedSizeBytesis request-only and the converter also reads numbers;TotalItemCountisint); re-check with a grep before merging. Update the OpenAPI schema transformer to mark everylongas string, and check that the regenerated web-player types don't change (they already see strings). - One validation place: tag rules only in
CreateTrackRequestValidator(remove from the handler, keep a domain guardGenreCompatibility.FindConflictscall in the handler only if commands can be sent from somewhere other than the API — they can't today, so remove);RecordListeningProgressgets a FluentValidation validator instead of handler checks; genre parsing inGetTracksmoves to binding (Genre? genrewith a validator). - Endpoint orchestration + bulk insert: the
AddPlayListTrackendpoint calls one command and stops re-querying the track. Replace it withPOST /playlists/{id}/trackstaking{ "trackIds": [...] }(1–500 ids) → 204, backed byAddTracksToPlayListCommandthat appends the tracks in order in one transaction, skipping those already present. This is the bulk endpoint web-player P8.4 asks for (A.4) and removes the per-track POST loop inweb-player/src/lib/server/playlistActions.ts. For consistency, albums get the same shape (POST /albums/{id}/trackswithtrackIds), replacingPOST /albums/{albumId}/tracks/{trackId}. Contract change on both; update the two web-player call sites. UserHasTrack: it duplicatesTrack.OwnerUserId(written only on create). Replace its reads (GetTracks,GetTracksByUserId,GetGenres,GenerateMixesForUser) withTracksfiltered by owner/active, then drop the table (migration). If the intent was a future "library" (saved tracks), write that in the Log and keep it, but then it must not be written on create.- Mix texts: move the Spanish titles out of
GenerateMixesForUser(B.2 rule 13): store aMixKindenum (Discovery,Daily) instead of title/subtitle strings and let the web-player render the text. Migration + contract change. Decision (recommended default): do it; if not, at least move the strings to a single constants class. GenerateMixesForUser: rely on cascade delete forMixItems(remove the manual item query).
P10: Infrastructure services
Tasks
StorageService: after P4 it no longer depends onIDatabase; register it as singleton. UseStorageKey.Combinefor keys; fix thetrasnsferUtilitytypo and dispose it; fixPath.Combine(sourceDirectory, file)(file is already a full path); prefix normalisation and pagination helpers shared byListObjectsAsync; no log-and-rethrow (B.2 rule 5); consistent log levels (Debugfor per-object operations).GetFileAsyncreturnsnullonNoSuchKey(used by the cover endpoints, P3.5).IStorageService:CancellationTokenlast (preventOverwritebefore it), parameter names camelCase.AudioTranscoderService.cs: rename the class toAudioTranscoderService(file already has that name). Access modifiers, no log-and-rethrow, oneExecuteFfmpegAsync(arguments, workingDirectory, Stream? input, …)instead of two near-identical methods.PictureService: no log-and-rethrow.MassTransitEventBus: expression-bodied, nonameof.- Jobs: one mechanism. Recommended: Hangfire for all four (it already runs in the Worker,
gives retries and a dashboard). Convert
UploadIntentExpirationJobandTemporalUploadsCleanUpJob(rename file/class toTempUploadsCleanupJob) to Hangfire recurring jobs with the configured intervals, and move their logic behind commands in Application (ExpireUploadIntentsCommand,DeleteStaleTempUploadsCommand) likeDeleteStaleUncountedListensCommand. RenameAddDailyMixGenerationJobtoAddJobsand register all jobs there. Job recurring registration moves out ofWorker/Program.csinto an extension in Infrastructure. - Observability:
AddObservabilityalso used by the Worker (P13). JwtBearerEventsHandler(Api): disposeHttpRequestMessage/response; read the userinfo endpoint from the OIDC discovery document instead of hardcoding/oidc/v1/userinfo.
P11: MassTransit deduplication
Prerequisites: P5 (ImageSizes), P6 (IHasLifeCycle).
Tasks
RoutingSlipshelper (B.3.9); rewrite every builder and failed-consumer with it. DeleteEndpointHelper.- Merge
AlbumPictureSourceRoutingSlipBuilderandPlayListPictureSourceRoutingSlipBuilderintoPictureSourceRoutingSlipBuilder(the only difference is the publish-activity arguments; pass a factory or aCoverOwner). - Generic lifecycle activities:
MarkLifeCycleActivity<TEntity>(arguments:Guid Id,LifeCycleStatus Status) andDeleteEntityActivity<TEntity>forTrack,PlayList,Album. ReplacesMark{Track,PlayList,Album}As{Failed,Removing}ActivityandDelete{Track,PlayList}FromDbActivity(+ their arguments). Keep distinct endpoint names per entity (mark-track-lifecycle, …) so queues stay separate. Message contract change (A.2). Update{Track,PlayList,Album}PictureActivity: one generic baseUpdatePicturesActivity<TEntity, TLog>with an abstract "apply/restore pictures" hook; Track additionally setsProcessingStatus. Compensation for Album/PlayList should also leave a consistent state (today only Track marks failed).*ProcessingFailedConsumer: one shared method building the clean-up slip from(entity, bucket, keys[]).- The 9 one-line consumers (
Create*Consumer,Update*Consumer,Delete*Consumer) stay one class per message (MassTransit needs it) but become expression-bodied with no blank lines. - The three Audio activities repeat "load track or throw, set status failed on error": extract
TrackAudioStatus.MarkFailedAsync(database, trackId, ct). GeneratePictureWorkflowPathsActivityisn't async: returnTask.FromResult.- DI: outbox configuration in one local function (used by client and consumers); saga repository configuration in one local function (used 3×); keep registration lists alphabetised.
ThrowIfNullwithoutnameof; injected services named after their type (storageService,pictureService).
Closing criteria: Infrastructure/MassTransit line count down ≥ 30 %; manual end-to-end run:
create/update/delete track, album and playlist, plus a forced failure for each.
P12: API layer
Tasks
Program.cs:WebApplication.CreateBuilder(args); sorted usings; CORS constant at top.- Cover endpoints: one
CoverEndpoint.Stream(...)helper used by the three/coverroutes. - Routes: parameters named
{id}for the resource itself and{trackId}for sub-resources; Guid routes use:guid, user ids:long.PlayListroutes keep the/playlistspath. - Every endpoint returns
IResultthroughToHttpResult(no raw return types), including the ones whose handlers can't fail (useResults.Ok). - Update DTOs: drop the
New…prefix (UpdateAlbumRequest(Title, Description, ReleaseYear, PictureIntentId)) — contract change, regenerate web-player; or keep and write the reason in the Log. Decision (recommended default): rename, it's the only place with that prefix. - Validators: one shared upload validator (P1.4 created the allow-lists); album and playlist
picture validators become one generic
PictureUploadRequestValidator<T>, or merge the DTOs intoPictureUploadRequest(preferred, since the responses merged in P8). OpenApiOptionsSetupandScalarOptionsSetup: share theSecuritySchemeIdconstant..WithSummary(...)strings: sentence case, no trailing period, consistently.JwtBearerOptionsSetup: single-line guard without braces (B.1.1when_multiline).- Move the validation limits into one
Hosts/Musify.Api/Validators/Limits.cs(playlist name 50, descriptions 256, titles 200, user name 48, release year range) used by every validator, and list them in the Log so web-player P7.1 copies the same values (A.4). If the two plans disagree on a limit (e.g. the web-player draft has playlist description 300 and track title 100), the database column length wins.
P13: Worker and Gateway hosts
Tasks
- Worker
Program.cs: callAddApplication(configuration)instead of registering Application options by hand (it currently forgetsAlbumConfiguration); addAddObservability; removeValidateOnBuild = falseand make the container validate. If a handler has dependencies the Worker can't provide (stream tickets, gateway config), register those configs too — they're just POCOs — rather than disabling validation. - Worker
appsettings.json: complete and consistent with the API for shared sections (after P5 the keys changed), remove stale ones. - Gateway:
Program.csusesAddValidatedOptions-style registration (it doesn't reference Application; copy the 6-line helper locally, or reference nothing and keepAddOptions…ValidateOnStart— decision (recommended default): keep it local and self-contained, the gateway must stay dependency-free); field naming per B.1.1 (no underscores; rename the middleware field that shadows theoptionsconstructor parameter);appsettings.jsondefault bucket in the YARP transform is overridden by compose — replace it with an obviously-placeholder value ("/buckets/CHANGE_ME/{**rest}") so the override is explicit, or document it indocs/streaming.md.
P14: Tests
Tasks
- Add tests for what has none today: MassTransit activities (at least the generic lifecycle and
picture-update activities with the SQLite
TestDatabase),ProcessingSlipFaultConsumer(every process kind), routing slip builders (activity order and arguments), saga state machines with MassTransit's test harness (happy path, failure, missing instance on update). - Endpoint tests for visibility/ownership (P1, P3) if not already added there.
- Keep
TestDatabasein sync withDatabase.OnModelCreating: extract the owned-type and index configuration ofDatabaseintoModelConfiguration.Apply(ModelBuilder)in Infrastructure and call it from both, so the test model can't drift. (RequiresApplication.Teststo reference Infrastructure, or move the test DB toInfrastructure.Tests; pick the former and note it.)
P15: Final sweep and documentation
Tasks
- Recompute the A.5 metrics (script below) and fill D.1.
- Grep for leftovers:
Error.Unauthorized(,StaticPagedList,new ImageSize(,ThrowIfNull(.*nameof,#pragma warning disable CS8618,tryblocks aroundSaveChangesAsyncinApplication/,serviceDescriptors,storageHandler,PlaylistVisibility. - Update
docs/architecture.md,docs/media-processing.md,docs/projects.md,docs/storage.md,docs/streaming.mdwith the new names (jobs, entities, routes). - Add a short
backend/CLAUDE.mdwith section B.2 (conventions) so future work follows them. - Keep this document as the record: don't delete section E, just make sure every row points to a checked part in D.
Metrics script (from backend/, Git Bash):
F=$(git ls-files | grep "\.cs$" | grep -v Migrations | grep -v Tests); cat $F | wc -l; grep -c "new StaticPagedList" $F | awk -F: '{s+=$2} END {print s}'; grep -c "new ImageSize(" $F | awk -F: '{s+=$2} END {print s}'
D. Log
| Part | Done | Date | Notes (decisions, contract changes, migrations, queue drains) |
|---|---|---|---|
| P0 Formatting and guardrails | [x] | 2026-09-24 | .editorconfig + analyzers added. Rules downgraded to suggestion (need behaviour/API changes, revisit in P15): CA1848, CA1873, CA1305, CA1707, CA1859, CA1716, CA1000, CA1725, CA1816, CA1822, CA1852, CA1068, CA1711, CA1861. 350 tests green (Docker available). |
| P1 Security and privacy | [x] | 2026-09-24 | Deleted POST /users, GET /playlists; /playlists/users/{id} lost onlyPublic (owner sees all, others public); /playlists/{id}/tracks hides non-visible playlists; intent purpose checked, same intent for picture+audio rejected; upload validators use allow-lists in Validators/Uploads.cs; gateway replaces multi/invalid Range and rejects suffix ranges with 416 when maxBytes is set; listening-stats now requires auth and self (403 otherwise). Decisions: /playlists/{id}/cover stays public; listening-history and last-listened-track stay public. Contract changes: web-player schema regenerated, onlyPublic call removed. Test helper ApiTestFixture.SeedUserAsync replaces POST /users. |
| P2 Pipeline bugs | [x] | 2026-09-24 | Album picture failures now reported; TempRootPrefix/TempUploadsRetentionDays/TempCleanupJobIntervalSeconds class properties renamed to match appsettings (default value temp); create track/album/playlist slips have fault subscriptions (new process kinds TrackCreation/AlbumCreation/PlayListCreation, slips carry Bucket/PictureKey/AudioKey variables so cleanup works); update-source slips fault as TrackPicture-style (AlbumPicture/PlayListPicture kinds); duration stored as double; empty audio folder key now throws; sagas discard picture/audio events for missing instances. NOT verified: manual stack run with RabbitMQ (no local stack); objects under the old temporal/ prefix in the bucket, if any, need a one-off clean-up. Queues should be drained before deploy (new variable names/process kinds). |
| P3 Query and API bugs | [x] | 2026-09-24 | All 12 tasks done. Contract changes: 403 (ProblemDetails) instead of 401 for not-owner; NotFound/Conflict now ProblemDetails; page params validated (pageNumber>=1, pageSize 1..100, bound through Api PageQuery, default size 20) and /albums/recent limit 1..50 -> 400; /users/{id}/last-listened-track returns 204 when empty; DELETE /albums/{id} is now async (Removing + DeleteAlbumEvent + DeleteAlbumRoutingSlipBuilder). Web-player: schema regenerated, fetchAllPages helper replaces pageSize 200/500 calls. Numbering decision: playlist positions 0-based (renumbered on removal), album track numbers 1-based (already renumbered). Migration PlayListTrackUnique (dedupes rows, unique index). Covers: resized names stay null until processed (AlbumPictures/PlayListPictures.Pending, cover falls back to original, S3 NoSuchKey -> GetFileAsync returns null -> 404). PageRequest helper + AppErrors + TrySaveChangesAsync created here (rest of P7 helpers still pending). Queues: new DeleteAlbumEvent, drain not required. |
| P4 Dead code | [x] | 2026-09-24 | All 12 tasks. Merged the two migrations into one: DropUploadTableAndTrackAudioRetryColumns. Removed SingleFlightCache (+test, Caching.Memory), IStorageService.GetUrlAsync/RemoveFolderAsync, IAudioTranscoderService.IsValidAudioFileAsync, DownloadFileFromUrl activity/args/log, Upload entity/UploadState/IDatabase.Uploads and StorageService DB writes (StorageService no longer depends on IDatabase), CurrentUser.HasClaim/FindClaim/Principal. Newtonsoft.Json pin moved to Infrastructure.csproj (Hangfire pulls a vulnerable 11.0.1 transitively; removing it from Api/Worker triggered NU1903). Api/Worker lost their redundant package references. AppIDatabase alias removed (Database now names Musify.Application.Contracts.IDatabase in full because it clashes with EF Storage.IDatabase). Kept AddUploadIntentConfiguration (Worker needs it until P13). Worker UserSecretsId left as is. Removed AudioTranscoder/Workers from Api appsettings, Worker AudioTranscoder.Routes.WorkingDirectory, and the matching env vars in deploy/compose.yml. docs/architecture.md and development.md updated. |
| P5 Configuration | [x] | 2026-09-24 | Pictures.cs (PictureSize, PictureRoutes, PictureSizes, IPictureOwnerConfiguration, PictureSizeParser), ImageSizes, IConfigurationOptions (static abstract SectionName; every config POCO implements it) and AddValidatedOptions(services, configuration) added; all inline/RegisterValidatedOptions copies removed. Config key renames: ParentFolders->ParentFolder, ProcessedAudioFolder->ProcessedAudiosFolder, Picture<Width |
| P6 Domain | [x] | 2026-09-24 | EntityPictures replaces AlbumPictures/PlayListPictures (Pending factory); IHasLifeCycle and IOwnedEntity live in Domain/Abstractions (Domain cannot reference Application); PlayList.UserId -> OwnerUserId ([Column(UserId)]) and User nav -> Owner; entity noise removed (redundant Required/initializers/pragma, nav = null!, Track.Owner no longer required; CreateTrack still loads the user because the response needs the artist name); PlaylistVisibility -> PlayListVisibility (OpenAPI schema name changed, web-player regenerated, check clean); explicit Table attributes with current names (tables NOT renamed); UploadIntent.IsExpired -> IsExpiredAt(now). Verified with an empty migration (deleted); only the model snapshot changed. No schema migration. |
| P7 Application helpers | [x] | 2026-09-24 | Pagination.cs (PageRequest, PaginatedResponse, ToPaginatedAsync), TextNormalizer, AppErrors, OwnershipExtensions.FindOwnedAsync (treats non-active as NotFound; IOwnedEntity has no Id, IHasLifeCycle supplies it), Track/Album/PlayListProjections. X.PagedList removed. GetTracks/GetAlbums return PaginatedResponse directly (contract: no more track/album wrapper; web-player updated). List queries keep int PageNumber/PageSize in the query record and build PageRequest inside the handler. PlayListApplicationResponse gained ownerUserId, trackCount, durationSeconds (web-player P3.1/P8.1 unblocked). Plain return types for GetUsers, GetLikedTracks, GetAlbums, GetTracks, GetPlayListsByUserId, GetRecentlyListenedAlbums, GetMixesByUserId, GetListeningHistory (IReadOnlyList). Inline responses moved to Responses/ (cover locations merge in P8). Create/Update album+playlist, Request*Upload, CreateTrack and RequestTrackUploadUrls keep their try/catch until they are rewritten in P8/P9. |
| P8 Pictures feature | [x] | 2026-09-24 | UploadIntentIssuer (IssueAsync/IssueOneAsync/IssuePictureAsync: user check, quota in a serializable transaction, presigned URLs issued AFTER the quota check) shared by album/playlist/track upload commands; PictureUploadResponse replaces AlbumPictureUploadResponse/PlayListPictureUploadResponse (OpenAPI schema renamed, web-player regenerated). GetCoverQuery(CoverOwner, Id, PictureSize)+CoverLocation replace the three cover queries; API uses CoverEndpoint.Stream helper (done here instead of P12.2). PictureSourceChange.PrepareAsync used by create/update album+playlist; events use the intent Bucket. UploadIntentValidator now injects UploadIntentConfiguration; quota is one aggregate query, uses the audio default for audio intents and ignores intents already past ExpiresAt. Events and Publish*Arguments carry ImageSizes Sizes (message contract change: drain queues before deploying). Update/Create handlers dropped their try/catch. |
| P9 Responsibilities | [x] | 2026-09-24 | StartListeningCommand replaces GetTrackStreamQuery (creates the ListeningHistory); TrackStreamIssuer is DB-free. LongAsString converters live in Api/Serialization and are registered globally (OpenAPI transformer marks response longs as string, request longs stay integer |
| P10 Infrastructure services | [x] | 2026-09-24 | StorageService is a singleton without log-and-rethrow (GetFileAsync null on NoSuchKey), IStorageService.GetUploadUrlAsync now (bucket,key,contentType,expiry,preventOverwrite,ct). AudioService -> AudioTranscoderService with a single ExecuteFfmpegAsync. PictureService and MassTransitEventBus simplified. Jobs: all four are Hangfire recurring jobs registered by RecurringJobsRegistrar (hosted service) inside AddJobs (was AddDailyMixGenerationJob); logic lives in ExpireUploadIntentsCommand / DeleteStaleTempUploadsCommand; TemporalUploadsCleanUpJob renamed TempUploadsCleanupJob. Hangfire cron has 1-minute resolution, so the configured *Seconds intervals are rounded up to whole minutes (UploadIntent ExpirationJobIntervalSeconds, TempCleanupJobIntervalSeconds). GenerateMixesForUser is now a plain ICommand. JwtBearerEventsHandler disposes request/response and reads the userinfo endpoint from the OIDC discovery document (falls back to /oidc/v1/userinfo). Observability for the Worker is wired in the Worker part. |
| P11 MassTransit | [x] | 2026-09-24 | RoutingSlips helper (Create/AddStep/TrackFaults/AddRemoveFile) replaces the slip preamble and EndpointHelper; RoutingSlipVariableNames moved to the RoutingSlip namespace. Merged builders: PictureSourceRoutingSlipBuilder (album+playlist) and DeleteRoutingSlipBuilder (track/album/playlist). Generic MarkLifeCycleActivity/DeleteEntityActivity and UpdatePicturesActivity with one thin subclass per entity (separate queues kept; IDatabase gained Set()). Album/playlist picture updates now return a compensation log. ProcessingFailedCleanup shared by the three failed consumers; TrackAudioStatus shared by the audio activities; consumers are expression-bodied; DI uses AddOutbox/ConfigureSagaRepository local helpers. MassTransit folder 3547 -> ~2530 lines (-29%). Contract change: new activity endpoints (mark--life-cycle, delete-, update-*-picture args/logs) - drain queues before deploying. Not done: the remaining log-and-rethrow in the Files activities, and the manual end-to-end run (no local stack). |
| P12 API layer | [x] | 2026-09-24 | Routes: resource is {id:guid}, sub-resource {trackId:guid}, user ids {id:long}/{userId:long} (OpenApiOptionsSetup maps int64 path params to string so the web-player keeps passing string ids). Contract changes: path params renamed (albumId/playlistId -> id, DELETE /tracks/{id}), New* prefix dropped from UpdateAlbumRequest/UpdatePlayListRequest, album+playlist picture DTOs/validators merged into PictureUploadRequest(+Validator), listening-stats 403 is now ProblemDetails; malformed guid/long routes now 404. Every endpoint returns IResult. Summaries in sentence case. Program: CreateBuilder(args) and RouteHandlerOptions.ThrowOnBadRequest=false (with args the test host runs as Development, which would turn bad query params into 500s). Validators/Limits.cs (web-player P7.1 must copy): track title 200, album title 200, playlist name 50, description 256 (album and playlist), release year 1877..current year+1, user name 48 (Domain only, no API validator), max 500 tracks per add request. Web-player: schema regenerated, call sites and update bodies adapted, npm run check clean. 365 tests green. |
| P13 Worker and Gateway | [x] | 2026-09-24 | Worker Program uses AddApplication + AddObservability + AddStreamTicketService and the container now validates (ValidateOnBuild and ValidateScopes on); checked by running the Worker in Development, it builds and starts the MassTransit endpoints. Because every Application handler is registered, the Worker also needs Album, StreamGateway, StreamTicket (key never read) and Playback sections: Worker appsettings.json now mirrors the Api for shared sections, appsettings.Development.json got the stream settings, deploy/compose.yml gives the worker StreamGateway__PublicBaseUrl, StreamTicket__PrivateKeyPath, the keys volume and keys-init dependency. IEventBus is now registered by AddMassTransitConsumers too. AddUploadIntentConfiguration removed. Gateway: kept its own options registration, no underscore fields (TicketValidator lost its unused options field), CreateBuilder(args), CorsPolicy constant, YARP bucket in appsettings.json is CHANGE_ME (compose overrides it; appsettings.Development.json carries webapi-storage for local runs). 365 tests green. Docs (media-processing.md Worker sections, streaming.md bucket) to update in P15. |
| P14 Tests | [x] | 2026-09-24 | ModelConfiguration.Apply(ModelBuilder) in Infrastructure/Persistence is called by Database and by the Application.Tests TestDatabase (Application.Tests now references Infrastructure; TestDatabase therefore also creates the saga tables). New Infrastructure.Tests (Infrastructure gets InternalsVisibleTo for Infrastructure.Tests and DynamicProxyGenAssembly2 so NSubstitute can mock ExecuteContext over internal argument types): ProcessingSlipFaultConsumer for every process kind plus unknown/missing kind, saga state machines (Track/Album/PlayList happy path, failure, event for a missing instance is discarded) on the MassTransit test harness, routing slip builders (create, audio workflow, the four picture-source slips, DeleteRoutingSlipBuilder against Postgres), lifecycle activities (mark/delete, missing entity is skipped, Postgres fixture instead of SQLite because the fixture already exists there), picture-update activities run inside real routing slips on the harness (execute, missing entity/variable fault the slip, compensation restores names / marks track pictures failed), RecurringJobsRegistrar.EveryCron. Api.Tests: PlayListEndpointsTests (private/public visibility, tracks hidden, per-user list, non-owner update/delete 403, non-guid id 404, name length). Observation: EveryCron maps 60+ minute intervals to whole hours by integer division (5400 s -> every hour), left as is. 432 tests green. |
| P15 Final sweep | [x] | 2026-09-24 | Metrics recomputed (D.1). Leftover greps: Error.Unauthorized( only in StartListening ("sign in", intended), new ImageSize( only in PictureSizes, no StaticPagedList/serviceDescriptors/storageHandler/PlaylistVisibility/ThrowIfNull(nameof); the two #pragma warning disable CS8618 in TrackAudio/TrackPictures were unnecessary and removed; no try/catch around SaveChangesAsync in Application. Log-and-rethrow removed from the Copy, Download, Transfer, Upload and Resize activities (Remove keeps its NoSuchKey catch; the bare catch blocks in the audio activities mark the track failed and are not log-and-rethrow). Analyzer overrides revisited: CA1000, CA1725 (param names), CA1816, CA1852, CA1068 (cancellation tokens moved last), CA1711 (only kept off for test collection fixtures), CA1861, CA1305 (InvariantCulture), CA1859 and CA1707 (off only under Tests/) are fixed or scoped; still suggestion: CA1848 and CA1873 (would need [LoggerMessage] on ~110 call sites), CA1716 (IDatabase.Set<T> and the Shared namespace, deliberate) and CA1822 (stateless DI builders). Added a private static readonly PascalCase naming rule. dotnet format applied (imports order and whitespace only) and --verify-no-changes is clean. docs/ (architecture, media-processing, projects, storage, streaming, development, authentication) updated to the new names, and backend/CLAUDE.md added. Temporary ZzOpenApiDumpTests deleted (to regenerate the web-player schema again, run the API in Development and export /openapi/v1.json). Every row of section E points to a part that is now checked. 431 tests green (432 with the temporary dump test); NOT verified: a manual end-to-end run of the full stack (RabbitMQ, SeaweedFS, Zitadel) after the refactor. Deploy: drain the queues first (message contracts changed in P2, P8, P11, P3) and apply the migrations PlayListTrackUnique, DropUploadTableAndTrackAudioRetryColumns, DropUserHasTrack, MixKind; the worker now needs StreamGateway__PublicBaseUrl and StreamTicket__PrivateKeyPath (+ keys volume), already added to deploy/compose.yml. |
D.1 Recalculated metrics (after P15)
| Metric | Before | After |
|---|---|---|
Non-migration .cs lines (excluding tests) |
12,687 | 10,424 (-17.9 %, below the -25 to -30 % target: the plan's dedup was done, the rest is behaviour the audit added such as bulk endpoints, ownership helpers, mix kinds and Hangfire jobs). MassTransit 3,547 -> 2,482, Application 3,811 |
new StaticPagedList |
7 | 0 |
new ImageSize( |
15 | 3 (inside PictureSizes) |
| Files with block namespace | 249 | 0 (306 file-scoped; 4 files have no namespace: AssemblyInfo and the three Program.cs) |
E. Findings index (audit 2026-09-24 → part)
E.1 Bugs
| # | Finding | Where | Part |
|---|---|---|---|
| 1 | POST /users is anonymous; anyone can create any user id |
UserEndpoints.cs:91 |
P1.1 |
| 2 | GET /playlists lists every user's private playlists |
PlayListEndpoints.cs:22, GetPlayLists.cs |
P1.2 |
| 3 | onlyPublic=false default exposes other users' private playlists |
PlayListEndpoints.cs:135 |
P1.2 |
| 4 | Playlist tracks endpoint ignores visibility | GetPlayListTracks.cs |
P1.2 |
| 5 | Upload intent purpose not checked; same intent usable for picture and audio | UploadIntentValidator.cs:45, CreateTrack.cs |
P1.3 |
| 6 | FileType/ContentType unrestricted; long extension overflows varchar(64) → 500 |
upload validators | P1.4 |
| 7 | Gateway passes invalid/multi Range unclamped → anonymous preview bypass |
TicketValidationMiddleware.cs:84 |
P1.5 |
| 8 | Album picture failures never reported; saga stuck in Processing |
ProcessingSlipFaultConsumer.cs:22 |
P2.1 |
| 9 | Temp* config keys don't match Temporal* properties → ignored |
both appsettings.json |
P2.2 |
| 10 | Create slips without fault handling → entity Pending forever |
CreateTrackRoutingSlipBuilder, picture-source builders |
P2.3 |
| 11 | Duration rounded to int although column is double | TranscodeAudioActivity.cs:58 |
P2.4 |
| 12 | Empty audio folder key publishes "processed" without completing | UpdateTrackAudioActivity.cs |
P2.5 |
| 13 | (to verify) picture-update events hit finalized sagas → _error queues |
Sagas/* |
P2.6 |
| 14 | "Not owner" returns 401 instead of 403 | ErrorOrHttpExtensions.cs:32 + 10 handlers |
P3.1 |
| 15 | pageSize=0 → 500; no upper bound; limit unbounded |
all list endpoints | P3.2 |
| 16 | totalCount computed before Active filter |
GetPlayListsByUserId.cs:41 |
P3.3 |
| 17 | Removed/failed rows returned by public reads | many queries | P3.4 |
| 18 | Album/playlist covers 500 until processing finishes | CreateAlbum.cs:63, cover queries |
P3.5 |
| 19 | Double-click like/follow/add → unique violation 500; playlist tracks can duplicate | likes, follows, AddTrackTo*, Database.cs |
P3.6 |
| 20 | Position Max+1 race; playlists 0-based without renumber vs albums 1-based with |
AddTrackTo*, RemoveTrackFrom* |
P3.7 |
| 21 | Playlist update can't clear description; names not trimmed; invalid enum accepted | UpdatePlayList.cs, CreatePlayList.cs |
P3.8 |
| 22 | N+1 in mixes list | GetMixesByUserId.cs |
P3.9 |
| 23 | Last-listened counts uncounted listens; 404 when none | GetLastTrackListenedByUserId.cs |
P3.10 |
| 24 | Streak resets before the day ends | GetListeningStats.cs |
P3.11 |
| 25 | DeleteAlbum leaves pictures in storage |
DeleteAlbum.cs:33 |
P3.12 |
| 26 | Path.Combine(sourceDirectory, file) with a full path |
StorageService.cs:121 |
P10.1 |
| 27 | Userinfo URL hardcoded to Zitadel path | JwtBearerEventsHandler.cs |
P10.7 |
| 28 | Wrong log message "as processing" on delete | DeleteTrack.cs:64 |
P7.5 (log policy rewrite) |
E.2 Endpoints not used by the web-player (free to change)
GET /playlists, POST /users, GET /users/{id}, GET /users/{id}/is-following,
GET /albums/recent, GET /tracks/{id}. GET /users/{id}/listening-stats has one call site;
check it before P1.6.
E.3 Quality findings
| Finding | Part |
|---|---|
No .editorconfig/analyzers; mixed namespaces, BOMs (2 mid-file), missing final newlines, stray spaces, blank lines after {, unsorted usings, missing private |
P0 |
Private field naming mixed (_x vs x), field shadowing ctor parameter in gateway middleware |
P0, P13 |
| Album ↔ PlayList handler clones (upload, cover, create/update picture parts) | P8 |
AlbumRoutes/PlayListRoutes/TrackRoutes + 3 *PicturesSizes; ParentFolders vs ParentFolder; identical BuildTempPicturePath/BuildTempAudioPath |
P5 |
AlbumPictures ≡ PlayListPictures |
P6 |
15× new ImageSize |
P5 |
9× cover-ids projection, 9× listens-count projection, 7× StaticPagedList |
P7 |
Search wrapper responses with swapped HasNext/HasPrevious order |
P7 |
| Owner-check boilerplate in ~12 handlers | P7 |
| try/catch around save/publish in ~15 handlers; log-and-rethrow in infrastructure | P7, P10 |
Inconsistent errors (NotFound() with/without description, one code), Result.Success vs new Success(), log levels |
P7 |
Handlers returning ErrorOr that can't fail; lazy IEnumerable result |
P7 |
Responses declared inside handler files; CreateUserCommand.ToEntity |
P7, P1 |
| Inconsistent ordering; pagination clamp in one handler only | P3, P7 |
Query that writes (GetTrackStream) |
P9 |
| JSON converters in Application | P9 |
| Duplicated validation (tags, listening progress, genre parsing in endpoint) | P9 |
Endpoint orchestration in AddPlayListTrack; album vs playlist add-track API mismatch |
P9 |
UserHasTrack duplicates ownership |
P9 |
| Hardcoded Spanish mix titles in Application | P9 |
StorageService writes to DB (Upload), typo trasnsferUtility, duplicated key building, CancellationToken not last, PascalCase parameter |
P4, P10 |
AudioService in AudioTranscoderService.cs; duplicated ffmpeg execution methods |
P10 |
Two job mechanisms (Hangfire + BackgroundService); job logic in infrastructure vs command; CleanUp/Cleanup naming; misleading AddDailyMixGenerationJob |
P10 |
8 copies of validated-options registration; serviceDescriptors vs services; …Options vs …Configuration |
P5 |
| 7 near-identical lifecycle activities; 3 picture-update activities; 2 picture-source builders; 3 failed consumers; routing-slip preamble ×10; outbox ×2; saga repo ×3 | P11 |
RoutingSlipVariableNames namespace ≠ folder |
P11 |
Cover endpoint ×3; route parameter naming; no route constraints; raw return types; New… DTO prefix; duplicated upload validators; duplicated SecuritySchemeId |
P12 |
Worker: manual options (misses Album), no observability, ValidateOnBuild = false, stale appsettings |
P13 |
Entity noise: redundant [Required], initializers, pragma vs null!, required navigation, mixed table naming, PlaylistVisibility casing |
P6 |
| Dead code (see P4 list) and redundant package references | P4 |
No tests for MassTransit activities, builders, sagas; TestDatabase drifts from Database |
P14 |