diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..128436e --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,79 @@ +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 = block_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 + +# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first. +# Private fields are _camelCase whatever their modifiers (static, readonly, const). +dotnet_naming_rule.private_fields.symbols = private_fields +dotnet_naming_rule.private_fields.style = underscore_camel_case +dotnet_naming_rule.private_fields.severity = warning +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel_case.capitalization = camel_case +dotnet_naming_style.underscore_camel_case.required_prefix = _ + +# Non-private fields (constants, static readonly, etc.) are PascalCase. +dotnet_naming_rule.other_fields.symbols = other_fields +dotnet_naming_rule.other_fields.style = pascal_case +dotnet_naming_rule.other_fields.severity = warning +dotnet_naming_symbols.other_fields.applicable_kinds = field +dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = interface_style +dotnet_naming_rule.interfaces.severity = warning +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.interface_style.capitalization = pascal_case +dotnet_naming_style.interface_style.required_prefix = I + +dotnet_naming_rule.types_and_members.symbols = types_and_members +dotnet_naming_rule.types_and_members.style = pascal_case +dotnet_naming_rule.types_and_members.severity = warning +dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event +dotnet_naming_symbols.types_and_members.applicable_accessibilities = * + +# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the +# EF-style IDatabase.Set() and the Musify.Application.Shared namespace, whose names are deliberate. +dotnet_diagnostic.CA1848.severity = suggestion +dotnet_diagnostic.CA1873.severity = suggestion +dotnet_diagnostic.CA1716.severity = suggestion +# The routing slip builders are stateless services resolved from DI; they stay instance members. +dotnet_diagnostic.CA1822.severity = suggestion + +# Test names follow Method_Scenario_Expected. +[Tests/**.cs] +dotnet_diagnostic.CA1707.severity = none +# xUnit collection fixtures are conventionally named ...Collection. +dotnet_diagnostic.CA1711.severity = none diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..4a07a5a --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,58 @@ +# Musify: backend + +.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`. + +## Commands + +Run from `backend/`. + +```sh +dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005) +dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers) +dotnet format Musify.slnx --verify-no-changes +``` + +- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit. +- EF migrations: `dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses. +- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean. +- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit. + +## Style + +- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it). +- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006). +- `ArgumentNullException.ThrowIfNull(x)` without `nameof`. +- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`). +- User-facing strings are in English (the web-player owns translation). +- Trim stored names; normalized columns go through `TextNormalizer.Normalize`. +- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last. + +## Application layer + +- One file per use case, `/.cs`, holding the record and its handler; responses go in `Responses/`. +- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204. +- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows. +- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found). +- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow. +- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`. + +## API layer + +- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids. +- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise). +- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`. +- `.WithSummary(...)` is sentence case without a trailing period. + +## Infrastructure and hosts + +- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity`, `DeleteEntityActivity`, `UpdatePicturesActivity`). +- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes. +- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's. +- The StreamingGateway references neither Application nor Infrastructure and keeps its own options. + +## Tests + +- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051). +- Test names are `Method_Scenario_Expected`. +- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness. +- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`. diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs deleted file mode 100644 index 219705e..0000000 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ /dev/null @@ -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>; - - public class AddTrackToAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> 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(); - } - } -} diff --git a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs new file mode 100644 index 0000000..455660e --- /dev/null +++ b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs @@ -0,0 +1,78 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums +{ + /// Appends your own tracks to an album in the given order, skipping the ones it already has. + public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) + : ICommand>; + + public class AddTracksToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { track.Id, track.OwnerUserId }) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var alreadyInAlbum = await database.AlbumHasTracks + .AsNoTracking() + .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Album track numbers are user-visible, so they start at 1. + var nextTrackNumber = (await database.AlbumHasTracks + .Where(link => link.AlbumId == request.AlbumId) + .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; + + foreach (var trackId in toAdd) + { + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = trackId, + TrackNumber = nextTrackNumber++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 842f97c..8be75e6 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -2,14 +2,15 @@ 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.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.Albums { @@ -26,9 +27,7 @@ namespace Musify.Application.Albums IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + AlbumConfiguration albumConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) @@ -37,92 +36,38 @@ namespace Musify.Application.Albums .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"); - } + return AppErrors.NotFound("User", request.UserId); - var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); - if (pictureIntent.IsError) - return pictureIntent.Errors; + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - var intent = pictureIntent.Value; + var picture = prepared.Value; var title = request.Title.Trim(); var album = new Album { OwnerUserId = request.UserId, Title = title, - NormalizedTitle = title.ToUpperInvariant(), + NormalizedTitle = TextNormalizer.Normalize(title), Description = request.Description, ReleaseYear = request.ReleaseYear, - Pictures = new AlbumPictures - { - OriginalName = intent.ObjectName, - SmallName = intent.ObjectName, - MediumName = intent.ObjectName, - LargeName = intent.ObjectName - } + Pictures = picture.Pictures }; 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; + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); - 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}"); - } + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); return AlbumApplicationResponse.FromEntity(album, trackCount: 0); } - - private async Task> 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}"); - } - } } } diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 182a1fb..137aeb5 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -1,8 +1,10 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums { @@ -11,40 +13,25 @@ namespace Musify.Application.Albums public class DeleteAlbumCommandHandler( IDatabase database, + IEventBus eventBus, ILogger logger) : ICommandHandler> { public async ValueTask> 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(); - } + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); - } + var album = found.Value; + album.LifeCycleStatus = LifeCycleStatus.Removing; - database.Albums.Remove(album); + await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - 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("Marked album {AlbumId} as removing", request.AlbumId); - logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); - - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 97e92c2..5461a17 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Albums { @@ -16,23 +17,14 @@ namespace Musify.Application.Albums { var album = await database.Albums .AsNoTracking() + .Active() .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() - }) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (album == null) - return Error.NotFound(); - - return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); + return album == null + ? AppErrors.NotFound("Album", request.Id) + : album; } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs deleted file mode 100644 index 0ade1b8..0000000 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ /dev/null @@ -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>; - - public record AlbumCoverLocation(string Bucket, string Key, string ContentType); - - public class GetAlbumCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration) - : IQueryHandler> - { - public async ValueTask> 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 builder, string? name) - => string.IsNullOrEmpty(name) ? null : builder(name); - } -} diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index fbee9b8..7da6847 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -1,11 +1,10 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; namespace Musify.Application.Albums { @@ -22,28 +21,19 @@ namespace Musify.Application.Albums { var albumExists = await database.Albums .AsNoTracking() + .Active() .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); if (!albumExists) - return Error.NotFound(); + return AppErrors.NotFound("Album", request.AlbumId); - var tracksQuery = database.AlbumHasTracks + return await 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 + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .OrderBy(albumTrack => albumTrack.TrackNumber) - .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index f149d35..2da6cbc 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -1,60 +1,34 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using X.PagedList; -using X.PagedList.EF; +using Musify.Application.Shared; namespace Musify.Application.Albums { public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>; + : IQuery>; public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler> { - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { var albumsQuery = database.Albums .AsNoTracking() - .AsQueryable(); + .Active(); - if (!string.IsNullOrEmpty(request.Title)) + if (!string.IsNullOrWhiteSpace(request.Title)) { - var normalizedTitle = request.Title.Trim().ToUpperInvariant(); + var normalizedTitle = TextNormalizer.Normalize(request.Title); albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); } - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery + return 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); + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index 8b7b5b2..53fe0d7 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -4,8 +4,6 @@ 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 { @@ -21,33 +19,16 @@ namespace Musify.Application.Albums .AsNoTracking() .AnyAsync(user => user.Id == request.UserId, cancellationToken); if (!userExists) - return Error.NotFound(description: $"User {request.UserId} not found"); + return AppErrors.NotFound("User", request.UserId); - var albumsQuery = database.Albums + return await database.Albums .AsNoTracking() - .Where(album => album.OwnerUserId == request.UserId); - - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery + .Active() + .Where(album => album.OwnerUserId == request.UserId) .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( - pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedAlbums); + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index b46190d..2f87e1a 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; @@ -7,54 +6,46 @@ using Musify.Application.Contracts; namespace Musify.Application.Albums { public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) - : IQuery>>; + : IQuery>; public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) { - var listenedTrackIds = database.ListeningHistories + var listens = database.ListeningHistories .AsNoTracking() .Where(history => history.UserId == request.UserId && history.IsCounted); var ranked = await database.AlbumHasTracks .AsNoTracking() + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Join( - listenedTrackIds, + listens, 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) + .ThenBy(entry => entry.AlbumId) .Take(request.Limit) .ToListAsync(cancellationToken); if (ranked.Count == 0) - return new List(); + return []; var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albums = await database.Albums + var albumsById = 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); + .SelectResponse() + .ToDictionaryAsync(album => 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)) + .Where(entry => albumsById.ContainsKey(entry.AlbumId)) + .Select(entry => albumsById[entry.AlbumId]) .ToList(); } } diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index d913840..04849fd 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Albums { @@ -16,20 +17,9 @@ namespace Musify.Application.Albums { public async ValueTask> 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 album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; var albumTracks = await database.AlbumHasTracks .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) @@ -38,10 +28,7 @@ namespace Musify.Application.Albums 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."); - } + return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); database.AlbumHasTracks.Remove(link); @@ -53,7 +40,7 @@ namespace Musify.Application.Albums logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index e0f0abc..68c0dfb 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -1,13 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Albums.Responses; +using Musify.Application.Pictures.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.Albums { @@ -16,90 +12,14 @@ namespace Musify.Application.Albums string FileType, string ContentType, long? ExpectedSizeBytes = null) - : ICommand>; + : ICommand>; - public class RequestAlbumPictureUploadCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) + : ICommandHandler> { - public async ValueTask> 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"); - } - } + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index ea03647..77ce78e 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,6 +1,4 @@ -using System.Text.Json.Serialization; using Musify.Domain.Entities; -using Musify.Application.Serialization; namespace Musify.Application.Albums.Responses { @@ -9,7 +7,7 @@ namespace Musify.Application.Albums.Responses string Title, string? Description, int? ReleaseYear, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, int TrackCount, string? SmallImageKeyName, string? MediumImageKeyName, @@ -18,7 +16,7 @@ namespace Musify.Application.Albums.Responses DateTime UpdatedAt, IReadOnlyList CoverTrackIds) { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public static AlbumApplicationResponse FromEntity( Album album, diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs new file mode 100644 index 0000000..cf4b63b --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs @@ -0,0 +1,32 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums.Responses +{ + public static class AlbumProjections + { + public const int CoverTrackCount = 4; + + public static IQueryable Active(this IQueryable query) => + query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(album => new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + album.AlbumTracks.Count, + album.Pictures!.SmallName, + album.Pictures!.MediumName, + album.Pictures!.LargeName, + album.CreatedAt, + album.UpdatedAt, + album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList())); + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs deleted file mode 100644 index b587b80..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Application.Albums.Responses -{ - public record AlbumSearchItemResponse(AlbumApplicationResponse Album) - { - public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album); - } -} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs deleted file mode 100644 index ea5c64c..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Musify.Application.Albums.Responses -{ - - public record AlbumsSearchResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasPreviousPage, - bool HasNextPage); -} diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index d89500e..dc627dc 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -2,14 +2,14 @@ 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.Configuration; using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Pictures; +using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums { @@ -27,124 +27,48 @@ namespace Musify.Application.Albums IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + AlbumConfiguration albumConfiguration) : ICommandHandler> { public async ValueTask> 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 found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var album = found.Value; var title = request.Title.Trim(); album.Title = title; - album.NormalizedTitle = title.ToUpperInvariant(); + album.NormalizedTitle = TextNormalizer.Normalize(title); album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; - UploadIntent? pictureIntent = null; - string? finalPictureKey = null; - - if (request.NewPictureIntentId.HasValue) + if (request.NewPictureIntentId is { } intentId) { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); - if (validation.IsError) - return validation.Errors; + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.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); - } + var picture = prepared.Value; + album.Pictures = picture.Pictures; - 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> 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)), + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), 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}"); } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated album {AlbumId}", album.Id); + + return await database.Albums + .AsNoTracking() + .Where(a => a.Id == album.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Application.csproj b/backend/Core/Musify.Application/Application.csproj index a424aa4..c1b27d7 100644 --- a/backend/Core/Musify.Application/Application.csproj +++ b/backend/Core/Musify.Application/Application.csproj @@ -19,8 +19,6 @@ - - diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 176157c..a96334a 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -3,25 +3,27 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Application.Configuration; using Musify.Application.Services; -namespace Musify.Application; - -public static class ApplicationDependencyInjection +namespace Musify.Application { - public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + public static class ApplicationDependencyInjection { - services.AddMediator(); - services.AddScoped(); - services.AddScoped(); + public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + { + services.AddMediator(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); - services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); - services.AddValidatedOptions(configuration, AlbumConfiguration.SectionName); - services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); - services.AddValidatedOptions(configuration, MixConfiguration.SectionName); - services.AddValidatedOptions(configuration, StreamGatewayConfiguration.SectionName); - services.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); - services.AddValidatedOptions(configuration, PlaybackConfiguration.SectionName); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Application/AssemblyInfo.cs b/backend/Core/Musify.Application/AssemblyInfo.cs index 845ff9b..f8c51ca 100644 --- a/backend/Core/Musify.Application/AssemblyInfo.cs +++ b/backend/Core/Musify.Application/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using Mediator; +using Mediator; using Microsoft.Extensions.DependencyInjection; -[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] \ No newline at end of file +[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs index 1802775..88db57b 100644 --- a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -1,79 +1,15 @@ using System.ComponentModel.DataAnnotations; -using Musify.Application.Shared; namespace Musify.Application.Configuration { - public class AlbumConfiguration + public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - public const string SectionName = "Album"; + public static string SectionName => "Album"; [Required] - public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; [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; + public PictureSizes PicturesSizes { get; set; } = new(); } } diff --git a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs index 1458cc3..199cc5e 100644 --- a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration { - public class ApplicationStorageConfiguration + public class ApplicationStorageConfiguration : IConfigurationOptions { - public const string SectionName = "ApplicationStorage"; + public static string SectionName => "ApplicationStorage"; [Required] [StringLength(128, MinimumLength = 1)] diff --git a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs index 4c341f3..ff58e2f 100644 --- a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs +++ b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs @@ -2,24 +2,29 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Musify.Application.Configuration; - -public static class ConfigurationDependencyInjection +namespace Musify.Application.Configuration { - public static IServiceCollection AddValidatedOptions( - this IServiceCollection services, - IConfiguration configuration, - string sectionName) - where T : class + public static class ConfigurationDependencyInjection { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(sectionName)) - .ValidateDataAnnotations(); + public static IServiceCollection AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) + where T : class, IConfigurationOptions => + services.AddValidatedOptions(configuration, T.SectionName); - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + public static IServiceCollection AddValidatedOptions( + this IServiceCollection services, + IConfiguration configuration, + string sectionName) + where T : class + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(sectionName)) + .ValidateDataAnnotations(); - return services; + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return services; + } } } diff --git a/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs new file mode 100644 index 0000000..8bd3ccf --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Configuration +{ + public interface IConfigurationOptions + { + public static abstract string SectionName { get; } + } +} diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index 5c99baf..1a71d6e 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration { - public class MixConfiguration + public class MixConfiguration : IConfigurationOptions { - public const string SectionName = "Mix"; + public static string SectionName => "Mix"; [Range(4, 100)] public int ItemsPerMix { get; set; } = 12; diff --git a/backend/Core/Musify.Application/Configuration/Pictures.cs b/backend/Core/Musify.Application/Configuration/Pictures.cs new file mode 100644 index 0000000..08235a1 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/Pictures.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Musify.Application.Shared; + +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) => StorageKey.Combine(ParentFolder, size switch + { + PictureSize.Small => SmallPicturesFolder, + PictureSize.Medium => MediumPicturesFolder, + PictureSize.Large => LargePicturesFolder, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, null) + }); + + public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName); + + public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, 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) => new( + new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), + new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), + new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); + } + + public interface IPictureOwnerConfiguration + { + public PictureRoutes Routes { get; } + + public PictureSizes PicturesSizes { get; } + } + + public static class PictureSizeParser + { + /// Parses the size query value; anything unknown means . + public static PictureSize Parse(string? value) => + Enum.TryParse(value, ignoreCase: true, out var size) && Enum.IsDefined(size) + ? size + : PictureSize.Medium; + } +} diff --git a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index cb1ea32..7550d1e 100644 --- a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -1,79 +1,15 @@ using System.ComponentModel.DataAnnotations; -using Musify.Application.Shared; namespace Musify.Application.Configuration { - public class PlayListConfiguration + public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - public const string SectionName = "PlayList"; + public static string SectionName => "PlayList"; [Required] - public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; [Required] - public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); + public PictureSizes PicturesSizes { get; set; } = new(); } - - public class PlayListRoutes - { - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolders { get; set; } = "PlayLists"; - - [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 PlayListPicturesSizes - { - [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; - } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs index 90ac7aa..bf0f0c2 100644 --- a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -3,9 +3,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration { - public sealed class PlaybackConfiguration + public sealed class PlaybackConfiguration : IConfigurationOptions { - public const string SectionName = "Playback"; + public static string SectionName => "Playback"; public bool AllowAnonymousListening { get; set; } diff --git a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs index edba302..488536d 100644 --- a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration { - public class StreamGatewayConfiguration + public class StreamGatewayConfiguration : IConfigurationOptions { - public const string SectionName = "StreamGateway"; + public static string SectionName => "StreamGateway"; [Url] [Required] diff --git a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs index 1304e29..b569b1f 100644 --- a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs @@ -1,38 +1,28 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using Musify.Application.Shared; namespace Musify.Application.Configuration { - public class TrackConfiguration + public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - public const string SectionName = "Track"; + public static string SectionName => "Track"; [Required] - public TrackRoutes Routes { get; set; } = new TrackRoutes(); + public TrackRoutes Routes { get; set; } = new(); [Required] - public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); + public PictureSizes PicturesSizes { get; set; } = new(); + + PictureRoutes IPictureOwnerConfiguration.Routes => Routes; } - public class TrackRoutes + public class TrackRoutes : PictureRoutes { - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolder { get; set; } = "Tracks"; - - [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 TrackRoutes() + { + ParentFolder = "Tracks"; + } [Required] public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; @@ -50,13 +40,7 @@ namespace Musify.Application.Configuration public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; [Required] - public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; - - public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder); - - public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder); - - public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); + public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); @@ -64,53 +48,10 @@ namespace Musify.Application.Configuration public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); + public string BuildOriginalAudioPath(long userId, string audioName) => + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName); - public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); - - public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); - - public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName); - - public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName); - - public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); - - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); - - public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName); - - public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName); - - public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName); - - public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName); - - public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); - - public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); + public string BuildProcessedAudioPath(string folderName) => + StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); } - - public class TrackPicturesSizes - { - [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; - } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index b18d32d..eb68c90 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace Musify.Application.Configuration { - public class UploadIntentConfiguration + public class UploadIntentConfiguration : IConfigurationOptions { - public const string SectionName = "UploadIntent"; + public static string SectionName => "UploadIntent"; [Range(10, 3600)] public int UploadUrlExpiresInSeconds { get; set; } = 120; @@ -31,12 +31,12 @@ namespace Musify.Application.Configuration public int ExpiredIntentsRetentionDays { get; set; } = 7; [Required, MinLength(1)] - public string TemporalRootPrefix { get; set; } = "temporal"; + public string TempRootPrefix { get; set; } = "temp"; [Range(1, 30)] - public int TemporalUploadsRetentionDays { get; set; } = 3; + public int TempUploadsRetentionDays { get; set; } = 3; [Range(60, 86400)] - public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; + public int TempCleanupJobIntervalSeconds { get; set; } = 3600; } } diff --git a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs index 6f6c885..e4f56a0 100644 --- a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs +++ b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs @@ -2,8 +2,6 @@ namespace Musify.Application.Contracts { public interface IAudioTranscoderService { - Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); - - Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); + public Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index df8ebe6..840a894 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -1,3 +1,4 @@ +using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Domain.Entities; @@ -5,38 +6,38 @@ namespace Musify.Application.Contracts { public interface IDatabase { - DbSet Users { get; } + public DbSet Users { get; } - DbSet PlayLists { get; } + public DbSet PlayLists { get; } - DbSet Albums { get; } + public DbSet Albums { get; } - DbSet AlbumHasTracks { get; } + public DbSet AlbumHasTracks { get; } - DbSet Tracks { get; } + public DbSet Tracks { get; } - DbSet UserHasTracks { get; } + public DbSet PlayListHasTracks { get; } - DbSet PlayListHasTracks { get; } + public DbSet Mixes { get; } - DbSet Mixes { get; } + public DbSet MixItems { get; } - DbSet MixItems { get; } + public DbSet UploadIntents { get; } - DbSet Uploads { get; } + public DbSet ListeningHistories { get; } - DbSet UploadIntents { get; } + public DbSet TrackLikes { get; } - DbSet ListeningHistories { get; } + public DbSet TrackTags { get; } - DbSet TrackLikes { get; } + public DbSet UserFollows { get; } - DbSet TrackTags { get; } + public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); - DbSet UserFollows { get; } + public DbSet Set() where TEntity : class; - Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + public Task SaveChangesAsync(CancellationToken cancellationToken); - Task SaveChangesAsync(CancellationToken cancellationToken); + public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs index 42e6185..1026ebb 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs @@ -2,6 +2,6 @@ namespace Musify.Application.Contracts { public interface IDatabaseTransaction : IAsyncDisposable { - Task CommitAsync(CancellationToken cancellationToken = default); + public Task CommitAsync(CancellationToken cancellationToken = default); } } diff --git a/backend/Core/Musify.Application/Contracts/IEventBus.cs b/backend/Core/Musify.Application/Contracts/IEventBus.cs index 1f5acb9..0f26bc5 100644 --- a/backend/Core/Musify.Application/Contracts/IEventBus.cs +++ b/backend/Core/Musify.Application/Contracts/IEventBus.cs @@ -2,6 +2,6 @@ namespace Musify.Application.Contracts { public interface IEventBus { - Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Contracts/IPictureService.cs b/backend/Core/Musify.Application/Contracts/IPictureService.cs index 8c8ccd2..b5958d8 100644 --- a/backend/Core/Musify.Application/Contracts/IPictureService.cs +++ b/backend/Core/Musify.Application/Contracts/IPictureService.cs @@ -2,6 +2,6 @@ namespace Musify.Application.Contracts { public interface IPictureService { - Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); + public Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index e5f3c24..dfd37ab 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -2,27 +2,24 @@ namespace Musify.Application.Contracts { public interface IStorageService { - Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + /// Opens an object for reading, or returns null when it does not exist. + public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); - Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); + public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); - Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); + public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); - Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); + public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); - Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); + public Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); - Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); + public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); - Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); - - Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, + public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken); - Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken); - - IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); + public IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs index 26925ac..c4d9118 100644 --- a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs +++ b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs @@ -2,8 +2,7 @@ namespace Musify.Application.Contracts { public interface IStreamTicketService { - - StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); } public record StreamTicket(string Token, int ExpiresInSeconds); diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs index a6bbe38..f048259 100644 --- a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -8,7 +8,5 @@ namespace Musify.Application.Events string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs index 80266cb..faf34b9 100644 --- a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs @@ -8,7 +8,5 @@ namespace Musify.Application.Events string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs index e7f08b5..6a78fd5 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -12,7 +12,5 @@ namespace Musify.Application.Events string AudioSourceKey, string AudioDestinationKey, string AudioProcessedFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs new file mode 100644 index 0000000..ebe52d0 --- /dev/null +++ b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Events +{ + public record DeleteAlbumEvent( + Guid AlbumId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs index 338dc6c..6ec428d 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -6,7 +6,5 @@ namespace Musify.Application.Events Guid AlbumId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs index a6de018..92d727e 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -8,7 +8,5 @@ namespace Musify.Application.Events string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs index d617c75..e28110d 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs @@ -1,12 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events +namespace Musify.Application.Events { public record UpdatePlayListPictureEvent( Guid PlayListId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} \ No newline at end of file + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs index 24c7101..a5096fd 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -8,7 +8,5 @@ namespace Musify.Application.Events string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs index 30c7ede..e1bc155 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs @@ -1,4 +1,4 @@ -namespace Musify.Application.Events +namespace Musify.Application.Events { public record UpdateTrackAudioEvent( Guid TrackId, @@ -6,4 +6,4 @@ string SourceKey, string DestinationBucket, string DestinationFolderKey); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs index 8973e64..e7ca8f4 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs @@ -1,12 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events +namespace Musify.Application.Events { public record UpdateTrackPictureEvent( Guid TrackId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} \ No newline at end of file + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index 32c2563..f96eb11 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -1,12 +1,11 @@ using Mediator; +using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; namespace Musify.Application.Genres { public record GetAvailableGenresQuery : IQuery>; - public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); - public class GetAvailableGenresQueryHandler : IQueryHandler> { diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 1dece8f..680793e 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -1,14 +1,13 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; namespace Musify.Application.Genres { public record GetGenresQuery : IQuery>; - public record GenreResponse(Genre Genre, int TrackCount); - public class GetGenresQueryHandler(IDatabase database) : IQueryHandler> { @@ -16,7 +15,7 @@ namespace Musify.Application.Genres { var rows = await database.TrackTags .AsNoTracking() - .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) + .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) .GroupBy(tag => tag.Tag) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs new file mode 100644 index 0000000..85060be --- /dev/null +++ b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs @@ -0,0 +1,6 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres.Responses +{ + public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); +} diff --git a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs new file mode 100644 index 0000000..1ffd036 --- /dev/null +++ b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs @@ -0,0 +1,6 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres.Responses +{ + public record GenreResponse(Genre Genre, int TrackCount); +} diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index e50d7c1..5b4a2b1 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -1,11 +1,9 @@ -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; +using Musify.Domain.ValueObjects; namespace Musify.Application.Likes { @@ -13,31 +11,21 @@ namespace Musify.Application.Likes long UserId, int PageNumber, int PageSize) - : IQuery>>; + : IQuery>; public class GetLikedTracksQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) { - var likesQuery = database.TrackLikes + return await 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 + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) .OrderByDescending(like => like.CreatedAt) - .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .ThenBy(like => like.Id) + .Select(like => like.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 7fe93a1..95e5978 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -3,7 +3,9 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Likes { @@ -19,12 +21,9 @@ namespace Musify.Application.Likes { var trackExists = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } + return AppErrors.NotFound("Track", request.TrackId); var existingLike = await database.TrackLikes .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); @@ -45,7 +44,11 @@ namespace Musify.Application.Likes nowLiked = true; } - await database.SaveChangesAsync(cancellationToken); + // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); + if (saved.IsError) + return true; logger.LogInformation("User {UserId} {Action} track {TrackId}", request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 84df25f..7cda638 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,30 +9,30 @@ using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes { public record GenerateMixesForUserCommand(long UserId) - : ICommand>; + : ICommand; public class GenerateMixesForUserCommandHandler( IDatabase database, MixConfiguration mixConfiguration, ILogger logger) - : ICommandHandler> + : ICommandHandler { - private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); + private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); - public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) + public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; - var libraryTrackIds = await database.UserHasTracks + var libraryTrackIds = await database.Tracks .AsNoTracking() - .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(userTrack => userTrack.TrackId) + .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) .ToListAsync(cancellationToken); if (libraryTrackIds.Count == 0) { logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); - return new Success(); + return Unit.Value; } var historyTrackIds = await database.ListeningHistories @@ -51,41 +50,27 @@ namespace Musify.Application.Mixes 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)); + drafts.Add(new MixDraft(MixKind.Discovery, 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)); + drafts.Add(new MixDraft(MixKind.Daily, daily)); } if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return new Success(); + return Unit.Value; } 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}"); - } + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return new Success(); + return Unit.Value; } private List Take(IEnumerable trackIds) => @@ -94,7 +79,7 @@ namespace Musify.Application.Mixes private static List Shuffle(IEnumerable trackIds) => [.. trackIds.OrderBy(_ => Guid.NewGuid())]; - private static List Interleave(IReadOnlyList first, IReadOnlyList second) + private static List Interleave(List first, List second) { var merged = new List(first.Count + second.Count); for (var index = 0; index < Math.Max(first.Count, second.Count); index++) @@ -116,12 +101,6 @@ namespace Musify.Application.Mixes 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); } @@ -131,8 +110,7 @@ namespace Musify.Application.Mixes var mix = new Mix { UserId = userId, - Title = draft.Title, - Subtitle = draft.Subtitle, + Kind = draft.Kind, Position = position }; diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index c909ad1..65c8a9d 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; +using Musify.Application.Shared; namespace Musify.Application.Mixes { @@ -20,12 +21,12 @@ namespace Musify.Application.Mixes .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); if (mix == null) - return Error.NotFound(description: $"Mix {request.MixId} was not found"); + return AppErrors.NotFound("Mix", request.MixId); 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); + return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index 570aa6e..73ea1bb 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; @@ -7,28 +6,43 @@ using Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes { public record GetMixesByUserIdQuery(long UserId) - : IQuery>>; + : IQuery>; public class GetMixesByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask> 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) + .ThenBy(mix => mix.Id) .ToListAsync(cancellationToken); + var orderedItemsByMix = mixes.ToDictionary( + mix => mix.Id, + mix => mix.Items.OrderBy(item => item.Position).ToList()); + + var coverItems = orderedItemsByMix.Values + .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) + .ToList(); + var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) + .GroupBy(response => response.TrackId) + .ToDictionary(group => group.Key, group => group.First()); + var responses = new List(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); + var orderedItems = orderedItemsByMix[mix.Id]; + var covers = orderedItems + .Take(MixApplicationResponse.CoverItemCount) + .Where(item => coverResponses.ContainsKey(item.TrackId)) + .Select(item => coverResponses[item.TrackId]) + .ToList(); - responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); + responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); } return responses; diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index e6493df..e5a6520 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes { @@ -18,7 +19,7 @@ namespace Musify.Application.Mixes var tracksById = await database.Tracks .AsNoTracking() .Include(track => track.Owner) - .Where(track => trackIds.Contains(track.Id)) + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(x => x.Track.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index 6af9494..6172bbc 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,9 +1,10 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Application.Mixes.Responses { public record MixApplicationResponse( Guid Id, - string Title, - string? Subtitle, + MixKind Kind, int ItemCount, IReadOnlyList Items) { diff --git a/backend/Core/Musify.Application/Pictures/GetCover.cs b/backend/Core/Musify.Application/Pictures/GetCover.cs new file mode 100644 index 0000000..1e45cbc --- /dev/null +++ b/backend/Core/Musify.Application/Pictures/GetCover.cs @@ -0,0 +1,76 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Pictures +{ + public enum CoverOwner + { + Track, + Album, + PlayList + } + + public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) + : IQuery>; + + public record CoverLocation(string Bucket, string Key, string ContentType); + + public class GetCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> + { + private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); + + public async ValueTask> Handle(GetCoverQuery request, CancellationToken cancellationToken) + { + var (source, routes) = request.Owner switch + { + CoverOwner.Track => (await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), + CoverOwner.Album => (await database.Albums.AsNoTracking() + .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) + .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes), + _ => (await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null) + .Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes) + }; + + var entity = request.Owner.ToString(); + if (source == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); + + var resizedName = request.Size switch + { + PictureSize.Small => source.Small, + PictureSize.Large => source.Large, + _ => source.Medium + }; + + // The resized files only exist once processing has finished; until then serve the original. + var (name, key) = !string.IsNullOrEmpty(resizedName) + ? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) + : !string.IsNullOrEmpty(source.Original) + ? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original)) + : (null, null); + + if (name == null || key == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); + + return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); + } + } +} diff --git a/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs new file mode 100644 index 0000000..90df675 --- /dev/null +++ b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs @@ -0,0 +1,36 @@ +using ErrorOr; +using Musify.Application.Configuration; +using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Pictures +{ + /// What an album or playlist needs to switch to a freshly uploaded picture. + public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes); + + public static class PictureSourceChange + { + public static async Task> PrepareAsync( + UploadIntentValidator uploadIntentValidator, + Guid intentId, + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + CancellationToken cancellationToken) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken); + if (validation.IsError) + return validation.Errors; + + var intent = validation.Value; + + return new PreparedPicture( + intent, + EntityPictures.Pending(intent.ObjectName), + owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), + owner.PicturesSizes.ToImageSizes(owner.Routes)); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs similarity index 66% rename from backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs rename to backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs index ab3dcd3..dc67b39 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs +++ b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs @@ -1,6 +1,6 @@ -namespace Musify.Application.Albums.Responses +namespace Musify.Application.Pictures.Responses { - public record AlbumPictureUploadResponse( + public record PictureUploadResponse( Guid IntentId, string Bucket, string Key, diff --git a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs index 55b39bc..88ee148 100644 --- a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs @@ -2,12 +2,15 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.PlayLists { + /// Appends every track of an album to a playlist in album order, skipping the ones it already has. public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId) : ICommand>; @@ -18,41 +21,29 @@ namespace Musify.Application.PlayLists { public async ValueTask> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; var albumExists = await database.Albums .AsNoTracking() + .Active() .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); if (!albumExists) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(description: $"Album {request.AlbumId} not found"); - } + return AppErrors.NotFound("Album", request.AlbumId); var albumTrackIds = await database.AlbumHasTracks .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .OrderBy(albumTrack => albumTrack.TrackNumber) .Select(albumTrack => albumTrack.TrackId) .ToListAsync(cancellationToken); var playListTracksQuery = database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId); + .Where(link => link.PlayListId == request.PlayListId); var existingTrackIds = (await playListTracksQuery - .Select(plt => plt.TrackId) + .Select(link => link.TrackId) .ToListAsync(cancellationToken)).ToHashSet(); var newTrackIds = albumTrackIds @@ -62,20 +53,24 @@ namespace Musify.Application.PlayLists if (newTrackIds.Count == 0) return new AddAlbumToPlayListResponse(0); - var nextPosition = existingTrackIds.Count > 0 - ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 - : 0; + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await playListTracksQuery + .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; - await database.PlayListHasTracks.AddRangeAsync( - newTrackIds.Select((trackId, index) => new PlayListHasTrack + foreach (var trackId in newTrackIds) + { + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack { PlayListId = request.PlayListId, TrackId = trackId, - Position = nextPosition + index - }), - cancellationToken); + Position = nextPosition++ + }, cancellationToken); + } - await database.SaveChangesAsync(cancellationToken); + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved.Errors; logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}", newTrackIds.Count, request.AlbumId, request.PlayListId); diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs deleted file mode 100644 index 737f9be..0000000 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ /dev/null @@ -1,75 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.Entities; - -namespace Musify.Application.PlayLists -{ - public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) - : ICommand>; - - public class AddTrackToPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } - - 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 alreadyAdded = await database.PlayListHasTracks - .AsNoTracking() - .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - { - logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.Conflict(description: "Track is already in the playlist."); - } - - var playListTracksQuery = database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId); - - var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken); - var nextPosition = hasTracks - ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 - : 0; - - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack - { - PlayListId = request.PlayListId, - TrackId = request.TrackId, - Position = nextPosition - }, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", - request.TrackId, request.PlayListId, nextPosition); - - return new Success(); - } - } -} diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs new file mode 100644 index 0000000..87db300 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -0,0 +1,74 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists +{ + /// Appends tracks to a playlist in the given order, skipping the ones it already has. + public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) + : ICommand>; + + public class AddTracksToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var existingIds = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var alreadyInPlayList = await database.PlayListHasTracks + .AsNoTracking() + .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await database.PlayListHasTracks + .Where(link => link.PlayListId == request.PlayListId) + .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + + foreach (var trackId in toAdd) + { + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 1568a88..fec544d 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -3,13 +3,14 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.PlayLists { @@ -18,7 +19,7 @@ namespace Musify.Application.PlayLists string Name, string? Description, Guid? PictureIntentId, - PlaylistVisibility Visibility = PlaylistVisibility.Private) + PlayListVisibility Visibility = PlayListVisibility.Private) : ICommand>; public class CreatePlayListCommandHandler( @@ -26,121 +27,53 @@ namespace Musify.Application.PlayLists IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + PlayListConfiguration playListConfiguration) : ICommandHandler> { - private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); - public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { var userExists = await database.Users .AsNoTracking() .AnyAsync(u => u.Id == request.UserId, cancellationToken); if (!userExists) + return AppErrors.NotFound("User", request.UserId); + + PreparedPicture? picture = null; + if (request.PictureIntentId is { } intentId) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + picture = prepared.Value; } - var picturesResult = await ResolvePicturesAsync(request, cancellationToken); - if (picturesResult.IsError) - return picturesResult.Errors; - - var resolvedPictures = picturesResult.Value; - + var name = request.Name.Trim(); var playList = new PlayList { - UserId = request.UserId, - Name = request.Name, - NormalizedName = request.Name.Trim().ToUpperInvariant(), + OwnerUserId = request.UserId, + Name = name, + NormalizedName = TextNormalizer.Normalize(name), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), - Pictures = resolvedPictures.Pictures, + Pictures = picture?.Pictures, Visibility = request.Visibility }; await database.PlayLists.AddAsync(playList, cancellationToken); - if (resolvedPictures.Intent != null) - { - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName); - var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken); - if (publishResult.IsError) - return publishResult.Errors; - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId); - return Error.Failure(description: $"Failed to create playlist for user {request.UserId}"); - } - - logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); - return PlayListApplicationResponse.FromEntity(playList); - } - - private async ValueTask> ResolvePicturesAsync( - CreatePlayListCommand request, CancellationToken cancellationToken) - { - if (!request.PictureIntentId.HasValue) - { - return new ResolvedPlayListPictures(null, null); - } - - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); - - if (validation.IsError) - return validation.Errors; - - return new ResolvedPlayListPictures(new PlayListPictures - { - OriginalName = validation.Value.ObjectName, - SmallName = validation.Value.ObjectName, - MediumName = validation.Value.ObjectName, - LargeName = validation.Value.ObjectName - }, validation.Value); - } - - private async Task> PublishCreatePlayListEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) - { - try + if (picture != null) { await eventBus.PublishAsync( new CreatePlayListResourcesEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - new ImageSize( - playListConfiguration.Routes.SmallPicturesPath, - playListConfiguration.PicturesSizes.SmallPictureWidth, - playListConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - playListConfiguration.Routes.MediumPicturesPath, - playListConfiguration.PicturesSizes.MediumPictureWidth, - playListConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - playListConfiguration.Routes.LargePicturesPath, - playListConfiguration.PicturesSizes.LargePictureWidth, - playListConfiguration.PicturesSizes.LargePictureHeight)), + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return new Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId); - return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}"); } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); + + return PlayListApplicationResponse.FromEntity(playList); } } } diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 51b4fd5..fa465ec 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -1,9 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists @@ -19,44 +19,19 @@ namespace Musify.Application.PlayLists { public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); - return Error.Unauthorized(); - } + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var playList = found.Value; playList.LifeCycleStatus = LifeCycleStatus.Removing; - try - { - await eventBus.PublishAsync( - new DeletePlayListEvent(playList.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId); - return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); - } + await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId); - return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); - } + logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index ef55024..d532bc2 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -1,9 +1,9 @@ using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.PlayLists.Responses; using Mediator; +using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; namespace Musify.Application.PlayLists { @@ -15,30 +15,17 @@ namespace Musify.Application.PlayLists { public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) { - var entry = await database.PlayLists + var playList = await database.PlayLists .AsNoTracking() + .Active() + .VisibleTo(request.RequestingUserId) .Where(p => p.Id == request.Id) - .Select(p => new - { - PlayList = p, - TrackCount = p.PlayListTracks.Count, - DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) + .SelectResponse() .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.TrackCount, entry.DurationSeconds, entry.CoverTrackIds); + return playList == null + ? AppErrors.NotFound("PlayList", request.Id) + : playList; } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs deleted file mode 100644 index 312d609..0000000 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ /dev/null @@ -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>; - - public record PlayListCoverLocation(string Bucket, string Key, string ContentType); - - public class GetPlayListCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration) - : IQueryHandler> - { - public async ValueTask> 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 builder, string? name) - => string.IsNullOrEmpty(name) ? null : builder(name); - } -} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index b26a3fc..9da4317 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -2,17 +2,18 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists { public record GetPlayListTracksQuery( Guid PlayListId, int PageNumber, - int PageSize) + int PageSize, + long? ViewerId = null) : IQuery>>; public class GetPlayListTracksQueryHandler(IDatabase database) @@ -20,30 +21,22 @@ namespace Musify.Application.PlayLists { public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) { - var playListExists = await database.PlayLists + var playListVisible = await database.PlayLists .AsNoTracking() - .AnyAsync(p => p.Id == request.PlayListId, cancellationToken); - if (!playListExists) - return Error.NotFound(); + .Active() + .VisibleTo(request.ViewerId) + .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); + if (!playListVisible) + return AppErrors.NotFound("PlayList", request.PlayListId); - var tracksQuery = database.PlayListHasTracks + return await database.PlayListHasTracks .AsNoTracking() - .Include(plt => plt.Track.Owner) - .Include(plt => plt.Track.Tags) - .Where(plt => plt.PlayListId == request.PlayListId); - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderBy(plt => plt.Position) - .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(playListTrack => playListTrack.Position) + .ThenBy(playListTrack => playListTrack.Id) + .Select(playListTrack => playListTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs deleted file mode 100644 index 50df7ce..0000000 --- a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs +++ /dev/null @@ -1,45 +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>>; - - public class GetPlayListsQueryHandler(IDatabase database) - : IQueryHandler>> - { - public async ValueTask>> 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, - TrackCount = p.PlayListTracks.Count, - DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, - 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( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedPlayLists); - } - } -} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 92d24f7..14f49ee 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -1,12 +1,8 @@ -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 { @@ -15,52 +11,31 @@ namespace Musify.Application.PlayLists string? Name, int PageNumber, int PageSize, - bool OnlyPublic = false) - : IQuery>>; + long? ViewerId = null) + : IQuery>; public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) { var playListsQuery = database.PlayLists .AsNoTracking() - .Where(p => p.UserId == request.UserId); + .Active() + .VisibleTo(request.ViewerId) + .Where(playList => playList.OwnerUserId == request.UserId); - if (request.OnlyPublic) + if (!string.IsNullOrWhiteSpace(request.Name)) { - playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); + var normalizedName = TextNormalizer.Normalize(request.Name); + playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); } - 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, - TrackCount = p.PlayListTracks.Count, - DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, - 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( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedPlayLists); + return await playListsQuery + .OrderBy(playList => playList.CreatedAt) + .ThenBy(playList => playList.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index 1202324..1e071db 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.PlayLists { @@ -16,34 +17,30 @@ namespace Musify.Application.PlayLists { public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + var playListTracks = await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .OrderBy(plt => plt.Position) + .ToListAsync(cancellationToken); - var link = await database.PlayListHasTracks - .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); if (link == null) - { - logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.NotFound(description: "Track is not in the playlist."); - } + return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); database.PlayListHasTracks.Remove(link); + + var position = 0; + foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) + remaining.Position = position++; + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index dbf519d..0ebfd95 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -1,13 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.PlayLists.Responses; +using Musify.Application.Pictures.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.PlayLists { @@ -16,90 +12,14 @@ namespace Musify.Application.PlayLists string FileType, string ContentType, long? ExpectedSizeBytes = null) - : ICommand>; + : ICommand>; - public class RequestPlayListPictureUploadCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) + : ICommandHandler> { - public async ValueTask> Handle(RequestPlayListPictureUploadCommand 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 = playListConfiguration.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.PlayListPicture, - 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} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); - - return new PlayListPictureUploadResponse( - intent.Id, - storageConfiguration.Bucket, - tempKey, - objectName, - request.ContentType, - uploadIntentConfiguration.UploadUrlExpiresInSeconds, - uploadUrl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId); - return Error.Failure(description: "Failed to generate upload URL"); - } - } + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 1402ab7..10e4021 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,8 +1,7 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; + namespace Musify.Application.PlayLists.Responses { public record PlayListApplicationResponse( @@ -12,21 +11,21 @@ namespace Musify.Application.PlayLists.Responses string? SmallImageKeyName, string? MediumImageKeyName, string? LargeImageKeyName, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, - PlaylistVisibility Visibility, - int TrackCount, - double DurationSeconds, + PlayListVisibility Visibility, DateTime CreatedAt, DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) + IReadOnlyList CoverTrackIds, + long OwnerUserId, + int TrackCount, + double DurationSeconds) { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = PlayListProjections.CoverTrackCount; public static PlayListApplicationResponse FromEntity( PlayList playList, + IReadOnlyList? coverTrackIds = null, int trackCount = 0, - double durationSeconds = 0, - IReadOnlyList? coverTrackIds = null) + double durationSeconds = 0) { return new PlayListApplicationResponse( playList.Id, @@ -35,13 +34,13 @@ namespace Musify.Application.PlayLists.Responses playList.Pictures?.SmallName, playList.Pictures?.MediumName, playList.Pictures?.LargeName, - playList.UserId, playList.Visibility, - trackCount, - durationSeconds, playList.CreatedAt, playList.UpdatedAt, - coverTrackIds ?? []); + coverTrackIds ?? [], + playList.OwnerUserId, + trackCount, + durationSeconds); } } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs deleted file mode 100644 index 7928680..0000000 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Musify.Application.PlayLists.Responses -{ - public record PlayListPictureUploadResponse( - Guid IntentId, - string Bucket, - string Key, - string PictureName, - string ContentType, - int ExpiresInSeconds, - string UploadUrl); -} diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs new file mode 100644 index 0000000..5f99617 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs @@ -0,0 +1,38 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists.Responses +{ + public static class PlayListProjections + { + public const int CoverTrackCount = 4; + + public static IQueryable Active(this IQueryable query) => + query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => + query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(playList => new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures!.SmallName, + playList.Pictures!.MediumName, + playList.Pictures!.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + playList.PlayListTracks + .OrderBy(playListTrack => playListTrack.Position) + .Take(CoverTrackCount) + .Select(playListTrack => playListTrack.TrackId) + .ToList(), + playList.OwnerUserId, + playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), + playList.PlayListTracks + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Sum(playListTrack => playListTrack.Track.DurationSeconds))); + } +} diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 600f629..6ae2c03 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -3,13 +3,13 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; +using Musify.Application.Shared; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.PlayLists { @@ -19,7 +19,7 @@ namespace Musify.Application.PlayLists string? NewName, string? NewDescription, Guid? NewPictureIntentId, - PlaylistVisibility? NewVisibility = null) + PlayListVisibility? NewVisibility = null) : ICommand>; public class UpdatePlayListCommandHandler( @@ -27,144 +27,55 @@ namespace Musify.Application.PlayLists IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + PlayListConfiguration playListConfiguration) : ICommandHandler> { public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { - var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playListEntity == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - if (playListEntity.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + var playList = found.Value; if (!string.IsNullOrWhiteSpace(request.NewName)) { - playListEntity.Name = request.NewName; - playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); + playList.Name = request.NewName.Trim(); + playList.NormalizedName = TextNormalizer.Normalize(request.NewName); } - if (!string.IsNullOrWhiteSpace(request.NewDescription)) + // null means "unchanged"; a blank description clears it. + if (request.NewDescription != null) + playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); + + if (request.NewVisibility is { } visibility) + playList.Visibility = visibility; + + if (request.NewPictureIntentId is { } intentId) { - playListEntity.Description = request.NewDescription; - } + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - if (request.NewVisibility.HasValue) - { - playListEntity.Visibility = request.NewVisibility.Value; - } + var picture = prepared.Value; + playList.Pictures = picture.Pictures; - 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; - playListEntity.Pictures = new PlayListPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; - finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - } - - if (pictureIntent != null && finalPictureKey != null) - { - var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( - playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken); - if (publishResult.IsError) - return publishResult.Errors; - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id); - return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}"); - } - - var coverTrackIds = await database.PlayListHasTracks - .AsNoTracking() - .Where(plt => plt.PlayListId == playListEntity.Id) - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToListAsync(cancellationToken); - - logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); - var totals = await database.PlayListHasTracks - .AsNoTracking() - .Where(plt => plt.PlayListId == playListEntity.Id) - .GroupBy(plt => plt.PlayListId) - .Select(group => new - { - TrackCount = group.Count(), - DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds) - }) - .SingleOrDefaultAsync(cancellationToken); - - return PlayListApplicationResponse.FromEntity( - playListEntity, - totals?.TrackCount ?? 0, - totals?.DurationSeconds ?? 0, - coverTrackIds); - } - - private async Task> PublishUpdatePlayListPictureSourceEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) - { - try - { await eventBus.PublishAsync( new UpdatePlayListPictureSourceEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - new ImageSize( - playListConfiguration.Routes.SmallPicturesPath, - playListConfiguration.PicturesSizes.SmallPictureWidth, - playListConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - playListConfiguration.Routes.MediumPicturesPath, - playListConfiguration.PicturesSizes.MediumPictureWidth, - playListConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - playListConfiguration.Routes.LargePicturesPath, - playListConfiguration.PicturesSizes.LargePictureWidth, - playListConfiguration.PicturesSizes.LargePictureHeight)), + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return new Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId); - return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}"); } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated playlist {PlayListId}", playList.Id); + + return await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == playList.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs b/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs deleted file mode 100644 index a0d7642..0000000 --- a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs +++ /dev/null @@ -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 -{ - 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 -{ - 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(); - } -} diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index 490cfb0..fb50164 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -1,56 +1,36 @@ 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) +namespace Musify.Application.Services { - public async Task IssueAsync( - Guid trackId, - string audioFolderName, - long? userId, - CancellationToken cancellationToken) + /// Builds the stream URL and signed ticket for a processed track. It never touches the database. + public sealed class TrackStreamIssuer( + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration, + PlaybackConfiguration playbackConfiguration) { - var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); - var keyPrefix = $"{folderPath}/"; - - var maxBytes = AnonymousFragmentBytes(userId); - var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes); - - var manifestUrl = string.Join('/', - streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), - "media", - folderPath, - streamGatewayConfiguration.AudioFileName); - - Guid? listenId = null; - if (userId != null) + public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) { - var newListeningHistory = new ListeningHistory - { - UserId = userId.Value, - TrackId = trackId, - }; - await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - listenId = newListeningHistory.Id; + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); + var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); + + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.AudioFileName); + + return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); } - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); - } + private long? AnonymousFragmentBytes(long? userId) + { + if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) + return null; - private long? AnonymousFragmentBytes(long? userId) - { - if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) - return null; - - return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + } } } diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs new file mode 100644 index 0000000..1e36869 --- /dev/null +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -0,0 +1,134 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Pictures.Responses; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Services +{ + public sealed record UploadRequest( + UploadIntentPurpose Purpose, + PictureRoutes Routes, + string FileType, + string ContentType, + long? ExpectedSizeBytes); + + public sealed record IssuedUpload( + Guid IntentId, + string Key, + string ObjectName, + string ContentType, + string UploadUrl); + + /// Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first. + public sealed class UploadIntentIssuer( + IDatabase database, + IStorageService storageService, + UploadIntentValidator uploadIntentValidator, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + { + public async Task>> IssueAsync( + long userId, + IReadOnlyList requests, + CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == userId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", userId); + + var planned = requests + .Select(request => + { + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); + var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); + return (Request: request, ObjectName: objectName, Key: key, Size: size); + }) + .ToList(); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quota = await uploadIntentValidator.CheckQuotaAsync( + userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); + if (quota.IsError) + return quota.Errors; + + var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + var expiresAt = DateTime.UtcNow + expiresIn; + var issued = new List(planned.Count); + + foreach (var item in planned) + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); + + var intent = new UploadIntent + { + UserId = userId, + Bucket = storageConfiguration.Bucket, + Key = item.Key, + ObjectName = item.ObjectName, + ContentType = item.Request.ContentType, + ExpectedSizeBytes = item.Size, + Purpose = item.Request.Purpose, + ExpiresAt = expiresAt, + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); + } + + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", + issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); + + return issued; + } + + public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) + { + var issued = await IssueAsync(userId, [request], cancellationToken); + return issued.IsError ? issued.Errors : issued.Value[0]; + } + + public async Task> IssuePictureAsync( + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + string fileType, + string contentType, + long? expectedSizeBytes, + CancellationToken cancellationToken) + { + var issued = await IssueOneAsync( + userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); + if (issued.IsError) + return issued.Errors; + + return new PictureUploadResponse( + issued.Value.IntentId, + storageConfiguration.Bucket, + issued.Value.Key, + issued.Value.ObjectName, + issued.Value.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + issued.Value.UploadUrl); + } + + private long DefaultSize(UploadIntentPurpose purpose) => + purpose == UploadIntentPurpose.TrackAudio + ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes + : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + } +} diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 7b9bbb4..3855124 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -1,63 +1,78 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.Services; - -public sealed class UploadIntentValidator( - IDatabase database, - IStorageService storageService) +namespace Musify.Application.Services { - public async Task> CheckQuotaAsync( - UploadIntentConfiguration config, - long userId, - long requiredBytes, - int requiredIntentCount, - CancellationToken cancellationToken) + public sealed class UploadIntentValidator( + IDatabase database, + IStorageService storageService, + UploadIntentConfiguration config) { - var activeIntents = await database.UploadIntents - .AsNoTracking() - .Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued) - .ToListAsync(cancellationToken); + public async Task> CheckQuotaAsync( + long userId, + long requiredBytes, + int requiredIntentCount, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var pictureDefault = config.DefaultExpectedPictureSizeBytes; + var audioDefault = config.DefaultExpectedAudioSizeBytes; - if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) - return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); + var active = await database.UploadIntents + .AsNoTracking() + .Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now) + .GroupBy(_ => 1) + .Select(group => new + { + Count = group.Count(), + Bytes = group.Sum(intent => intent.ExpectedSizeBytes + ?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault)) + }) + .SingleOrDefaultAsync(cancellationToken); - var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes); - if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser) - return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); + if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) + return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); - return new Success(); - } + if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) + return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); - public async Task> ValidateAndLoadAsync( - UploadIntentConfiguration config, - Guid intentId, - long userId, - CancellationToken cancellationToken) - { - var intent = await database.UploadIntents - .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); + return Result.Success; + } - if (intent == null || intent.UserId != userId) - return Error.NotFound(description: "Upload intent not found or not accessible."); + public async Task> ValidateAndLoadAsync( + Guid intentId, + long userId, + UploadIntentPurpose purpose, + CancellationToken cancellationToken) + { + var intent = await database.UploadIntents + .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); - if (intent.IsConsumed) - return Error.Conflict(description: "Upload intent has already been consumed."); + if (intent == null || intent.UserId != userId) + return AppErrors.NotFound("UploadIntent", intentId); - if (intent.IsExpired) - return Error.Validation(description: "Upload intent has expired."); + if (intent.Purpose != purpose) + return Error.Validation(description: "Upload intent was issued for a different purpose."); - var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); - if (metadata == null) - return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first."); + if (intent.IsConsumed) + return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); - if (metadata.ContentLength > config.MaxUploadBytes) - return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); + if (intent.IsExpiredAt(DateTime.UtcNow)) + return Error.Validation(description: "Upload intent has expired."); - return intent; + var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); + if (metadata == null) + return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); + + if (metadata.ContentLength > config.MaxUploadBytes) + return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); + + return intent; + } } } diff --git a/backend/Core/Musify.Application/Shared/AppErrors.cs b/backend/Core/Musify.Application/Shared/AppErrors.cs new file mode 100644 index 0000000..2bec4a7 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/AppErrors.cs @@ -0,0 +1,16 @@ +using ErrorOr; + +namespace Musify.Application.Shared +{ + public static class AppErrors + { + public static Error NotFound(string entity, object id) => + Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); + + public static Error Forbidden(string entity, object id) => + Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); + + public static Error Conflict(string code, string description) => + Error.Conflict(code, description); + } +} diff --git a/backend/Core/Musify.Application/Shared/ImageSize.cs b/backend/Core/Musify.Application/Shared/ImageSize.cs index c6f27d2..b6a699e 100644 --- a/backend/Core/Musify.Application/Shared/ImageSize.cs +++ b/backend/Core/Musify.Application/Shared/ImageSize.cs @@ -1,7 +1,12 @@ -namespace Musify.Application.Shared +namespace Musify.Application.Shared { public record ImageSize( string SavePath, int Width, int Height); -} \ No newline at end of file + + public record ImageSizes( + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs new file mode 100644 index 0000000..af06273 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Abstractions; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Shared +{ + public static class OwnershipExtensions + { + /// + /// Loads an active, tracked entity owned by : NotFound when it is missing + /// or not active, Forbidden when someone else owns it. + /// + public static async Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity, IHasLifeCycle + { + var entity = await set.SingleOrDefaultAsync( + e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + + if (entity == null) + return AppErrors.NotFound(typeof(T).Name, id); + + return entity.OwnerUserId == userId + ? entity + : AppErrors.Forbidden(typeof(T).Name, id); + } + } +} diff --git a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs b/backend/Core/Musify.Application/Shared/PaginatedResponse.cs deleted file mode 100644 index 8bb7c1f..0000000 --- a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs +++ /dev/null @@ -1,26 +0,0 @@ -using X.PagedList; - -namespace Musify.Application.Shared -{ - public record PaginatedResponse( - IReadOnlyCollection Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasNextPage, - bool HasPreviousPage) - { - public static PaginatedResponse FromPagedList(IPagedList pagedList) - { - return new PaginatedResponse( - pagedList.ToArray(), - pagedList.PageNumber, - pagedList.PageSize, - pagedList.PageCount, - pagedList.TotalItemCount, - pagedList.HasNextPage, - pagedList.HasPreviousPage); - } - } -} diff --git a/backend/Core/Musify.Application/Shared/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs new file mode 100644 index 0000000..a58addf --- /dev/null +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; + +namespace Musify.Application.Shared +{ + public record PageRequest(int PageNumber = 1, int PageSize = 20) + { + public const int MaxPageSize = 100; + } + + public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); + + public static class PaginationExtensions + { + /// Counts and pages the same (already filtered, ordered and projected) query. + public static async Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken) + { + var pageNumber = Math.Max(page.PageNumber, 1); + var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); + + var totalCount = await query.CountAsync(cancellationToken); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PaginatedResponse( + items, + pageNumber, + pageSize, + pageCount, + totalCount, + HasNextPage: pageNumber < pageCount, + HasPreviousPage: pageNumber > 1 && pageCount > 0); + } + } +} diff --git a/backend/Core/Musify.Application/Shared/TextNormalizer.cs b/backend/Core/Musify.Application/Shared/TextNormalizer.cs new file mode 100644 index 0000000..3611c12 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/TextNormalizer.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Shared +{ + public static class TextNormalizer + { + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); + } +} diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 4e37204..ad734de 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -3,13 +3,13 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.Events; -using Musify.Application.Tracks.Responses; using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.Tracks { @@ -27,9 +27,7 @@ namespace Musify.Application.Tracks IEventBus eventBus, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + TrackConfiguration trackConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) @@ -43,31 +41,17 @@ namespace Musify.Application.Tracks } var distinctTags = request.Tags.Distinct().ToList(); - if (distinctTags.Count == 0) - { - logger.LogWarning("Track {Title} was submitted without any tags", request.Title); - return Error.Validation(description: "At least one tag is required."); - } - var tagConflicts = GenreCompatibility.FindConflicts(distinctTags); - if (tagConflicts.Count > 0) - { - var conflict = tagConflicts.First(); - logger.LogWarning( - "Track {Title} was submitted with incompatible tags {First} and {Second}", - request.Title, conflict.First, conflict.Second); - return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); - } + if (request.PictureIntentId == request.AudioIntentId) + return Error.Validation(description: "Picture and audio must use different upload intents."); var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.PictureIntentId, request.UserId, cancellationToken); + request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); if (pictureValidation.IsError) return pictureValidation.Errors; var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.AudioIntentId, request.UserId, cancellationToken); + request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); if (audioValidation.IsError) return audioValidation.Errors; @@ -80,8 +64,8 @@ namespace Musify.Application.Tracks var trackEntity = new Track { - Title = request.Title, - NormalizedTitle = request.Title.ToUpperInvariant(), + Title = request.Title.Trim(), + NormalizedTitle = TextNormalizer.Normalize(request.Title), OwnerUserId = user.Id, Owner = user, IsExplicit = request.IsExplicit, @@ -106,54 +90,23 @@ namespace Musify.Application.Tracks await database.Tracks.AddAsync(trackEntity, cancellationToken); - await database.UserHasTracks.AddAsync(new UserHasTrack - { - UserId = request.UserId, - TrackId = trackEntity.Id - }, cancellationToken); - try - { - await eventBus.PublishAsync( - new CreateTrackResourcesEvent( - trackEntity.Id, - pictureIntent.Id, - audioIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - audioIntent.Key, - finalAudioKey, - audioProcessedFolderKey, - new ImageSize( - trackConfiguration.Routes.SmallPicturesPath, - trackConfiguration.PicturesSizes.SmallPictureWidth, - trackConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - trackConfiguration.Routes.MediumPicturesPath, - trackConfiguration.PicturesSizes.MediumPictureWidth, - trackConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - trackConfiguration.Routes.LargePicturesPath, - trackConfiguration.PicturesSizes.LargePictureWidth, - trackConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id); - return Error.Failure(description: $"Failed to create track for {request.Title}"); - } + var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); + await eventBus.PublishAsync( + new CreateTrackResourcesEvent( + trackEntity.Id, + pictureIntent.Id, + audioIntent.Id, + pictureIntent.Bucket, + pictureIntent.Key, + finalPictureKey, + audioIntent.Key, + finalAudioKey, + audioProcessedFolderKey, + sizes), + cancellationToken); - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to save track {Title}", request.Title); - return Error.Failure(description: $"Failed to create track for {request.Title}"); - } + await database.SaveChangesAsync(cancellationToken); return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); } diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index 9756b3b..b7c772a 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -1,11 +1,10 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; -using Musify.Application.Events; using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { @@ -22,50 +21,22 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track == null) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(); - } - - if (track.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); - } + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var track = found.Value; if (track.Audio.IsInProgress) - { - logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); - return Error.Conflict(description: "Track is currently being processed and cannot be deleted"); - } + return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted."); track.LifeCycleStatus = LifeCycleStatus.Removing; - try - { - await eventBus.PublishAsync( - new DeleteTrackEvent(track.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); - return Error.Failure(description: $"Failed to delete track {request.TrackId}"); - } + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId); - return Error.Failure(description: $"Failed to delete track {request.TrackId}"); - } + logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 8cee026..55e34af 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -1,30 +1,27 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; + public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler { - public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) { - var track = await database.ListeningHistories + return await database.ListeningHistories .AsNoTracking() .Include(t => t.Track.Owner) .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId) + .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) .OrderByDescending(t => t.ListenedAt) + .ThenBy(t => t.Id) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) .FirstOrDefaultAsync(cancellationToken); - - return track == null - ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") - : track; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 3da51ea..48a797a 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -1,40 +1,40 @@ -using Mediator; +using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; namespace Musify.Application.Tracks { - public record GetListeningHistoryQuery(long UserId) : IQuery>; + public record GetListeningHistoryQuery(long UserId) : IQuery>; - public class GetListeningHistoryQueryHandler( - IDatabase database) - : IQueryHandler> + public class GetListeningHistoryQueryHandler(IDatabase database) + : IQueryHandler> { public const int ListSize = 15; - public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) { var recentTrackIds = await database.ListeningHistories .AsNoTracking() .Where(l => l.UserId == query.UserId && l.IsCounted) .GroupBy(l => l.TrackId) .OrderByDescending(g => g.Max(l => l.ListenedAt)) + .ThenBy(g => g.Key) .Select(g => g.Key) .Take(ListSize) .ToListAsync(cancellationToken); var tracksById = await database.Tracks .AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) + .Active() .Where(t => recentTrackIds.Contains(t.Id)) - .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + .SelectResponse() .ToDictionaryAsync(t => t.Id, cancellationToken); return recentTrackIds + .Where(id => tracksById.ContainsKey(id)) .Select(id => tracksById[id]) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); + .ToList(); } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 68eaf5f..c6a5d0a 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; namespace Musify.Application.Tracks @@ -14,17 +15,16 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { - var entity = await database.Tracks.AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) + var track = await database.Tracks + .AsNoTracking() + .Active() .Where(t => t.Id == request.TrackId) - .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (entity == null) - return Error.NotFound(); - - return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); + return track == null + ? AppErrors.NotFound("Track", request.TrackId) + : track; } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs deleted file mode 100644 index 80b00ec..0000000 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ /dev/null @@ -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>; - - public record TrackCoverLocation(string Bucket, string Key, string ContentType); - - public class GetTrackCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration) - : IQueryHandler> - { - public async ValueTask> 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 builder, string? name) - => string.IsNullOrEmpty(name) ? null : builder(name); - } -} \ No newline at end of file diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs deleted file mode 100644 index 1135863..0000000 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ /dev/null @@ -1,49 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tracks.Responses; - -namespace Musify.Application.Tracks -{ - - public record GetTrackStreamQuery(Guid TrackId, long? UserId) - : IQuery>; - - public class GetTrackStreamQueryHandler( - IDatabase database, - TrackStreamIssuer streamIssuer, - PlaybackConfiguration playbackConfiguration, - ILogger logger) - : IQueryHandler> - { - public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) - { - if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) - return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); - - var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId) - .Select(t => new { t.Id, t.Audio }) - .SingleOrDefaultAsync(cancellationToken); - - if (track == null) - return Error.NotFound(); - - if (!track.Audio.IsProcessed) - { - logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); - return Error.Conflict(description: "Track audio is not available for streaming yet."); - } - - var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); - - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); - - return response; - } - } -} diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 8174418..e8ed4e9 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,57 +1,38 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) - : IQuery>; + : IQuery>; public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler> { - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { - var tracksQuery = database.UserHasTracks + var tracksQuery = database.Tracks .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .AsQueryable(); + .Active(); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } if (request.Genre is { } genre) - tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); + tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre)); - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderByDescending(t => t.Track.CreatedAt) - .ThenBy(t => t.Id) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var items = pagedEntities - .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) - .ToList(); - - return new TracksSearchResponse( - items, - pagedEntities.PageNumber, - pagedEntities.PageSize, - pagedEntities.PageCount, - pagedEntities.TotalItemCount, - pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage); + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 20a7cd6..43b799d 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -1,11 +1,9 @@ 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; -using Musify.Application.Contracts; namespace Musify.Application.Tracks { @@ -21,33 +19,22 @@ namespace Musify.Application.Tracks { public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; - var pageSize = request.PageSize < 1 ? 10 : request.PageSize; - - var tracksQuery = database.UserHasTracks + var tracksQuery = database.Tracks .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .Where(p => p.UserId == request.UserId); + .Active() + .Where(track => track.OwnerUserId == request.UserId); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderBy(t => t.Track.CreatedAt) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index c23a410..b00696e 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Tracks { @@ -16,15 +17,12 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) { - if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0) - return Error.Validation(description: "playedSeconds must be a non-negative number."); - var listen = await database.ListeningHistories .Include(l => l.Track) .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); if (listen == null) - return Error.NotFound(); + return AppErrors.NotFound("Listen", request.ListenId); var now = DateTime.UtcNow; var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 09ae79b..98fcad9 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -1,13 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Services; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.Tracks { @@ -22,10 +18,7 @@ namespace Musify.Application.Tracks : ICommand>; public class RequestTrackUploadUrlsCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, + UploadIntentIssuer issuer, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration, UploadIntentConfiguration uploadIntentConfiguration) @@ -33,111 +26,33 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(RequestTrackUploadUrlsCommand 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 routes = trackConfiguration.Routes; + var issued = await issuer.IssueAsync( + request.UserId, + [ + new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), + new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) + ], + cancellationToken); + if (issued.IsError) + return issued.Errors; - var effectivePictureSize = request.ExpectedPictureSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; - var effectiveAudioSize = request.ExpectedAudioSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes; + var picture = issued.Value[0]; + var audio = issued.Value[1]; - var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; - var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; - - var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName); - var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName); - - try - { - var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); - - var pictureUploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, - tempPictureKey, - request.PictureContentType, - expiresIn, - cancellationToken); - - var audioUploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, - tempAudioKey, - request.AudioContentType, - expiresIn, - cancellationToken); - - var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); - - await using var transaction = await database.BeginTransactionAsync( - System.Data.IsolationLevel.Serializable, cancellationToken); - - var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( - uploadIntentConfiguration, - request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken); - if (quotaCheck.IsError) - { - logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); - return quotaCheck.Errors; - } - - var pictureIntent = new UploadIntent - { - UserId = request.UserId, - Bucket = storageConfiguration.Bucket, - Key = tempPictureKey, - ObjectName = pictureObjectName, - ContentType = request.PictureContentType, - ExpectedSizeBytes = effectivePictureSize, - Purpose = UploadIntentPurpose.TrackPicture, - ExpiresAt = expiresAt, - }; - - var audioIntent = new UploadIntent - { - UserId = request.UserId, - Bucket = storageConfiguration.Bucket, - Key = tempAudioKey, - ObjectName = audioObjectName, - ContentType = request.AudioContentType, - ExpectedSizeBytes = effectiveAudioSize, - Purpose = UploadIntentPurpose.TrackAudio, - ExpiresAt = expiresAt, - }; - - await database.UploadIntents.AddAsync(pictureIntent, cancellationToken); - await database.UploadIntents.AddAsync(audioIntent, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - - logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)", - pictureIntent.Id, audioIntent.Id, request.UserId); - - return new TrackUploadUrlsResponse( - pictureIntent.Id, - audioIntent.Id, - storageConfiguration.Bucket, - tempPictureKey, - pictureObjectName, - request.PictureContentType, - pictureUploadUrl, - tempAudioKey, - audioObjectName, - request.AudioContentType, - audioUploadUrl, - uploadIntentConfiguration.UploadUrlExpiresInSeconds); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId); - return Error.Failure(description: "Failed to generate upload URLs"); - } + return new TrackUploadUrlsResponse( + picture.IntentId, + audio.IntentId, + storageConfiguration.Bucket, + picture.Key, + picture.ObjectName, + picture.ContentType, + picture.UploadUrl, + audio.Key, + audio.ObjectName, + audio.ContentType, + audio.UploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds); } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 2748ded..95dd13a 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,7 +1,5 @@ -using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using System.Text.Json.Serialization; namespace Musify.Application.Tracks.Responses { @@ -14,7 +12,7 @@ namespace Musify.Application.Tracks.Responses int ListensCount, DateTime CreatedAt, DateTime UpdatedAt, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, IReadOnlyCollection Tags, bool IsExplicit) { diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs new file mode 100644 index 0000000..df228d3 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -0,0 +1,25 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + public static class TrackProjections + { + public static IQueryable Active(this IQueryable query) => + query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(track => new TrackApplicationResponse( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + track.ListeningHistories.Count(listen => listen.IsCounted), + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit)); + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs deleted file mode 100644 index 1477ec4..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Application.Tracks.Responses -{ - public record TrackSearchItemResponse(TrackApplicationResponse Track) - { - public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); - } -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs deleted file mode 100644 index 4fddfb6..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Musify.Application.Tracks.Responses -{ - - public record TracksSearchResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasPreviousPage, - bool HasNextPage); -} diff --git a/backend/Core/Musify.Application/Tracks/StartListening.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs new file mode 100644 index 0000000..73a51c7 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks +{ + /// Starts a listen: records it for signed-in users and issues the stream ticket. + public record StartListeningCommand(Guid TrackId, long? UserId) + : ICommand>; + + public class StartListeningCommandHandler( + IDatabase database, + TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(StartListeningCommand request, CancellationToken cancellationToken) + { + if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) + return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); + + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new { t.Id, t.Audio }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return AppErrors.NotFound("Track", request.TrackId); + + if (!track.Audio.IsProcessed) + return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); + + Guid? listenId = null; + if (request.UserId is { } userId) + { + var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; + await database.ListeningHistories.AddAsync(listen, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + listenId = listen.Id; + } + + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)"); + + return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); + } + } +} diff --git a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs new file mode 100644 index 0000000..a27baa6 --- /dev/null +++ b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs @@ -0,0 +1,39 @@ +using Mediator; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.UploadIntents +{ + /// Deletes temporary upload objects that nobody claimed within the retention window. + public record DeleteStaleTempUploadsCommand : ICommand; + + public class DeleteStaleTempUploadsCommandHandler( + IStorageService storageService, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) + { + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); + var deleted = 0; + + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + + if (deleted > 0) + logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); + + return deleted; + } + } +} diff --git a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs new file mode 100644 index 0000000..1759bfd --- /dev/null +++ b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs @@ -0,0 +1,47 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.UploadIntents +{ + /// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. + public record ExpireUploadIntentsCommand : ICommand; + + public class ExpireUploadIntentsCommandHandler( + IDatabase database, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + + var expiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) + .ToListAsync(cancellationToken); + + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; + + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + var oldExpiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); + + database.UploadIntents.RemoveRange(oldExpiredIntents); + + if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) + return 0; + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", + expiredIntents.Count, oldExpiredIntents.Count); + + return expiredIntents.Count + oldExpiredIntents.Count; + } + } +} diff --git a/backend/Core/Musify.Application/Users/CreateUser.cs b/backend/Core/Musify.Application/Users/CreateUser.cs deleted file mode 100644 index ad19221..0000000 --- a/backend/Core/Musify.Application/Users/CreateUser.cs +++ /dev/null @@ -1,59 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Users.Responses; -using Musify.Domain.Entities; - -namespace Musify.Application.Users -{ - public record CreateUserCommand(long Id, - string Name, - string? FirstName, - string? SecondName) - : ICommand> - { - public static User ToEntity(CreateUserCommand command) - { - return new User - { - Id = command.Id, - Name = command.Name, - NormalizedName = command.Name.ToUpperInvariant(), - FirstName = command.FirstName, - SecondName = command.SecondName - }; - } - } - - public class CreateUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) - { - var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken); - if (userExists) - { - logger.LogInformation("User {UserId} already exists", request.Id); - return Error.Conflict(description: $"User {request.Id} already exists"); - } - - var newUser = CreateUserCommand.ToEntity(request); - - try - { - await database.Users.AddAsync(newUser, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to create user {UserId}", request.Id); - return Error.Failure(description: $"Failed to create user {request.Id}"); - } - - logger.LogInformation("Created user {UserId}", newUser.Id); - return UserApplicationResponse.FromEntity(newUser); - } - } -} diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index c4d6541..029b2ee 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Users @@ -23,16 +24,13 @@ namespace Musify.Application.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"); - } + return AppErrors.NotFound("User", request.FollowedId); var alreadyFollowing = await database.UserFollows .AsNoTracking() .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); if (alreadyFollowing) - return new Success(); + return Result.Success; await database.UserFollows.AddAsync(new UserFollow { @@ -40,11 +38,15 @@ namespace Musify.Application.Users FollowedId = request.FollowedId }, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + // A concurrent double click can hit the unique index; already following is a successful no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); + if (saved.IsError) + return Result.Success; logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index d097b85..981c474 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -1,13 +1,12 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Users.Responses; namespace Musify.Application.Users { public record GetListeningStatsQuery(long UserId) : IQuery; - public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); - public class GetListeningStatsQueryHandler(IDatabase database) : IQueryHandler { @@ -39,6 +38,9 @@ namespace Musify.Application.Users var listenedDateSet = listenedDates.ToHashSet(); var streakDays = 0; var day = DateTime.UtcNow.Date; + if (!listenedDateSet.Contains(day)) + day = day.AddDays(-1); + while (listenedDateSet.Contains(day)) { streakDays++; diff --git a/backend/Core/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs index 92d651b..dc009bc 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Users.Responses; namespace Musify.Application.Users @@ -20,10 +21,9 @@ namespace Musify.Application.Users .Select(u => UserApplicationResponse.FromEntity(u)) .SingleOrDefaultAsync(cancellationToken); - if (user == null) - return Error.NotFound(); - - return user; + return user == null + ? AppErrors.NotFound("User", request.Id) + : user; } } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs index e5b0d9e..9759352 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowers.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users { @@ -17,20 +16,18 @@ namespace Musify.Application.Users public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) { if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) - return Error.Forbidden(); + return AppErrors.Forbidden("User", request.UserId); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowedId == request.UserId); - - var totalCount = await followsQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var page = await followsQuery + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId) .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) .Select(f => new UserSummaryResponse( f.Follower.Id, f.Follower.Name, @@ -38,9 +35,7 @@ namespace Musify.Application.Users f.Follower.SecondName, f.Follower.ProfilePictureUrl, viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(page); + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs index baa9221..ae2cb84 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowing.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users { @@ -17,17 +16,15 @@ namespace Musify.Application.Users public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) { if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowerId == request.UserId); - - var totalCount = await followsQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var page = await followsQuery + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId) .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) .Select(f => new UserSummaryResponse( f.Followed.Id, f.Followed.Name, @@ -35,9 +32,7 @@ namespace Musify.Application.Users f.Followed.SecondName, f.Followed.ProfilePictureUrl, viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(page); + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index 7066d90..51ad5dd 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -1,26 +1,14 @@ -using System.Text.Json.Serialization; using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -using Musify.Application.Serialization; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; namespace Musify.Application.Users { public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; - public record UserProfileResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - int FollowersCount, - int FollowingCount, - bool IsFollowing, - bool CanViewFollowers); - public class GetUserProfileQueryHandler(IDatabase database) : IQueryHandler> { @@ -41,7 +29,7 @@ namespace Musify.Application.Users .SingleOrDefaultAsync(cancellationToken); if (user == null) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); var followersCount = await database.UserFollows .AsNoTracking() diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index 7023c84..d839c73 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -1,45 +1,41 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; -using Musify.Application.Contracts; namespace Musify.Application.Users { public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) - : IQuery>>; + : IQuery>; public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var usersQuery = database.Users - .AsNoTracking() - .AsQueryable(); + .AsNoTracking(); - if (!string.IsNullOrEmpty(request.UsernameSearch)) + if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) { - var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); - usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername)); + var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); + usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername)); } - var totalCount = await usersQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var pagedUsers = await usersQuery - .OrderBy(u => u.Name) - .Select(u => new UserSummaryResponse( - u.Id, - u.Name, - u.FirstName, - u.SecondName, - u.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(pagedUsers); + return await usersQuery + .OrderBy(user => user.Name) + .ThenBy(user => user.Id) + .Select(user => new UserSummaryResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } } diff --git a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs new file mode 100644 index 0000000..b632205 --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Users.Responses +{ + public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index 9bd442f..a96a88a 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,11 +1,9 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; namespace Musify.Application.Users.Responses { public record UserApplicationResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, @@ -25,4 +23,4 @@ namespace Musify.Application.Users.Responses user.UpdatedAt); } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs new file mode 100644 index 0000000..de3252b --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -0,0 +1,15 @@ + +namespace Musify.Application.Users.Responses +{ + public record UserProfileResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs index 6d0af7f..cf08570 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -1,10 +1,8 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.Users.Responses { public record UserSummaryResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Application/Users/SyncUser.cs b/backend/Core/Musify.Application/Users/SyncUser.cs index 58056ba..4b15cb9 100644 --- a/backend/Core/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Users @@ -29,7 +30,7 @@ namespace Musify.Application.Users { Id = request.Id, Name = request.Name, - NormalizedName = request.Name.ToUpperInvariant(), + NormalizedName = TextNormalizer.Normalize(request.Name), FirstName = request.FirstName, SecondName = request.SecondName, ProfilePictureUrl = request.ProfilePictureUrl @@ -38,11 +39,11 @@ namespace Musify.Application.Users await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Provisioned user {UserId}", request.Id); - return new Success(); + return Result.Success; } user.Name = request.Name; - user.NormalizedName = request.Name.ToUpperInvariant(); + user.NormalizedName = TextNormalizer.Normalize(request.Name); user.FirstName = request.FirstName; user.SecondName = request.SecondName; user.ProfilePictureUrl = request.ProfilePictureUrl; @@ -51,7 +52,7 @@ namespace Musify.Application.Users if (written > 0) logger.LogInformation("Synced profile for user {UserId}", request.Id); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs index 1009d94..5d4ea87 100644 --- a/backend/Core/Musify.Application/Users/UnfollowUser.cs +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -19,14 +19,14 @@ namespace Musify.Application.Users .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); if (follow == null) - return new Success(); + return Result.Success; database.UserFollows.Remove(follow); await database.SaveChangesAsync(cancellationToken); logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - return new Success(); + return Result.Success; } } } diff --git a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs index 4815e2c..bbba395 100644 --- a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs +++ b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs @@ -2,8 +2,8 @@ namespace Musify.Domain.Abstractions { public interface IAuditable { - DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } } } diff --git a/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs new file mode 100644 index 0000000..0bcd88d --- /dev/null +++ b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs @@ -0,0 +1,11 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Abstractions +{ + public interface IHasLifeCycle + { + public Guid Id { get; } + + public LifeCycleStatus LifeCycleStatus { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs new file mode 100644 index 0000000..944c211 --- /dev/null +++ b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs @@ -0,0 +1,7 @@ +namespace Musify.Domain.Abstractions +{ + public interface IOwnedEntity + { + public long OwnerUserId { get; } + } +} diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index bf3f866..8542293 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -5,17 +5,15 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { -#pragma warning disable CS8618 - public class Album : IAuditable + [Table("Albums")] + public class Album : IAuditable, IHasLifeCycle, IOwnedEntity { [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; } @@ -24,21 +22,17 @@ namespace Musify.Domain.Entities public int? ReleaseYear { get; set; } - public AlbumPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - [Required] public required long OwnerUserId { get; set; } [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } + public User Owner { get; set; } = null!; - [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 AlbumTracks { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs index ed0bcae..cb90a5d 100644 --- a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs @@ -1,30 +1,24 @@ -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; } + public Album Album { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } } diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index 0439cb4..48ecefe 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -1,22 +1,18 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities { -#pragma warning disable CS8618 [Table("ListeningHistory")] public class ListeningHistory { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required] public required Guid TrackId { get; set; } - [Required] public DateTime ListenedAt { get; set; } = DateTime.UtcNow; public double? PlayedSeconds { get; set; } @@ -25,10 +21,10 @@ namespace Musify.Domain.Entities public bool IsCounted { get; set; } - [ForeignKey(nameof(UserId) )] - public User User { get; set; } + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 3d115d4..18ec1d3 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -1,38 +1,28 @@ 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 [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; } + public MixKind Kind { get; set; } - [MaxLength(256)] - public string? Subtitle { get; set; } + public int Position { 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 User User { get; set; } = null!; public ICollection Items { get; set; } = new List(); } diff --git a/backend/Core/Musify.Domain/Entities/MixItem.cs b/backend/Core/Musify.Domain/Entities/MixItem.cs index 2269e2d..844da15 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -3,26 +3,22 @@ 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; + public int Position { get; set; } - [Required] public required Guid TrackId { get; set; } [ForeignKey(nameof(MixId))] - public Mix Mix { get; set; } + public Mix Mix { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } } diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 61ba628..5a31a9e 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -6,44 +6,37 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { -#pragma warning disable CS8618 [Table("PlayLists")] - public class PlayList : IAuditable + public class PlayList : IAuditable, IHasLifeCycle, IOwnedEntity { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Column("UserId")] + public required long OwnerUserId { get; set; } - [Required] [MaxLength(50)] public required string Name { get; set; } - [Required] [MaxLength(50)] public required string NormalizedName { get; set; } [MaxLength(256)] public string? Description { get; set; } - public PlayListPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] - public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private; + public PlayListVisibility Visibility { get; set; } = PlayListVisibility.Private; - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId)) ] - public User User { get; set; } + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; public ICollection PlayListTracks { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs index 3de229f..a8e2c33 100644 --- a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs @@ -1,30 +1,24 @@ -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Musify.Domain.Entities { -#pragma warning disable CS8618 [Table("PlayListHasTrack")] public class PlayListHasTrack { public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required Guid PlayListId { get; set; } - [Required] public required Guid TrackId { get; set; } - [Required] - public int Position { get; set; } = 0; + public int Position { get; set; } - [Required] public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(PlayListId)) ] - public PlayList PlayList { get; set; } + [ForeignKey(nameof(PlayListId))] + public PlayList PlayList { get; set; } = null!; - [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index e892b48..e2a7c85 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -5,47 +5,37 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { -#pragma warning disable CS8618 - public class Track : IAuditable + [Table("Tracks")] + public class Track : IAuditable, IHasLifeCycle, IOwnedEntity { [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; } + public User Owner { get; set; } = null!; - [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] public bool IsExplicit { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public TrackPictures Pictures { get; set; } = new(); public TrackAudio Audio { get; set; } = new(); - public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayListTracks { get; set; } = new List(); public ICollection AlbumTracks { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/TrackLike.cs b/backend/Core/Musify.Domain/Entities/TrackLike.cs index 41ebb9f..2e10ce4 100644 --- a/backend/Core/Musify.Domain/Entities/TrackLike.cs +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -3,26 +3,22 @@ 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; } + public User User { get; set; } = null!; [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } + public Track Track { get; set; } = null!; } } diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs index 16fece7..4bf2389 100644 --- a/backend/Core/Musify.Domain/Entities/TrackTag.cs +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -4,19 +4,17 @@ using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { -#pragma warning disable CS8618 + [Table("TrackTags")] 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; } + public Track Track { get; set; } = null!; } } diff --git a/backend/Core/Musify.Domain/Entities/Upload.cs b/backend/Core/Musify.Domain/Entities/Upload.cs deleted file mode 100644 index 1cb9cb7..0000000 --- a/backend/Core/Musify.Domain/Entities/Upload.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -using Musify.Domain.ValueObjects; - -namespace Musify.Domain.Entities -{ - [Table("Upload")] - public class Upload - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - public required string Bucket { get; set; } - - [Required] - public required string Key { get; set; } - - [Required] - public required string ContentType { get; set; } - - [Required] - public DateTime CreatedTime { get; set; } = DateTime.UtcNow; - - [Required] - public UploadState State { get; set; } = UploadState.Pending; - } -} \ No newline at end of file diff --git a/backend/Core/Musify.Domain/Entities/UploadIntent.cs b/backend/Core/Musify.Domain/Entities/UploadIntent.cs index 7150dd1..176f8f4 100644 --- a/backend/Core/Musify.Domain/Entities/UploadIntent.cs +++ b/backend/Core/Musify.Domain/Entities/UploadIntent.cs @@ -11,42 +11,36 @@ namespace Musify.Domain.Entities [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [Required] public required long UserId { get; set; } - [Required, MaxLength(128)] + [MaxLength(128)] public required string Bucket { get; set; } - [Required, MaxLength(512)] + [MaxLength(512)] public required string Key { get; set; } - [Required, MaxLength(128)] + [MaxLength(128)] public required string ObjectName { get; set; } - [Required, MaxLength(128)] + [MaxLength(128)] public required string ContentType { get; set; } public long? ExpectedSizeBytes { get; set; } - [Required] public UploadIntentPurpose Purpose { get; set; } - [Required] public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; - [Required] public DateTime ExpiresAt { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; [NotMapped] public bool IsConsumed => Status == UploadIntentStatus.Consumed; - [NotMapped] - public bool IsExpired => Status == UploadIntentStatus.Expired || ExpiresAt < DateTime.UtcNow; + public bool IsExpiredAt(DateTime now) => Status == UploadIntentStatus.Expired || ExpiresAt < now; - [ForeignKey(nameof(UserId)) ] + [ForeignKey(nameof(UserId))] public User User { get; set; } = null!; } } diff --git a/backend/Core/Musify.Domain/Entities/User.cs b/backend/Core/Musify.Domain/Entities/User.cs index 8030e65..078c99b 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; @@ -11,11 +11,9 @@ namespace Musify.Domain.Entities [DatabaseGenerated(DatabaseGeneratedOption.None)] public required long Id { get; set; } - [Required] [MaxLength(48)] public required string Name { get; set; } - [Required] [MaxLength(48)] public required string NormalizedName { get; set; } @@ -28,16 +26,12 @@ namespace Musify.Domain.Entities [MaxLength(2048)] public string? ProfilePictureUrl { get; set; } - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayLists { get; set; } = new List(); public ICollection ListeningHistories { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Domain/Entities/UserFollow.cs b/backend/Core/Musify.Domain/Entities/UserFollow.cs index 1cd3322..01a9b77 100644 --- a/backend/Core/Musify.Domain/Entities/UserFollow.cs +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -3,26 +3,22 @@ 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; } + public User Follower { get; set; } = null!; [ForeignKey(nameof(FollowedId))] - public User Followed { get; set; } + public User Followed { get; set; } = null!; } } diff --git a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs deleted file mode 100644 index f2e72b3..0000000 --- a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - [Table("UserHasTrack")] - public class UserHasTrack - { - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - public required long UserId { get; set; } - - [Required] - public required Guid TrackId { get; set; } - - [ForeignKey(nameof(UserId)) ] - public User User { get; set; } - - [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } - } -} \ No newline at end of file diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs deleted file mode 100644 index 26e39c6..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs +++ /dev/null @@ -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; } - } -} diff --git a/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs new file mode 100644 index 0000000..bc6e091 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Domain.ValueObjects +{ + /// The picture file names of an album or playlist. Resized names stay null until processing finishes. + public class EntityPictures + { + [Required] + public required string OriginalName { get; set; } + + public string? SmallName { get; set; } + + public string? MediumName { get; set; } + + public string? LargeName { get; set; } + + public static EntityPictures Pending(string objectName) => new() { OriginalName = objectName }; + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs index 8b8710c..bd753f3 100644 --- a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs @@ -19,8 +19,10 @@ namespace Musify.Domain.ValueObjects var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; foreach (var intenseGenre in intense) + { foreach (var mellowGenre in mellow) Add(intenseGenre, mellowGenre); + } return pairs; } diff --git a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs new file mode 100644 index 0000000..3a0576f --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.ValueObjects +{ + public enum MixKind + { + Discovery = 0, + Daily = 1 + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs deleted file mode 100644 index 7c701a9..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs +++ /dev/null @@ -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; } - } -} diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs index f809e24..8ea1473 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -1,6 +1,6 @@ namespace Musify.Domain.ValueObjects { - public enum PlaylistVisibility + public enum PlayListVisibility { Private = 0, Public = 1 diff --git a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs index 66aa32e..7a9fdc2 100644 --- a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs @@ -1,4 +1,4 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects { public enum ProcessingStatus { @@ -7,4 +7,4 @@ Completed = 2, Failed = 3 } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs index 2333465..c61ae42 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; namespace Musify.Domain.ValueObjects { -#pragma warning disable CS8618 public class TrackAudio { public string? OriginalName { get; set; } @@ -11,12 +10,6 @@ namespace Musify.Domain.ValueObjects 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; diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs index 143d9da..9cf5243 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; namespace Musify.Domain.ValueObjects { -#pragma warning disable CS8618 public class TrackPictures { public string? OriginalName { get; set; } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadState.cs b/backend/Core/Musify.Domain/ValueObjects/UploadState.cs deleted file mode 100644 index 5bcaf5b..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/UploadState.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Domain.ValueObjects -{ - public enum UploadState - { - Pending, - Failed, - Successful, - } -} \ No newline at end of file diff --git a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs index b1cb2a9..87af825 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs @@ -1,10 +1,11 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class AudioConfiguration + public class AudioConfiguration : IConfigurationOptions { - public const string SectionName = "AudioTranscoder"; + public static string SectionName => "AudioTranscoder"; [Required] public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); @@ -38,4 +39,4 @@ namespace Musify.Infrastructure.Configuration public string AdditionalArguments { get; set; } = string.Empty; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs index e79f5ed..d0549ff 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs @@ -1,12 +1,13 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class DatabaseConfiguration + public class DatabaseConfiguration : IConfigurationOptions { - public const string SectionName = "Database"; + public static string SectionName => "Database"; [Required] public required string ConnectionString { get; set; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs index 2c2afb7..6f7a6d7 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class InfrastructureStorageConfiguration + public class InfrastructureStorageConfiguration : IConfigurationOptions { - public const string SectionName = "InfrastructureStorage"; + public static string SectionName => "InfrastructureStorage"; [Url] [Required] diff --git a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs index df216ee..d64a470 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class MassTransitConfiguration + public class MassTransitConfiguration : IConfigurationOptions { - public const string SectionName = "MassTransit"; + public static string SectionName => "MassTransit"; [Required] public required string Address { get; set; } diff --git a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs index e435db1..3f8e02e 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class OpenTelemetryConfiguration + public class OpenTelemetryConfiguration : IConfigurationOptions { - public const string SectionName = "OpenTelemetry"; + public static string SectionName => "OpenTelemetry"; [Url] [Required] diff --git a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs index 0773c90..18120a8 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class StreamTicketConfiguration + public class StreamTicketConfiguration : IConfigurationOptions { - public const string SectionName = "StreamTicket"; + public static string SectionName => "StreamTicket"; [Required] public required string PrivateKeyPath { get; set; } diff --git a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs index fa33cae..d7309b9 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; namespace Musify.Infrastructure.Configuration { - public class WorkerConfiguration + public class WorkerConfiguration : IConfigurationOptions { - public const string SectionName = "Workers"; + public static string SectionName => "Workers"; [Required] public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index 306716f..90108ae 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -14,16 +14,18 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + @@ -40,6 +42,12 @@ + + + + + + diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index 4c7f39c..5ff17e7 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -23,14 +23,7 @@ namespace Musify.Infrastructure.Jobs 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); - } - } + await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); logger.LogInformation("Finished daily mix generation"); } diff --git a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs new file mode 100644 index 0000000..a2f154b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs @@ -0,0 +1,42 @@ +using Hangfire; +using Microsoft.Extensions.Hosting; +using Musify.Application.Configuration; + +namespace Musify.Infrastructure.Jobs +{ + /// Registers every recurring Hangfire job when the host starts. + public sealed class RecurringJobsRegistrar( + IRecurringJobManager recurringJobManager, + UploadIntentConfiguration uploadIntentConfiguration) + : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) + { + var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; + + recurringJobManager.AddOrUpdate( + "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); + recurringJobManager.AddOrUpdate( + "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); + recurringJobManager.AddOrUpdate( + "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. + internal static string EveryCron(int intervalSeconds) + { + var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); + return minutes < 60 + ? $"*/{minutes} * * * *" + : $"0 */{Math.Max(1, minutes / 60)} * * *"; + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs new file mode 100644 index 0000000..a8aecff --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs @@ -0,0 +1,11 @@ +using Mediator; +using Musify.Application.UploadIntents; + +namespace Musify.Infrastructure.Jobs +{ + public class TempUploadsCleanupJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); + } +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs deleted file mode 100644 index ee0e0d4..0000000 --- a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.Jobs -{ - public class TemporalUploadsCleanUpJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService - { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix} )", - uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds, - uploadIntentConfiguration.TemporalRootPrefix); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds)); - - do - { - try - { - await RunAsync(stoppingToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogError(exception, "Error in TemporalUploadsCleanUpJob"); - } - } - while (await timer.WaitForNextTickAsync(stoppingToken)); - } - - async Task RunAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var storageService = scope.ServiceProvider.GetRequiredService(); - - var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TemporalUploadsRetentionDays); - var deleted = 0; - var errors = 0; - - await foreach (var (key, lastModified) in storageService.ListObjectsAsync( - storageConfiguration.Bucket, - uploadIntentConfiguration.TemporalRootPrefix, - cancellationToken)) - { - if (lastModified >= cutOff) - continue; - - try - { - await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); - deleted++; - } - catch (Exception exception) - { - errors++; - logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key); - } - } - - if (deleted > 0 || errors > 0) - logger.LogInformation("TemporalUploadsCleanUpJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index 5bf9192..66c46c1 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -1,74 +1,11 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; +using Mediator; +using Musify.Application.UploadIntents; namespace Musify.Infrastructure.Jobs { - public class UploadIntentExpirationJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService + public class UploadIntentExpirationJob(IMediator mediator) { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds)); - - do - { - try - { - await RunAsync(stoppingToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogError(exception, "Error in UploadIntentExpirationJob"); - } - } - while (await timer.WaitForNextTickAsync(stoppingToken)); - } - - async Task RunAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var database = scope.ServiceProvider.GetRequiredService(); - - var now = DateTime.UtcNow; - - var expiredIntents = await database.UploadIntents - .Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now) - .ToListAsync(cancellationToken); - - if (expiredIntents.Count > 0) - { - foreach (var intent in expiredIntents) - intent.Status = UploadIntentStatus.Expired; - - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count); - } - - if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0) - { - var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); - - var oldExpiredIntents = await database.UploadIntents - .Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff) - .ToListAsync(cancellationToken); - - if (oldExpiredIntents.Count > 0) - { - database.UploadIntents.RemoveRange(oldExpiredIntents); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); - } - } - } + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs deleted file mode 100644 index 9e55e31..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs +++ /dev/null @@ -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 logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "mark-album-as-failed"; - - public async Task Execute(ExecuteContext 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(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs index 0db2cab..9bae866 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -21,9 +21,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Albums arguments.AlbumId, arguments.Bucket, arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), + arguments.Sizes), executeContext.CancellationToken); logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs index 85b5216..f0b102b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -1,10 +1,9 @@ using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Activities.Audio { @@ -17,26 +16,15 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio public async Task Execute(ExecuteContext executeContext) { - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found."); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track for TrackId {TrackId}", - executeContext.Arguments.TrackId); - throw; - } + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); try { track.Audio.TranscodeStatus = ProcessingStatus.Processing; - var folderName = Guid.NewGuid().ToString(); - var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey); - var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName); - var sourceFilePath = Path.Combine(workingDirectory, sourceFileName); + + var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey)); Directory.CreateDirectory(workingDirectory); await database.SaveChangesAsync(executeContext.CancellationToken); @@ -48,26 +36,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory - } ); + }); } - catch (Exception exception) + catch { - logger.LogError(exception, "Failed to generate audio workflow paths for {SourceKey}", - executeContext.Arguments.SourceKey); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); throw; } } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs new file mode 100644 index 0000000..ba69169 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Infrastructure.MassTransit.Activities.Audio +{ + /// Shared bookkeeping of the audio processing activities. + internal static class TrackAudioStatus + { + public static async Task LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) => + await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken) + ?? throw new InvalidOperationException($"Track with id {trackId} not found"); + + public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track == null) + return; + + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index c09875c..1321e01 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -1,11 +1,9 @@ using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Activities.Audio { @@ -19,22 +17,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio public async Task Execute(ExecuteContext executeContext) { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); - var workingDirectory = executeContext.GetVariable(executeContext.Arguments.WorkingDirectoryVariable); - ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory)); + var arguments = executeContext.Arguments; + var sourceFilePath = executeContext.GetVariable(arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var workingDirectory = executeContext.GetVariable(arguments.WorkingDirectoryVariable); + ArgumentNullException.ThrowIfNull(workingDirectory); - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); - throw; - } + await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); try { @@ -42,9 +31,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio await using (var fileStream = File.OpenRead(sourceFilePath)) { transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( - fileStream, - workingDirectory, - executeContext.CancellationToken); + fileStream, workingDirectory, executeContext.CancellationToken); } if (transcodeResult.StatusCode != 0) @@ -52,47 +39,28 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio File.Delete(sourceFilePath); - var log = new TranscodeAudioLog(workingDirectory); - return executeContext.CompletedWithVariables(log, new Dictionary + return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary { - [RoutingSlipVariableNames.Audio.DurationSeconds] = (int)Math.Round(transcodeResult.Duration.TotalSeconds) - } ); + [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds + }); } - catch (Exception exception) + catch { - logger.LogError(exception, "Failed to transcode {SourceFilePath}", sourceFilePath); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); throw; } } public Task Compensate(CompensateContext compensateContext) { - try - { - if (Directory.Exists(compensateContext.Log.WorkingDirectory)) - Directory.Delete(compensateContext.Log.WorkingDirectory, recursive: true); - else - logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", - compensateContext.Log.WorkingDirectory); - return Task.FromResult(compensateContext.Compensated()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate transcoded audio directory {WorkingDirectory}", compensateContext.Log.WorkingDirectory); - return Task.FromResult(compensateContext.Failed(exception)); - } + var workingDirectory = compensateContext.Log.WorkingDirectory; + + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, recursive: true); + else + logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory); + + return Task.FromResult(compensateContext.Compensated()); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index 472e7fc..0771916 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -1,9 +1,7 @@ using MassTransit; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; @@ -20,97 +18,50 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio public async Task Execute(ExecuteContext executeContext) { - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track {TrackId}", - executeContext.Arguments.TrackId); - throw; - } - - var audioFolderKey = executeContext.Arguments.AudioFolderKey; - logger.LogDebug("Resolved audio folder key: {AudioFolderKey}", audioFolderKey); - - if (string.IsNullOrWhiteSpace(audioFolderKey)) - { - logger.LogWarning("Audio folder key is empty for track {TrackId}; skipping update", track.Id); - await publishEndpoint.Publish( - new TrackAudioProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); - return executeContext.Completed(); - } + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); try { - var durationSeconds = executeContext.GetVariable(executeContext.Arguments.DurationSecondsVariable) - ?? throw new InvalidOperationException($"Missing routing slip variable {executeContext.Arguments.DurationSecondsVariable}"); + if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey)) + throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); - var log = new UpdateTrackAudioLog( - track.Id, - track.Audio.FolderName, - track.DurationSeconds); + var durationSeconds = executeContext.GetVariable(arguments.DurationSecondsVariable) + ?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}"); - track.Audio.FolderName = Path.GetFileName(audioFolderKey); + var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds); + + track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey); track.DurationSeconds = durationSeconds; track.Audio.TranscodeStatus = ProcessingStatus.Completed; await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); + logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId); - await publishEndpoint.Publish( - new TrackAudioProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); + await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken); return executeContext.Completed(log); } - catch (Exception exception) + catch { - logger.LogError(exception, "Failed to update track {TrackId} audio", - executeContext.Arguments.TrackId); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); throw; } } public async Task Compensate(CompensateContext compensateContext) { - try - { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken); - if (track == null) - throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found"); + var log = compensateContext.Log; - track.Audio.FolderName = compensateContext.Log.PreviousAudioFolderName; - track.DurationSeconds = compensateContext.Log.PreviousDurationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(compensateContext.CancellationToken); + var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken); + track.Audio.FolderName = log.PreviousAudioFolderName; + track.DurationSeconds = log.PreviousDurationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(compensateContext.CancellationToken); - logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); + logger.LogInformation("Compensated track {TrackId} audio", log.TrackId); - return compensateContext.Compensated(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate track {TrackId} audio", - compensateContext.Log.TrackId); - return compensateContext.Failed(exception); - } + return compensateContext.Compensated(); } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs index 22e061a..c7340cd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files { internal class CopyFileInBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IActivity { @@ -15,35 +15,23 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files public async Task Execute(ExecuteContext executeContext) { - try - { - await storageHandler.CopyFileAsync( - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey, - executeContext.CancellationToken); + await storageService.CopyFileAsync( + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey, + executeContext.CancellationToken); - return executeContext.Completed(new CopyFileInBucketLog( - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to copy {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey); - throw; - } + return executeContext.Completed(new CopyFileInBucketLog( + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey)); } public async Task Compensate(CompensateContext compensateContext) { try { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( compensateContext.Log.DestinationBucket, compensateContext.Log.DestinationKey, compensateContext.CancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index 53803cf..67934db 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files { internal class DownloadFileFromBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IActivity { @@ -16,34 +16,27 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files public async Task Execute(ExecuteContext executeContext) { var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); + ArgumentNullException.ThrowIfNull(destinationPath); - try - { - using var fileStream = await storageHandler.GetFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken); + using var fileStream = await storageService.GetFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken) + ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); - if (fileStream.CanSeek) - fileStream.Position = 0; + if (fileStream.CanSeek) + fileStream.Position = 0; - using var destinationStream = File.Create(destinationPath); - await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); - return executeContext.Completed(new DownloadFileFromBucketLog( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - destinationPath)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to download file from bucket"); - throw; - } + using var destinationStream = File.Create(destinationPath); + await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); + return executeContext.Completed(new DownloadFileFromBucketLog( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + destinationPath)); } public Task Compensate(CompensateContext compensateContext) @@ -60,4 +53,4 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files } } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs deleted file mode 100644 index 6565766..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs +++ /dev/null @@ -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 logger) - : IActivity - { - public const string ExecuteEndpointName = "download-file-from-url"; - - public async Task Execute(ExecuteContext executeContext) - { - var destinationPath = executeContext.GetVariable(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 Compensate(CompensateContext 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)); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index b148c2b..7432997 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -1,14 +1,14 @@ -using MassTransit; -using Amazon.S3; -using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; using System.Net; +using Amazon.S3; +using MassTransit; +using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; namespace Musify.Infrastructure.MassTransit.Activities.Files { internal class RemoveFileFromBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IExecuteActivity { @@ -18,7 +18,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files { try { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( executeContext.Arguments.Bucket, executeContext.Arguments.Key, executeContext.CancellationToken); @@ -34,13 +34,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files executeContext.Arguments.Bucket); return executeContext.Completed(); } - catch (Exception exception) - { - logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}", - executeContext.Arguments.Key, - executeContext.Arguments.Bucket); - throw; - } } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs index c23193b..8bffd7c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files { internal class TransferFilesToBucketActivity( - IStorageService storageHandler, + IStorageService storageService, ILogger logger) : IActivity { @@ -16,29 +16,19 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files public async Task Execute(ExecuteContext executeContext) { var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); - ArgumentNullException.ThrowIfNull(folderPath, nameof(folderPath)); + ArgumentNullException.ThrowIfNull(folderPath); var destinationKey = executeContext.Arguments.DestinationKey; - ArgumentNullException.ThrowIfNull(destinationKey, nameof(destinationKey)); + ArgumentNullException.ThrowIfNull(destinationKey); - try - { - var uploadedKeys = await storageHandler.TransferFilesAsync( - folderPath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); + var uploadedKeys = await storageService.TransferFilesAsync( + folderPath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); - return executeContext.Completed(new TransferFilesToBucketLog( - executeContext.Arguments.DestinationBucket, - uploadedKeys.ToArray())); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to transfer files from {FolderPath} to {DestinationBucket}", - folderPath, - executeContext.Arguments.DestinationBucket); - throw; - } + return executeContext.Completed(new TransferFilesToBucketLog( + executeContext.Arguments.DestinationBucket, + uploadedKeys.ToArray())); } public async Task Compensate(CompensateContext compensateContext) @@ -47,7 +37,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files { foreach (var uploadedKey in compensateContext.Log.UploadedKeys) { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( compensateContext.Log.DestinationBucket, uploadedKey, compensateContext.CancellationToken); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs index 78029e2..c580bf3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -6,7 +6,7 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Files { - internal class UploadFileToBucketActivity(IStorageService storageHandler, + internal class UploadFileToBucketActivity(IStorageService storageService, ILogger logger) : IActivity { @@ -15,35 +15,27 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files public async Task Execute(ExecuteContext executeContext) { var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + ArgumentNullException.ThrowIfNull(sourceFilePath); var fileName = Path.GetFileName(sourceFilePath); var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } .Where(static s => !string.IsNullOrWhiteSpace(s)) .Select(static s => s.Trim().Trim('/', '\\'))); - try - { - await storageHandler.UploadFileAsync( - sourceFilePath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - return executeContext.CompletedWithVariables(new UploadFileToBucketLog( - executeContext.Arguments.DestinationBucket, - destinationKey)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to bucket"); - throw; - } + await storageService.UploadFileAsync( + sourceFilePath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.CompletedWithVariables(new UploadFileToBucketLog( + executeContext.Arguments.DestinationBucket, + destinationKey)); } public async Task Compensate(CompensateContext compensateContext) { try { - await storageHandler.RemoveFileAsync( + await storageService.RemoveFileAsync( compensateContext.Log.DestinationBucket, compensateContext.Log.DestinationKey, compensateContext.CancellationToken); @@ -58,4 +50,4 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files } } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs new file mode 100644 index 0000000..52bab3e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs @@ -0,0 +1,98 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Abstractions; +using Musify.Domain.Entities; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle +{ + /// Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped. + internal abstract class MarkLifeCycleActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle + { + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status); + return executeContext.Completed(); + } + + entity.LifeCycleStatus = arguments.Status; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status); + return executeContext.Completed(); + } + } + + /// Deletes an entity row. A missing entity is skipped. + internal abstract class DeleteEntityActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle + { + public async Task Execute(ExecuteContext executeContext) + { + var id = executeContext.Arguments.Id; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id); + return executeContext.Completed(); + } + + database.Set().Remove(entity); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id); + return executeContext.Completed(); + } + } + + // One concrete activity per entity keeps a separate queue for each of them: + // the endpoint name is the kebab-cased class name. + internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-track-life-cycle"; + } + + internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-play-list-life-cycle"; + } + + internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-album-life-cycle"; + } + + internal sealed class DeleteTrackActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-track"; + } + + internal sealed class DeletePlayListActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-play-list"; + } + + internal sealed class DeleteAlbumActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-album"; + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs index ce41d3f..1022f68 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs @@ -1,6 +1,7 @@ using MassTransit; using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Activities.Pictures { @@ -12,40 +13,28 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures internal const string ResizedPictureFileExtension = ".webp"; - public async Task Execute(ExecuteContext executeContext) + public Task Execute(ExecuteContext executeContext) { - try + var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + + var sourceFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); + var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + + Directory.CreateDirectory(workingDirectory); + + logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory); + + return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary { - var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); - - var sourceFilePath = Path.Combine( - workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); - var smallPictureFilePath = Path.Combine( - workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - var mediumPictureFilePath = Path.Combine( - workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - var largePictureFilePath = Path.Combine( - workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - - Directory.CreateDirectory(workingDirectory); - - logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", - workingDirectory); - return executeContext.CompletedWithVariables(new Dictionary - { - [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, - [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, - [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, - [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, - [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath - } ); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate picture workflow paths for {SourceKey}", - executeContext.Arguments.SourceKey); - throw; - } + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, + [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, + [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath + })); } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs index 78eac7c..89bf098 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -16,43 +16,35 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures public async Task Execute(ExecuteContext executeContext) { var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + ArgumentNullException.ThrowIfNull(sourceFilePath); var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationFilePath, nameof(destinationFilePath)); + ArgumentNullException.ThrowIfNull(destinationFilePath); - try + if (!File.Exists(sourceFilePath)) { - if (!File.Exists(sourceFilePath)) - { - logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); - throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); - } - - await using var fileStream = File.OpenRead(sourceFilePath); - - await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( - fileStream, - executeContext.Arguments.Width, - executeContext.Arguments.Height, - executeContext.CancellationToken); - - var destinationDirectory = Path.GetDirectoryName(destinationFilePath); - if (!string.IsNullOrEmpty(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - await using var destinationStream = File.Create(destinationFilePath); - await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); - - logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", - sourceFilePath, - destinationFilePath); - return executeContext.Completed(new ResizePictureLog(destinationFilePath)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resize picture {SourceFilePath}", sourceFilePath); - throw; + logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); + throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); } + + await using var fileStream = File.OpenRead(sourceFilePath); + + await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( + fileStream, + executeContext.Arguments.Width, + executeContext.Arguments.Height, + executeContext.CancellationToken); + + var destinationDirectory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrEmpty(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + await using var destinationStream = File.Create(destinationFilePath); + await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); + + logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", + sourceFilePath, + destinationFilePath); + return executeContext.Completed(new ResizePictureLog(destinationFilePath)); } public Task Compensate(CompensateContext compensateContext) @@ -69,4 +61,4 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures } } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs deleted file mode 100644 index 53102ea..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs +++ /dev/null @@ -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 logger) - : IActivity - { - public const string ExecuteEndpointName = "update-album-picture"; - - public async Task Execute(ExecuteContext executeContext) - { - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); - ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); - var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); - ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); - var largeResizedVariable = executeContext.GetVariable(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 Compensate(CompensateContext 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); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs new file mode 100644 index 0000000..3536d55 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs @@ -0,0 +1,161 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Pictures +{ + internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large); + + /// Stores the processed picture names on the owner and reports it; compensation restores the previous names. + internal abstract class UpdatePicturesActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity + where TEntity : class + { + protected IDatabase Database { get; } = database; + + protected abstract Task FindAsync(Guid id, CancellationToken cancellationToken); + + /// Reads the current names; throws when the entity has no pending picture. + protected abstract PictureNames Read(TEntity entity); + + protected abstract void Write(TEntity entity, PictureNames names, bool succeeded); + + protected abstract object ProcessedEvent(Guid id); + + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + var small = RequireVariable(executeContext, arguments.SmallPictureVariable); + var medium = RequireVariable(executeContext, arguments.MediumPictureVariable); + var large = RequireVariable(executeContext, arguments.LargePictureVariable); + + var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken) + ?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found"); + + var previous = Read(entity); + Write( + entity, + new PictureNames( + Path.GetFileName(arguments.OriginalPictureKey), + Path.GetFileName(small), + Path.GetFileName(medium), + Path.GetFileName(large)), + succeeded: true); + await Database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId); + + await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken); + + return executeContext.Completed(new UpdatePicturesLog( + arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large)); + } + + public async Task Compensate(CompensateContext compensateContext) + { + var log = compensateContext.Log; + + try + { + var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken); + if (entity == null) + return compensateContext.Compensated(); + + var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName); + Write(entity, previous, succeeded: false); + await Database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId); + return compensateContext.Failed(exception); + } + } + + protected static PictureNames ReadPictures(EntityPictures? pictures) => + pictures == null + ? throw new InvalidOperationException("The entity has no pending picture upload") + : new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName); + + protected static void WritePictures(EntityPictures? pictures, PictureNames names) + { + if (pictures == null) + return; + + pictures.OriginalName = names.Original ?? pictures.OriginalName; + pictures.SmallName = names.Small; + pictures.MediumName = names.Medium; + pictures.LargeName = names.Large; + } + + private static string RequireVariable(ExecuteContext executeContext, string variableName) => + executeContext.GetVariable(variableName) + ?? throw new InvalidOperationException($"Missing routing slip variable {variableName}"); + } + + internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) + { + public const string ExecuteEndpointName = "update-track-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken); + + protected override PictureNames Read(Track track) => + new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName); + + protected override void Write(Track track, PictureNames names, bool succeeded) + { + track.Pictures.OriginalName = names.Original; + track.Pictures.SmallName = names.Small; + track.Pictures.MediumName = names.Medium; + track.Pictures.LargeName = names.Large; + track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed; + } + + protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id); + } + + internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) + { + public const string ExecuteEndpointName = "update-play-list-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken); + + protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures); + + protected override void Write(PlayList playList, PictureNames names, bool succeeded) => + WritePictures(playList.Pictures, names); + + protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id); + } + + internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) + { + public const string ExecuteEndpointName = "update-album-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken); + + protected override PictureNames Read(Album album) => ReadPictures(album.Pictures); + + protected override void Write(Album album, PictureNames names, bool succeeded) => + WritePictures(album.Pictures, names); + + protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs deleted file mode 100644 index 4a4bfe2..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ /dev/null @@ -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 UpdatePlayListPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity - { - public const string ExecuteEndpointName = "update-play-list-picture"; - - public async Task Execute(ExecuteContext executeContext) - { - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); - ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); - var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); - ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); - var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); - ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); - - try - { - var playList = await database.PlayLists.FindAsync( - [executeContext.Arguments.PlayListId], - cancellationToken: executeContext.CancellationToken); - - if (playList == null) - { - logger.LogWarning("Playlist {PlayListId} not found", - executeContext.Arguments.PlayListId); - throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found"); - } - - if (playList.Pictures == null) - { - logger.LogWarning("Playlist {PlayListId} has no pending picture upload", - executeContext.Arguments.PlayListId); - throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} has no pending picture upload"); - } - - var log = new UpdatePlayListPictureLog( - playList.Id, - playList.Pictures.OriginalName, - playList.Pictures.SmallName, - playList.Pictures.MediumName, - playList.Pictures.LargeName); - - playList.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - playList.Pictures.SmallName = Path.GetFileName(smallResizedVariable); - playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); - playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable); - - database.PlayLists.Update(playList); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Updated playlist {PlayListId} pictures", - executeContext.Arguments.PlayListId); - - await publishEndpoint.Publish( - new PlayListPictureProcessed(executeContext.Arguments.PlayListId), - executeContext.CancellationToken); - - return executeContext.Completed(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update playlist {PlayListId} pictures", - executeContext.Arguments.PlayListId); - throw; - } - } - - public async Task Compensate(CompensateContext compensateContext) - { - try - { - var playList = await database.PlayLists.FindAsync( - [compensateContext.Log.PlayListId], - cancellationToken: compensateContext.CancellationToken); - - if (playList == null) - { - return compensateContext.Compensated(); - } - - playList.Pictures = new PlayListPictures - { - OriginalName = compensateContext.Log.PreviousOriginalPictureKey, - SmallName = compensateContext.Log.PreviousSmallPictureKey, - MediumName = compensateContext.Log.PreviousMediumPictureKey, - LargeName = compensateContext.Log.PreviousLargePictureKey - }; - - database.PlayLists.Update(playList); - await database.SaveChangesAsync(compensateContext.CancellationToken); - - return compensateContext.Compensated(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate playlist {PlayListId} pictures", compensateContext.Log.PlayListId); - return compensateContext.Failed(exception); - } - } - } -} \ No newline at end of file diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs deleted file mode 100644 index 2f240c8..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs +++ /dev/null @@ -1,103 +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 UpdateTrackPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity - { - public const string ExecuteEndpointName = "update-track-picture"; - - public async Task Execute(ExecuteContext executeContext) - { - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); - ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); - var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); - ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); - var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); - ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); - - try - { - var track = await database.Tracks.FindAsync( - [executeContext.Arguments.TrackId], - cancellationToken: executeContext.CancellationToken); - - if (track == null) - { - logger.LogWarning("Track {TrackId} not found", - executeContext.Arguments.TrackId); - throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); - } - - var log = new UpdateTrackPictureLog( - track.Id, - track.Pictures.OriginalName, - track.Pictures.SmallName, - track.Pictures.MediumName, - track.Pictures.LargeName); - - track.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - track.Pictures.SmallName = Path.GetFileName(smallResizedVariable); - track.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); - track.Pictures.LargeName = Path.GetFileName(largeResizedVariable); - track.Pictures.ProcessingStatus = ProcessingStatus.Completed; - - database.Tracks.Update(track); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Updated track {TrackId} pictures", - executeContext.Arguments.TrackId); - - await publishEndpoint.Publish( - new TrackPictureProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); - - return executeContext.Completed(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update track {TrackId} pictures", - executeContext.Arguments.TrackId); - throw; - } - } - - public async Task Compensate(CompensateContext compensateContext) - { - try - { - var track = await database.Tracks.FindAsync( - [compensateContext.Log.TrackId], - cancellationToken: compensateContext.CancellationToken); - - if (track == null) - return compensateContext.Compensated(); - - track.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey; - track.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey; - track.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey; - track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; - track.Pictures.ProcessingStatus = ProcessingStatus.Failed; - - database.Tracks.Update(track); - await database.SaveChangesAsync(compensateContext.CancellationToken); - - return compensateContext.Compensated(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate track {TrackId} pictures", compensateContext.Log.TrackId); - return compensateContext.Failed(exception); - } - } - } -} \ No newline at end of file diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs deleted file mode 100644 index 1c589e8..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists -{ - internal class DeletePlayListFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "delete-play-list-from-db"; - - public async Task Execute(ExecuteContext executeContext) - { - var playList = await database.PlayLists - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - - if (playList == null) - { - logger.LogInformation( - "PlayList {PlayListId} not found while deleting from DB, skipping", - executeContext.Arguments.PlayListId); - return executeContext.Completed(); - } - - database.PlayLists.Remove(playList); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Deleted playlist {PlayListId} from DB", playList.Id); - return executeContext.Completed(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs deleted file mode 100644 index f534204..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs +++ /dev/null @@ -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.PlayLists -{ - internal class MarkPlayListAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "mark-play-list-as-failed"; - - public async Task Execute(ExecuteContext executeContext) - { - var playList = await database.PlayLists - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - - if (playList != null) - { - playList.LifeCycleStatus = LifeCycleStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Marked playlist {PlayListId} as failed", executeContext.Arguments.PlayListId); - } - - return executeContext.Completed(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs deleted file mode 100644 index bac3433..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists -{ - internal class MarkPlayListAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "mark-play-list-as-removing"; - - public async Task Execute(ExecuteContext executeContext) - { - var playList = await database.PlayLists - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - - if (playList == null) - { - logger.LogInformation( - "PlayList {PlayListId} not found while marking as removing, skipping", - executeContext.Arguments.PlayListId); - return executeContext.Completed(); - } - - playList.LifeCycleStatus = LifeCycleStatus.Removing; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked playlist {PlayListId} as removing", playList.Id); - return executeContext.Completed(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs index 4112cbb..dc2b771 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs @@ -21,9 +21,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.PlayLists arguments.PlayListId, arguments.Bucket, arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), + arguments.Sizes), executeContext.CancellationToken); logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs deleted file mode 100644 index cb1b0e0..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.MassTransit.Arguments; - -namespace Musify.Infrastructure.MassTransit.Activities.Tracks -{ - internal class DeleteTrackFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "delete-track-from-db"; - - public async Task Execute(ExecuteContext executeContext) - { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - - if (track == null) - { - logger.LogInformation( - "Track {TrackId} not found while deleting from DB, skipping", - executeContext.Arguments.TrackId); - return executeContext.Completed(); - } - - database.Tracks.Remove(track); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Deleted track {TrackId} from DB", track.Id); - return executeContext.Completed(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs deleted file mode 100644 index 46f2a69..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs +++ /dev/null @@ -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.Tracks -{ - internal class MarkTrackAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "mark-track-as-failed"; - - public async Task Execute(ExecuteContext executeContext) - { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - - if (track != null) - { - track.LifeCycleStatus = LifeCycleStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Marked track {TrackId} as failed", executeContext.Arguments.TrackId); - } - - return executeContext.Completed(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs deleted file mode 100644 index d5b9a10..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.MassTransit.Activities.Tracks -{ - internal class MarkTrackAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "mark-track-as-removing"; - - public async Task Execute(ExecuteContext executeContext) - { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - - if (track == null) - { - logger.LogInformation( - "Track {TrackId} not found while marking as removing, skipping", - executeContext.Arguments.TrackId); - return executeContext.Completed(); - } - - track.LifeCycleStatus = LifeCycleStatus.Removing; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked track {TrackId} as removing", track.Id); - return executeContext.Completed(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs index a82865c..9b88b0e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -21,9 +21,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks arguments.TrackId, arguments.Bucket, arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), + arguments.Sizes), executeContext.CancellationToken); await publishEndpoint.Publish( diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs new file mode 100644 index 0000000..257ceee --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record DeleteEntityArguments(Guid Id); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs deleted file mode 100644 index b455ed9..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record DeletePlayListFromDbArguments( - Guid PlayListId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs deleted file mode 100644 index 982e1ae..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record DeleteTrackFromDbArguments( - Guid TrackId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs index 1579a91..e687d24 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs @@ -1,7 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments +namespace Musify.Infrastructure.MassTransit.Arguments { public record DownloadFileFromBucketArguments( string Bucket, string Key, string DestinationFilePathVariable); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs deleted file mode 100644 index 5d7422b..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record DownloadFileFromUrlArguments( - string SourceUrl, - string DestinationFilePathVariable); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs index 330a3b8..9a71d63 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs @@ -3,4 +3,4 @@ namespace Musify.Infrastructure.MassTransit.Arguments public record GeneratePictureWorkflowPathsArguments( string TemporaryRootDirectory, string SourceKey); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs deleted file mode 100644 index 1c9cf0e..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record MarkAlbumAsFailedArguments(Guid AlbumId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs new file mode 100644 index 0000000..f886919 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs @@ -0,0 +1,6 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs deleted file mode 100644 index 386c9ec..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record MarkPlayListAsFailedArguments(Guid PlayListId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs deleted file mode 100644 index 8cd63f7..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record MarkPlayListAsRemovingArguments( - Guid PlayListId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs deleted file mode 100644 index 659f9e0..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record MarkTrackAsFailedArguments(Guid TrackId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs deleted file mode 100644 index 5731258..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record MarkTrackAsRemovingArguments( - Guid TrackId); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs index f484264..b2efad7 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -6,7 +6,5 @@ namespace Musify.Infrastructure.MassTransit.Arguments Guid AlbumId, string Bucket, string FinalPictureKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs index 5721ea8..8168dbf 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -6,7 +6,5 @@ namespace Musify.Infrastructure.MassTransit.Arguments Guid PlayListId, string Bucket, string FinalPictureKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs index a13a5e0..e711bc8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -8,7 +8,5 @@ namespace Musify.Infrastructure.MassTransit.Arguments string FinalPictureKey, string FinalAudioKey, string AudioDestinationFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs index 5f3e499..c0e803f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs @@ -1,6 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments +namespace Musify.Infrastructure.MassTransit.Arguments { public record RemoveFileFromBucketArguments( string Bucket, string Key); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs index 48f804d..dcc61fd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs @@ -1,7 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments +namespace Musify.Infrastructure.MassTransit.Arguments { public record TransferFilesToBucketArguments( string DestinationBucket, string SourceDirectoryVariable, string DestinationKey); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs similarity index 74% rename from backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs index 6f006f0..06e6115 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs @@ -1,7 +1,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments { - public record UpdateAlbumPictureArguments( - Guid AlbumId, + public record UpdatePicturesArguments( + Guid SubjectId, string OriginalPictureKey, string SmallPictureVariable, string MediumPictureVariable, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs deleted file mode 100644 index bb05ee6..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UpdatePlayListPictureArguments( - Guid PlayListId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); -} \ No newline at end of file diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs index af0ae38..b284c4e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -1,7 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments +namespace Musify.Infrastructure.MassTransit.Arguments { public record UpdateTrackAudioArguments( Guid TrackId, string AudioFolderKey, string DurationSecondsVariable); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs deleted file mode 100644 index 0c48ac3..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UpdateTrackPictureArguments( - Guid TrackId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); -} \ No newline at end of file diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs index 8fd35f8..a34ab21 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs @@ -1,7 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments +namespace Musify.Infrastructure.MassTransit.Arguments { public record UploadFileToBucketArguments( string FilePathVariable, string DestinationBucket, string DestinationRoute); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs index db2c554..f799916 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -1,36 +1,19 @@ 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.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers { public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer { - public async Task Consume(ConsumeContext context) + public Task Consume(ConsumeContext 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()); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey)); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs index 0c022f8..1c723ba 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -4,19 +4,10 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class CreateAlbumConsumer( - IBus bus, - AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs index 03d4d67..96b5585 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs @@ -4,19 +4,10 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class CreatePlayListConsumer( - IBus bus, - PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) + public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs index 058a225..6d46e90 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs @@ -4,19 +4,10 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class CreateTrackConsumer( - IBus bus, - CreateTrackRoutingSlipBuilder routingSlipBuilder) + public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs new file mode 100644 index 0000000..42971c8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs @@ -0,0 +1,19 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildAlbumAsync( + message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken); + + await bus.Execute(routingSlip.Build()); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs index a361907..35e8863 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -4,23 +4,16 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class DeletePlayListConsumer( - IBus bus, - DeletePlayListRoutingSlipBuilder routingSlipBuilder) + public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) + public async Task Consume(ConsumeContext context) { - var routingSlip = (await routingSlipBuilder - .BuildAsync( - consumeContext.Message.PlayListId, - consumeContext.Message.UserId, - consumeContext.CorrelationId, - consumeContext.CancellationToken)) - .Build(); + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildPlayListAsync( + message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip); + await bus.Execute(routingSlip.Build()); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs index 4d747fa..90a69be 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -4,19 +4,16 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class DeleteTrackConsumer( - IBus bus, - DeleteTrackRoutingSlipBuilder routingSlipBuilder) + public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) + public async Task Consume(ConsumeContext context) { - var routingSlip = (await routingSlipBuilder - .BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken)) - .Build(); + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildTrackAsync( + message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip); + await bus.Execute(routingSlip.Build()); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs deleted file mode 100644 index 16cc517..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Consumers -{ - internal static class EndpointHelper - { - internal static Uri BuildExecuteActivityUri(string endpointName) => new($"queue:{endpointName}_execute"); - - internal static Uri BuildConsumerUri(string endpointName) => new($"queue:{endpointName}"); - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs index 9f0292d..0cdf562 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs @@ -1,36 +1,19 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.PlayLists; -using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers { public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer { - public async Task Consume(ConsumeContext context) + public Task Consume(ConsumeContext 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.RemovePlayListOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); - - builder.AddActivity( - ActivityNames.MarkPlayListAsFailed, - EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsFailedActivity.ExecuteEndpointName), - new MarkPlayListAsFailedArguments(message.PlayListId)); - - await bus.Execute(builder.Build()); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemovePlayListOriginalPicture, message.PictureKey)); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs new file mode 100644 index 0000000..e717b55 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs @@ -0,0 +1,35 @@ +using MassTransit; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + internal static class ProcessingFailedCleanup + { + /// Removes whatever files were left behind and marks the entity as failed. + public static Task ExecuteAsync( + IBus bus, + Guid subjectId, + string markStepName, + string markEndpointName, + string? bucket, + params (string StepName, string? Key)[] files) + { + var builder = RoutingSlips.Create(correlationId: null); + + if (!string.IsNullOrWhiteSpace(bucket)) + { + foreach (var (stepName, key) in files) + { + if (!string.IsNullOrWhiteSpace(key)) + builder.AddRemoveFile(stepName, bucket, key); + } + } + + builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed)); + + return bus.Execute(builder.Build()); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs index fd7e8bf..2ffe99c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs @@ -1,6 +1,7 @@ using MassTransit; using MassTransit.Courier.Contracts; using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers { @@ -17,7 +18,9 @@ namespace Musify.Infrastructure.MassTransit.Consumers if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject) || !Guid.TryParse(subjectObject?.ToString(), out var subjectId)) + { return; + } switch (kindObject?.ToString()) { @@ -30,7 +33,29 @@ namespace Musify.Infrastructure.MassTransit.Consumers case RoutingSlipVariableNames.ProcessKinds.PlayListPicture: await context.Publish(new PlayListPictureProcessingFailed(subjectId)); break; + case RoutingSlipVariableNames.ProcessKinds.AlbumPicture: + await context.Publish(new AlbumPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.TrackCreation: + await context.Publish(new TrackProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey), + Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.AlbumCreation: + await context.Publish(new AlbumProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.PlayListCreation: + await context.Publish(new PlayListProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; } } + + private static string? Variable(IDictionary variables, string name) => + variables.TryGetValue(name, out var value) ? value?.ToString() : null; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs index 582f58a..6d253ef 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -1,6 +1,7 @@ -using MassTransit; +using MassTransit; using MassTransit.Courier.Contracts; using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers { @@ -11,13 +12,13 @@ namespace Musify.Infrastructure.MassTransit.Consumers { public const string QueueName = "routing-slip-clean-up"; - public Task Consume(ConsumeContext consumeContext) - => DeleteFolder(consumeContext.Message.Variables); + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - public Task Consume(ConsumeContext consumeContext) - => DeleteFolder(consumeContext.Message.Variables); + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - Task DeleteFolder(IDictionary variables) + private Task DeleteFolder(IDictionary variables) { if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) return Task.CompletedTask; @@ -32,17 +33,12 @@ namespace Musify.Infrastructure.MassTransit.Consumers Directory.Delete(pathString, recursive: true); logger.LogInformation("Deleted temporal directory at path {Path}", pathString); } - catch (DirectoryNotFoundException directoryNotFoundException) + catch (DirectoryNotFoundException) { - logger.LogWarning(directoryNotFoundException, "Temporal directory at path {Path} was not found for deletion", pathString); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to delete temporal directory at path {Path}", pathString); - throw; + logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString); } return Task.CompletedTask; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs index 07bdb55..83852ef 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs @@ -1,42 +1,20 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.Tracks; -using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; namespace Musify.Infrastructure.MassTransit.Consumers { public class TrackProcessingFailedConsumer(IBus bus) : IConsumer { - public async Task Consume(ConsumeContext context) + public Task Consume(ConsumeContext 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.RemoveTrackOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); - - if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.AudioKey)) - builder.AddActivity( - ActivityNames.RemoveTrackOriginalAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.AudioKey)); - - builder.AddActivity( - ActivityNames.MarkTrackAsFailed, - EndpointHelper.BuildExecuteActivityUri(MarkTrackAsFailedActivity.ExecuteEndpointName), - new MarkTrackAsFailedArguments(message.TrackId)); - - await bus.Execute(builder.Build()); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveTrackOriginalPicture, message.PictureKey), + (ActivityNames.RemoveTrackOriginalAudio, message.AudioKey)); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs index 0a31f11..caeb800 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -4,19 +4,10 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class UpdateAlbumPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs index 9ce0dcd..ec1203f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -4,19 +4,10 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class UpdateAlbumPictureSourceConsumer( - IBus bus, - AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs index bac0d3d..079493c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs @@ -1,22 +1,13 @@ -using MassTransit; +using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class UpdatePlayListPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs index f29a17e..177685a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs @@ -4,19 +4,10 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class UpdatePlayListPictureSourceConsumer( - IBus bus, - PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) + public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs index a3ae3c9..5570cec 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs @@ -1,22 +1,13 @@ -using MassTransit; +using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class UpdateTrackAudioConsumer( - IBus bus, - AudioWorkflowRoutingSlipBuilder routingSlipBuilder) + public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs index 0fe65bc..079c8e2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs @@ -1,22 +1,13 @@ -using MassTransit; +using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; namespace Musify.Infrastructure.MassTransit.Consumers { - public class UpdateTrackPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) : IConsumer { - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs deleted file mode 100644 index 3255d0a..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Musify.Infrastructure.MassTransit -{ - public static partial class MassTransitDependencyInjection - { - static void RegisterValidatedOptions( - IServiceCollection services, - IConfiguration configuration, - string sectionName) - where TOptions : class - { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(sectionName)) - .ValidateDataAnnotations(); - - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index cae468b..9561290 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -1,9 +1,10 @@ using MassTransit; using Microsoft.Extensions.DependencyInjection; -using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.Albums; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; +using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; @@ -16,62 +17,59 @@ namespace Musify.Infrastructure.MassTransit { public static partial class MassTransitDependencyInjection { - static void RegisterRoutingSlipBuilders(IServiceCollection services) + private static void RegisterRoutingSlipBuilders(IServiceCollection services) { services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } - static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) + private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) { - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); + options.AddActivity(); + options.AddActivity(); options.AddActivity(); + options.AddActivity(); options.AddActivity(); options.AddActivity(); + options.AddActivity(); + options.AddActivity(); options.AddActivity(); + options.AddActivity(); options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index 5790c2c..24940fb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -1,6 +1,7 @@ using MassTransit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; @@ -10,92 +11,55 @@ namespace Musify.Infrastructure.MassTransit { public static partial class MassTransitDependencyInjection { - public static IServiceCollection AddMassTransitClient(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) { - RegisterValidatedOptions( - serviceDescriptors, - configuration, - MassTransitConfiguration.SectionName); + services.AddValidatedOptions(configuration); + services.AddScoped(); - serviceDescriptors.AddScoped(); - serviceDescriptors.AddMassTransit(options => + services.AddMassTransit(options => { - options.AddEntityFrameworkOutbox(outbox => - { - outbox.UsePostgres(); - outbox.UseBusOutbox(); - - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - } ); + AddOutbox(options); options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => ConfigureRabbitMqHost( busFactoryConfigurator, busRegistrationContext.GetRequiredService())); - } ); + }); - return serviceDescriptors; + return services; } - public static IServiceCollection AddMassTransitConsumers(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration) { - RegisterValidatedOptions( - serviceDescriptors, - configuration, - MassTransitConfiguration.SectionName); + services.AddValidatedOptions(configuration); - RegisterValidatedOptions( - serviceDescriptors, - configuration, - WorkerConfiguration.SectionName); + services.AddValidatedOptions(configuration); + services.AddScoped(); - RegisterRoutingSlipBuilders(serviceDescriptors); + RegisterRoutingSlipBuilders(services); - serviceDescriptors.AddMassTransit(options => + services.AddMassTransit(options => { - options.AddEntityFrameworkOutbox(outbox => - { - outbox.UsePostgres(); - outbox.UseBusOutbox(); - - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - } ); + AddOutbox(options); options.SetKebabCaseEndpointNameFormatter(); RegisterConsumersAndActivities(options); options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } ); + .EntityFrameworkRepository(ConfigureSagaRepository); options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } ); + .EntityFrameworkRepository(ConfigureSagaRepository); options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } ); + .EntityFrameworkRepository(ConfigureSagaRepository); options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => { UseStandardRetry(endpointConfigurator); endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); - } ); + }); options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => { @@ -104,13 +68,31 @@ namespace Musify.Infrastructure.MassTransit busRegistrationContext.GetRequiredService()); busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); - } ); - } ); + }); + }); - return serviceDescriptors; + return services; } - static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => + private static void AddOutbox(IBusRegistrationConfigurator options) => + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + private static void ConfigureSagaRepository(IEntityFrameworkSagaRepositoryConfigurator repository) + where TSaga : class, ISaga + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + } + + private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => endpointConfigurator.UseMessageRetry(retryConfigurator => retryConfigurator.Exponential( retryLimit: 5, @@ -118,7 +100,7 @@ namespace Musify.Infrastructure.MassTransit maxInterval: TimeSpan.FromSeconds(30), intervalDelta: TimeSpan.FromSeconds(2))); - static void ConfigureRabbitMqHost( + private static void ConfigureRabbitMqHost( IRabbitMqBusFactoryConfigurator busFactoryConfigurator, MassTransitConfiguration massTransitConfiguration) { @@ -126,7 +108,7 @@ namespace Musify.Infrastructure.MassTransit { options.Username(massTransitConfiguration.Username); options.Password(massTransitConfiguration.Password); - } ); + }); } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs index bb00b6e..0ecef2b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs @@ -1,7 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Logs +namespace Musify.Infrastructure.MassTransit.Logs { public record DownloadFileFromBucketLog( string Bucket, string Key, string DestinationFilePath); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs deleted file mode 100644 index 5c80cc1..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record DownloadFileFromUrlLog( - string SourceUrl, - string DestinationFilePath); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs deleted file mode 100644 index 7563dbf..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdateAlbumPictureLog( - Guid AlbumId, - string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs new file mode 100644 index 0000000..55f1fcb --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdatePicturesLog( + Guid SubjectId, + string? PreviousOriginalName, + string? PreviousSmallName, + string? PreviousMediumName, + string? PreviousLargeName); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs deleted file mode 100644 index 8135ec8..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdatePlayListPictureLog( - Guid PlayListId, - string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs index eee0342..52064ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -1,4 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs +namespace Musify.Infrastructure.MassTransit.Logs { public record UpdateTrackAudioLog( Guid TrackId, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs deleted file mode 100644 index eb8c991..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdateTrackPictureLog( - Guid TrackId, - string? PreviousOriginalPictureKey, - string? PreviousSmallPictureKey, - string? PreviousMediumPictureKey, - string? PreviousLargePictureKey); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs index eac29f1..ab95439 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs @@ -1,4 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs +namespace Musify.Infrastructure.MassTransit.Logs { public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs index beaea4f..e9d300f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs @@ -6,11 +6,7 @@ namespace Musify.Infrastructure.MassTransit public class MassTransitEventBus(IPublishEndpoint publishEndpoint) : IEventBus { - public async Task PublishAsync(T message, CancellationToken cancellationToken) where T : class - { - ArgumentNullException.ThrowIfNull(message, nameof(message)); - - await publishEndpoint.Publish(message, cancellationToken); - } + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => + publishEndpoint.Publish(message, cancellationToken); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 544388c..25cbd26 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -9,12 +9,13 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; + internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; + internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; - internal const string DownloadThumbnail = "DownloadThumbnail"; internal const string TranscodeAudio = "TranscodeAudio"; @@ -55,5 +56,8 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; + internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; + internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; + internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs deleted file mode 100644 index f19686f..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs +++ /dev/null @@ -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; - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs index 2d807b1..d4e3be2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -1,72 +1,45 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, 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.GenerateAudioWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), - new GenerateAudioWorkflowPathsArguments( - message.TrackId, - workerConfiguration.Routes.TemporaryFilesDirectory, - message.SourceKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadFile, - EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), - new DownloadFileFromBucketArguments( - message.SourceBucket, - message.SourceKey, - RoutingSlipVariableNames.Audio.SourceFilePath)); - - routingSlipBuilder.AddActivity( - ActivityNames.TranscodeAudio, - EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName), - new TranscodeAudioArguments( - message.TrackId, - RoutingSlipVariableNames.Audio.SourceFilePath, - RoutingSlipVariableNames.Workflow.TemporalDirectory)); - - routingSlipBuilder.AddActivity( - ActivityNames.TransferFiles, - EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName), - new TransferFilesToBucketArguments( - message.DestinationBucket, - RoutingSlipVariableNames.Audio.TranscodedDirectory, - message.DestinationFolderKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackAudio, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), - new UpdateTrackAudioArguments( - message.TrackId, - message.DestinationFolderKey, - RoutingSlipVariableNames.Audio.DurationSeconds)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } + public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) => + RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GenerateAudioWorkflowPaths, + GenerateAudioWorkflowPathsActivity.ExecuteEndpointName, + new GenerateAudioWorkflowPathsArguments( + message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments( + message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath)) + .AddStep( + ActivityNames.TranscodeAudio, + TranscodeAudioActivity.ExecuteEndpointName, + new TranscodeAudioArguments( + message.TrackId, + RoutingSlipVariableNames.Audio.SourceFilePath, + RoutingSlipVariableNames.Workflow.TemporalDirectory)) + .AddStep( + ActivityNames.TransferFiles, + TransferFilesToBucketActivity.ExecuteEndpointName, + new TransferFilesToBucketArguments( + message.DestinationBucket, + RoutingSlipVariableNames.Audio.TranscodedDirectory, + message.DestinationFolderKey)) + .AddStep( + ActivityNames.UpdateTrackAudio, + UpdateTrackAudioActivity.ExecuteEndpointName, + new UpdateTrackAudioArguments( + message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index fd9612f..f8ba5ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -1,11 +1,9 @@ using MassTransit; -using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { @@ -13,51 +11,36 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) + .AddStep( + ActivityNames.CopyAudioToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) + .AddStep( + ActivityNames.PublishTrackProcessingEvents, + PublishTrackProcessingEventsActivity.ExecuteEndpointName, + new PublishTrackProcessingEventsArguments( + message.TrackId, + message.Bucket, + message.PictureDestinationKey, + message.AudioDestinationKey, + message.AudioProcessedFolderKey, + message.Sizes)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); + builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); - routingSlipBuilder.AddActivity( - ActivityNames.CopyPictureToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - message.Bucket, - message.PictureSourceKey, - message.Bucket, - message.PictureDestinationKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.CopyAudioToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - message.Bucket, - message.AudioSourceKey, - message.Bucket, - message.AudioDestinationKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.ConsumeUploadIntents, - EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), - new ConsumeUploadIntentsArguments( - [message.PictureIntentId, message.AudioIntentId])); - - routingSlipBuilder.AddActivity( - ActivityNames.PublishTrackProcessingEvents, - EndpointHelper.BuildExecuteActivityUri(PublishTrackProcessingEventsActivity.ExecuteEndpointName), - new PublishTrackProcessingEventsArguments( - message.TrackId, - message.Bucket, - message.PictureDestinationKey, - message.AudioDestinationKey, - message.AudioProcessedFolderKey, - message.Small, - message.Medium, - message.Large)); - - return routingSlipBuilder; + return builder; } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs deleted file mode 100644 index 9af5547..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs +++ /dev/null @@ -1,94 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Configuration; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.PlayLists; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders -{ - public class DeletePlayListRoutingSlipBuilder( - IDatabase database, - PlayListConfiguration playListConfiguration, - ApplicationStorageConfiguration storageConfiguration) - { - public async Task BuildAsync( - Guid playListId, - long userId, - Guid? correlationId, - CancellationToken cancellationToken) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - var playList = await database.PlayLists - .AsNoTracking() - .SingleOrDefaultAsync(p => p.Id == playListId, cancellationToken); - - routingSlipBuilder.AddActivity( - ActivityNames.MarkPlayListAsRemoving, - EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), - new MarkPlayListAsRemovingArguments(playListId)); - - if (playList?.Pictures != null) - { - var bucket = storageConfiguration.Bucket; - var routes = playListConfiguration.Routes; - - if (playList.Pictures.OriginalName is { } originalName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildOriginalPicturePath(userId, originalName))); - } - - if (playList.Pictures.SmallName is { } smallName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListSmallPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildSmallPicturePath(smallName))); - } - - if (playList.Pictures.MediumName is { } mediumName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildMediumPicturePath(mediumName))); - } - - if (playList.Pictures.LargeName is { } largeName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildLargePicturePath(largeName))); - } - } - - routingSlipBuilder.AddActivity( - ActivityNames.DeletePlayListFromDb, - EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName), - new DeletePlayListFromDbArguments(playListId)); - - return routingSlipBuilder; - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs new file mode 100644 index 0000000..6e5f6e3 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs @@ -0,0 +1,136 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + /// Marks an entity as removing, deletes its files from the bucket and finally deletes its row. + public class DeleteRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) + { + private sealed record PictureStepNames(string Original, string Small, string Medium, string Large); + + private static readonly PictureStepNames AlbumSteps = new( + ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture); + + private static readonly PictureStepNames PlayListSteps = new( + ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture, + ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture); + + public async Task BuildTrackAsync( + Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkTrackAsRemoving, + MarkTrackLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing)); + + var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track != null) + { + var bucket = storageConfiguration.Bucket; + var routes = trackConfiguration.Routes; + + if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalPicture, bucket, + routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); + } + + // Unprocessed tracks still point at the shared preset pictures, which must never be deleted. + if (track.Pictures.IsProcessed) + { + builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName)); + } + + if (!string.IsNullOrEmpty(track.Audio.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalAudio, bucket, + routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName)); + } + + if (track.Audio.IsProcessed) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackProcessedAudio, bucket, + routes.BuildProcessedAudioPath(track.Audio.FolderName)); + } + } + + return builder.AddStep( + ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId)); + } + + public async Task BuildAlbumAsync( + Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkAlbumAsRemoving, + MarkAlbumLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing)); + + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == albumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures); + + return builder.AddStep( + ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId)); + } + + public async Task BuildPlayListAsync( + Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkPlayListAsRemoving, + MarkPlayListLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing)); + + var pictures = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == playListId) + .Select(p => p.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures); + + return builder.AddStep( + ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId)); + } + + private void AddPictureRemovals( + RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures) + { + if (pictures == null) + return; + + var bucket = storageConfiguration.Bucket; + + if (pictures.OriginalName is { } original) + builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original)); + if (pictures.SmallName is { } small) + builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small)); + if (pictures.MediumName is { } medium) + builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium)); + if (pictures.LargeName is { } large) + builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large)); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs deleted file mode 100644 index 35b8000..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs +++ /dev/null @@ -1,107 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Configuration; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.Tracks; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders -{ - public class DeleteTrackRoutingSlipBuilder( - IDatabase database, - TrackConfiguration trackConfiguration, - ApplicationStorageConfiguration storageConfiguration) - { - public async Task BuildAsync( - Guid trackId, - long userId, - Guid? correlationId, - CancellationToken cancellationToken) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - var track = await database.Tracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); - - routingSlipBuilder.AddActivity( - ActivityNames.MarkTrackAsRemoving, - EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName), - new MarkTrackAsRemovingArguments(trackId)); - - if (track != null) - { - var bucket = storageConfiguration.Bucket; - - if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName))); - } - - if (track.Pictures.IsProcessed) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackSmallPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildSmallPicturePath(track.Pictures.SmallName))); - - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildMediumPicturePath(track.Pictures.MediumName))); - - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildLargePicturePath(track.Pictures.LargeName))); - } - - if (!string.IsNullOrEmpty(track.Audio.OriginalName)) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackOriginalAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName))); - } - - if (track.Audio.IsProcessed) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackProcessedAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildProcessedAudioPath(track.Audio.FolderName))); - } - } - - routingSlipBuilder.AddActivity( - ActivityNames.DeleteTrackFromDb, - EndpointHelper.BuildExecuteActivityUri(DeleteTrackFromDbActivity.ExecuteEndpointName), - new DeleteTrackFromDbArguments(trackId)); - - return routingSlipBuilder; - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs new file mode 100644 index 0000000..6395130 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs @@ -0,0 +1,75 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + /// + /// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow. + /// Creation faults mark the whole entity as failed; update faults only report the picture as failed. + /// + public class PictureSourceRoutingSlipBuilder + { + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + private static RoutingSlipBuilder BuildSlip( + Guid subjectId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + Guid? correlationId, + string processKind, + string publishStepName, + string publishEndpointName, + object publishArguments) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([pictureIntentId])) + .AddStep(publishStepName, publishEndpointName, publishArguments) + .TrackFaults(subjectId, processKind); + + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + + return builder; + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index 34bf4e4..07cfcda 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -1,185 +1,87 @@ using MassTransit; -using MassTransit.Courier.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { + /// Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner. public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, - message.SourceKey, - message.Small, - message.Medium, - message.Large, - correlationId); + public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.TrackPicture); - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), - new UpdateTrackPictureArguments( - message.TrackId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.AlbumPicture); - return routingSlipBuilder; - } - - public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, - message.SourceKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdatePlayListPicture, - EndpointHelper.BuildExecuteActivityUri(UpdatePlayListPictureActivity.ExecuteEndpointName), - new UpdatePlayListPictureArguments( - message.PlayListId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.PlayListId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, - message.SourceKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateAlbumPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateAlbumPictureActivity.ExecuteEndpointName), - new UpdateAlbumPictureArguments( - message.AlbumId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.AlbumId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - RoutingSlipBuilder BuildPictureWorkflow( + private RoutingSlipBuilder BuildPictureWorkflow( + Guid subjectId, string bucket, string sourceKey, - ImageSize small, - ImageSize medium, - ImageSize large, - Guid? correlationId) + ImageSizes sizes, + Guid? correlationId, + string updateStepName, + string updateEndpointName, + string processKind) { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GeneratePictureWorkflowPaths, + GeneratePictureWorkflowPathsActivity.ExecuteEndpointName, + new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath)); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small); + AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium); + AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large); - routingSlipBuilder.AddActivity( - ActivityNames.GeneratePictureWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName), - new GeneratePictureWorkflowPathsArguments( - workerConfiguration.Routes.TemporaryFilesDirectory, - sourceKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadFile, - EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), - new DownloadFileFromBucketArguments( - bucket, - sourceKey, - RoutingSlipVariableNames.Picture.OriginalFilePath)); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeSmall, - ActivityNames.UploadSmall, - bucket, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - small); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeMedium, - ActivityNames.UploadMedium, - bucket, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - medium); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeLarge, - ActivityNames.UploadLarge, - bucket, - RoutingSlipVariableNames.Picture.LargeResizedFilePath, - large); - - return routingSlipBuilder; + return builder + .AddStep( + updateStepName, + updateEndpointName, + new UpdatePicturesArguments( + subjectId, + sourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)) + .TrackFaults(subjectId, processKind); } - static void AddResizeActivity( - RoutingSlipBuilder routingSlipBuilder, - string resizeActivityName, - string uploadActivityName, - string bucketName, - string destinationFilePathVariableName, - ImageSize imageSize) - { - routingSlipBuilder.AddActivity( - resizeActivityName, - EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName), - new ResizePictureLocalArguments( - RoutingSlipVariableNames.Picture.OriginalFilePath, - destinationFilePathVariableName, - imageSize.Width, - imageSize.Height)); - - routingSlipBuilder.AddActivity( - uploadActivityName, - EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName), - new UploadFileToBucketArguments( - destinationFilePathVariableName, - bucketName, - imageSize.SavePath)); - } + private static void AddResize( + RoutingSlipBuilder builder, + string resizeStepName, + string uploadStepName, + string bucket, + string resizedFileVariable, + ImageSize size) => + builder + .AddStep( + resizeStepName, + ResizePictureActivity.ExecuteEndpointName, + new ResizePictureLocalArguments( + RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height)) + .AddStep( + uploadStepName, + UploadFileToBucketActivity.ExecuteEndpointName, + new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath)); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs deleted file mode 100644 index f863910..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs +++ /dev/null @@ -1,85 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -using Musify.Application.Shared; -using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.PlayLists; -using Musify.Infrastructure.MassTransit.Activities.UploadIntents; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders -{ - public class PlayListPictureSourceRoutingSlipBuilder - { - public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) - => BuildSlip( - message.PlayListId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) - => BuildSlip( - message.PlayListId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - static RoutingSlipBuilder BuildSlip( - Guid playListId, - 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.PublishPlayListPictureProcessingEvent, - EndpointHelper.BuildExecuteActivityUri(PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName), - new PublishPlayListPictureProcessingEventArguments( - playListId, - bucket, - pictureDestinationKey, - small, - medium, - large)); - - return routingSlipBuilder; - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index ed2b181..6290468 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -1,4 +1,4 @@ -namespace Musify.Infrastructure.MassTransit +namespace Musify.Infrastructure.MassTransit.RoutingSlip { internal static class RoutingSlipVariableNames { @@ -8,6 +8,9 @@ namespace Musify.Infrastructure.MassTransit internal const string CorrelationId = "Workflow.CorrelationId"; internal const string SubjectId = "Workflow.SubjectId"; internal const string ProcessKind = "Workflow.ProcessKind"; + internal const string Bucket = "Workflow.Bucket"; + internal const string PictureKey = "Workflow.PictureKey"; + internal const string AudioKey = "Workflow.AudioKey"; } internal static class ProcessKinds @@ -16,6 +19,9 @@ namespace Musify.Infrastructure.MassTransit internal const string TrackAudio = "TrackAudio"; internal const string PlayListPicture = "PlayListPicture"; internal const string AlbumPicture = "AlbumPicture"; + internal const string TrackCreation = "TrackCreation"; + internal const string AlbumCreation = "AlbumCreation"; + internal const string PlayListCreation = "PlayListCreation"; } internal static class Audio diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs new file mode 100644 index 0000000..b9cd391 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs @@ -0,0 +1,49 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip +{ + /// Shared building blocks for every routing slip in the worker. + internal static class RoutingSlips + { + public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute"); + + public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}"); + + /// A new slip that carries the correlation id and always cleans its temporary directory up. + public static RoutingSlipBuilder Create(Guid? correlationId) + { + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + builder.AddSubscription( + ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + return builder; + } + + public static RoutingSlipBuilder AddStep( + this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments) + { + builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments); + return builder; + } + + /// Publishes a failure event for the subject when the slip faults. + public static RoutingSlipBuilder TrackFaults( + this RoutingSlipBuilder builder, Guid subjectId, string processKind) + { + builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId); + builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); + builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted); + return builder; + } + + public static RoutingSlipBuilder AddRemoveFile( + this RoutingSlipBuilder builder, string stepName, string bucket, string key) => + builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key)); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs index 332c90b..5c66b3e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -17,8 +17,16 @@ namespace Musify.Infrastructure.MassTransit.Sagas 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)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); Initially( When(ProcessingStarted) @@ -28,7 +36,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.Bucket = context.Message.Bucket; context.Saga.PictureKey = context.Message.PictureDestinationKey; - } ) + }) .TransitionTo(Processing)); During(Processing, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs index fa89e46..3721227 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs @@ -17,8 +17,16 @@ namespace Musify.Infrastructure.MassTransit.Sagas InstanceState(state => state.CurrentState); Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId)); - Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.PlayListId)); - Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.PlayListId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); Initially( When(ProcessingStarted) @@ -28,7 +36,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.Bucket = context.Message.Bucket; context.Saga.PictureKey = context.Message.PictureDestinationKey; - } ) + }) .TransitionTo(Processing)); During(Processing, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index 9b157fb..70c376a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -19,10 +19,26 @@ namespace Musify.Infrastructure.MassTransit.Sagas InstanceState(state => state.CurrentState); Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => AudioFailed, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); Initially( When(ProcessingStarted) @@ -33,7 +49,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas context.Saga.Bucket = context.Message.Bucket; context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.AudioKey = context.Message.AudioDestinationKey; - } ) + }) .TransitionTo(Processing)); During(Processing, @@ -42,14 +58,14 @@ namespace Musify.Infrastructure.MassTransit.Sagas { context.Saga.PictureProcessed = true; context.Saga.UpdatedAt = DateTime.UtcNow; - } ) + }) .If(context => context.Saga.AudioProcessed, binder => binder.Finalize()), When(AudioProcessed) .Then(context => { context.Saga.AudioProcessed = true; context.Saga.UpdatedAt = DateTime.UtcNow; - } ) + }) .If(context => context.Saga.PictureProcessed, binder => binder.Finalize()), When(PictureFailed) .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) diff --git a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs index 75c4903..b75d67b 100644 --- a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using Musify.Application.Configuration; using Musify.Infrastructure.Configuration; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; @@ -12,29 +12,23 @@ namespace Musify.Infrastructure.Observability { public static class OpenTelemetryDependencyInjection { - public static IServiceCollection AddObservability(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(OpenTelemetryConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors + services .AddOptions() .Configure((exporterOptions, openTelemetryConfiguration) => { exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); - } ); + }); - serviceDescriptors + services .AddOpenTelemetry() .ConfigureResource(resource => { resource.AddService("Musify"); - } ) + }) .WithTracing(tracing => tracing.AddSource("MassTransit") .AddAspNetCoreInstrumentation() @@ -54,7 +48,7 @@ namespace Musify.Infrastructure.Observability logging.AddOtlpExporter() ); - return serviceDescriptors; + return services; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 9085718..149ec10 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -1,17 +1,17 @@ +using ErrorOr; +using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -using MassTransit; +using Musify.Application.Contracts; 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; +using Npgsql; namespace Musify.Infrastructure.Persistence { public class Database(DatabaseConfiguration configuration) - : DbContext, AppIDatabase + : DbContext, Musify.Application.Contracts.IDatabase { private static readonly AuditableEntityInterceptor AuditInterceptor = new(); @@ -30,143 +30,7 @@ namespace Musify.Infrastructure.Persistence modelBuilder.AddOutboxMessageEntity(); modelBuilder.AddOutboxStateEntity(); - var trackProcessing = modelBuilder.Entity(); - trackProcessing.HasKey(state => state.CorrelationId); - trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); - - modelBuilder.Entity() - .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().Navigation(track => track.Pictures).IsRequired(); - - modelBuilder.Entity() - .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().Navigation(track => track.Audio).IsRequired(); - - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .Property(playList => playList.Visibility) - .HasDefaultValue(PlaylistVisibility.Private); - - modelBuilder.Entity() - .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().Navigation(playList => playList.Pictures).IsRequired(false); - - modelBuilder.Entity() - .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().Navigation(album => album.Pictures).IsRequired(false); - - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Album) - .WithMany(album => album.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.AlbumId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Track) - .WithMany(track => track.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(mix => new { mix.UserId, mix.Position }); - - modelBuilder.Entity() - .HasOne(item => item.Mix) - .WithMany(mix => mix.Items) - .HasForeignKey(item => item.MixId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(item => item.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); - - modelBuilder.Entity() - .HasIndex(like => new { like.UserId, like.TrackId }) - .IsUnique(); - - modelBuilder.Entity() - .HasOne(trackTag => trackTag.Track) - .WithMany(track => track.Tags) - .HasForeignKey(trackTag => trackTag.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) - .IsUnique(); - - modelBuilder.Entity() - .HasOne(follow => follow.Follower) - .WithMany() - .HasForeignKey(follow => follow.FollowerId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasOne(follow => follow.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) - .IsUnique(); - - var playListProcessing = modelBuilder.Entity(); - playListProcessing.HasKey(state => state.CorrelationId); - playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); - - var albumProcessing = modelBuilder.Entity(); - albumProcessing.HasKey(state => state.CorrelationId); - albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + ModelConfiguration.Apply(modelBuilder); } public DbSet Users => Set(); @@ -179,16 +43,12 @@ namespace Musify.Infrastructure.Persistence public DbSet AlbumHasTracks => Set(); - public DbSet UserHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); public DbSet Mixes => Set(); public DbSet MixItems => Set(); - public DbSet Uploads => Set(); - public DbSet UploadIntents => Set(); public DbSet ListeningHistories => Set(); @@ -205,6 +65,19 @@ namespace Musify.Infrastructure.Persistence return new DatabaseTransaction(transaction); } + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + return onUniqueViolation; + } + } + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction { public Task CommitAsync(CancellationToken cancellationToken) => diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index e577bc5..7b28400 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; @@ -8,21 +8,15 @@ namespace Musify.Infrastructure.Persistence { public static class DatabaseDependencyInjection { - public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + services.AddDbContext(); - serviceDescriptors.AddDbContext(); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - serviceDescriptors.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - - return serviceDescriptors; + return services; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs index 2b3cb7d..6b731c4 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; using Musify.Infrastructure.Configuration; @@ -21,10 +20,7 @@ namespace Musify.Infrastructure.Persistence .Get() ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString); - return new Database(databaseConfiguration); } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs new file mode 100644 index 0000000..c1b855b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs @@ -0,0 +1,1174 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924030938_PlayListTrackUnique")] + partial class PlayListTrackUnique + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs new file mode 100644 index 0000000..14525bd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class PlayListTrackUnique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Remove duplicate rows (keeping the lowest position) before enforcing uniqueness. + migrationBuilder.Sql(""" + DELETE FROM "PlayListHasTrack" a + USING "PlayListHasTrack" b + WHERE a."PlayListId" = b."PlayListId" + AND a."TrackId" = b."TrackId" + AND (a."Position", a."Id") > (b."Position", b."Id"); + """); + + migrationBuilder.DropIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId_TrackId", + table: "PlayListHasTrack", + columns: new[] { "PlayListId", "TrackId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PlayListHasTrack_PlayListId_TrackId", + table: "PlayListHasTrack"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack", + column: "PlayListId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs new file mode 100644 index 0000000..33f55c8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.Designer.cs @@ -0,0 +1,1133 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924031745_DropUploadTableAndTrackAudioRetryColumns")] + partial class DropUploadTableAndTrackAudioRetryColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs new file mode 100644 index 0000000..6f91664 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924031745_DropUploadTableAndTrackAudioRetryColumns.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class DropUploadTableAndTrackAudioRetryColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Upload"); + + migrationBuilder.DropColumn( + name: "DownloadRequested", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "LastRetryAt", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "RetryCount", + table: "Tracks"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DownloadRequested", + table: "Tracks", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastRetryAt", + table: "Tracks", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RetryCount", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Upload", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Bucket = table.Column(type: "text", nullable: false), + ContentType = table.Column(type: "text", nullable: false), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + Key = table.Column(type: "text", nullable: false), + State = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Upload", x => x.Id); + }); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs new file mode 100644 index 0000000..91dcf21 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs @@ -0,0 +1,1090 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924101123_DropUserHasTrack")] + partial class DropUserHasTrack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs new file mode 100644 index 0000000..cd4fd04 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class DropUserHasTrack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserHasTrack"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserHasTrack", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserHasTrack", x => x.Id); + table.ForeignKey( + name: "FK_UserHasTrack_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserHasTrack_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_TrackId", + table: "UserHasTrack", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_UserId", + table: "UserHasTrack", + column: "UserId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs new file mode 100644 index 0000000..94412dd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs @@ -0,0 +1,1084 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924101237_MixKind")] + partial class MixKind + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs new file mode 100644 index 0000000..9427cc7 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class MixKind : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Kind", + table: "Mixes", + type: "integer", + nullable: false, + defaultValue: 0); + + // Mixes used to store their Spanish title; the daily mix is kind 1, everything else is discovery. + migrationBuilder.Sql("UPDATE \"Mixes\" SET \"Kind\" = 1 WHERE \"Title\" = 'Tu mezcla diaria';"); + + migrationBuilder.DropColumn( + name: "Subtitle", + table: "Mixes"); + + migrationBuilder.DropColumn( + name: "Title", + table: "Mixes"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Kind", + table: "Mixes"); + + migrationBuilder.AddColumn( + name: "Subtitle", + table: "Mixes", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "Mixes", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index bf0434b..84961db 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -304,17 +304,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Position") + b.Property("Kind") .HasColumnType("integer"); - b.Property("Subtitle") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("Position") + .HasColumnType("integer"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -379,12 +373,13 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); - b.Property("UserId") - .HasColumnType("bigint"); - b.Property("Visibility") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -392,7 +387,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("OwnerUserId"); b.ToTable("PlayLists"); }); @@ -417,10 +412,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("PlayListId"); - b.HasIndex("TrackId"); + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + b.ToTable("PlayListHasTrack"); }); @@ -510,35 +506,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackTags"); }); - modelBuilder.Entity("Musify.Domain.Entities.Upload", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Bucket") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContentType") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Key") - .IsRequired() - .HasColumnType("text"); - - b.Property("State") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Upload"); - }); - modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => { b.Property("Id") @@ -653,27 +620,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("UserFollows"); }); - modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("TrackId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("TrackId"); - - b.HasIndex("UserId"); - - b.ToTable("UserHasTrack"); - }); - modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => { b.Property("CorrelationId") @@ -784,19 +730,17 @@ namespace Musify.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => { b1.Property("AlbumId") .HasColumnType("uuid"); b1.Property("LargeName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("LargePictureName"); b1.Property("MediumName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("MediumPictureName"); @@ -808,7 +752,6 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnName("OriginalPictureName"); b1.Property("SmallName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("SmallPictureName"); @@ -896,13 +839,13 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => { - b.HasOne("Musify.Domain.Entities.User", "User") + b.HasOne("Musify.Domain.Entities.User", "Owner") .WithMany("PlayLists") - .HasForeignKey("UserId") + .HasForeignKey("OwnerUserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => { b1.Property("PlayListId") .HasColumnType("uuid"); @@ -935,9 +878,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasForeignKey("PlayListId"); }); - b.Navigation("Pictures"); + b.Navigation("Owner"); - b.Navigation("User"); + b.Navigation("Pictures"); }); modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => @@ -972,28 +915,16 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.Property("TrackId") .HasColumnType("uuid"); - b1.Property("DownloadRequested") - .HasColumnType("boolean") - .HasColumnName("DownloadRequested"); - b1.Property("FolderName") .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("AudioFolderName"); - b1.Property("LastRetryAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("LastRetryAt"); - b1.Property("OriginalName") .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("OriginalAudioName"); - b1.Property("RetryCount") - .HasColumnType("integer") - .HasColumnName("RetryCount"); - b1.Property("TranscodeStatus") .HasColumnType("integer") .HasColumnName("AudioTranscodeProcessingStatus"); @@ -1112,25 +1043,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Follower"); }); - modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", "Track") - .WithMany("UserTracks") - .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "User") - .WithMany("UserTracks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Track"); - - b.Navigation("User"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Album", b => { b.Navigation("AlbumTracks"); @@ -1155,8 +1067,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("PlayListTracks"); b.Navigation("Tags"); - - b.Navigation("UserTracks"); }); modelBuilder.Entity("Musify.Domain.Entities.User", b => @@ -1164,8 +1074,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("ListeningHistories"); b.Navigation("PlayLists"); - - b.Navigation("UserTracks"); }); #pragma warning restore 612, 618 } diff --git a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs new file mode 100644 index 0000000..dfeac50 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Sagas; + +namespace Musify.Infrastructure.Persistence +{ + /// + /// Entity model shared by and the in-memory test database, so both always agree. + /// + public static class ModelConfiguration + { + public static void Apply(ModelBuilder modelBuilder) + { + var trackProcessing = modelBuilder.Entity(); + trackProcessing.HasKey(state => state.CorrelationId); + trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); + + modelBuilder.Entity() + .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().Navigation(track => track.Pictures).IsRequired(); + + modelBuilder.Entity() + .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"); + }); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .Property(playList => playList.Visibility) + .HasDefaultValue(PlayListVisibility.Private); + + modelBuilder.Entity() + .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().Navigation(playList => playList.Pictures).IsRequired(false); + + modelBuilder.Entity() + .OwnsOne(album => album.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + 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().Navigation(album => album.Pictures).IsRequired(false); + + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); + + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); + + var playListProcessing = modelBuilder.Entity(); + playListProcessing.HasKey(state => state.CorrelationId); + playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); + + var albumProcessing = modelBuilder.Entity(); + albumProcessing.HasKey(state => state.CorrelationId); + albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index dfe75e0..a83ddd1 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -1,66 +1,55 @@ -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.Configuration; using System.Diagnostics; using System.Globalization; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; namespace Musify.Infrastructure.Services { - public partial class AudioService(ILogger logger, + public sealed partial class AudioTranscoderService( + ILogger logger, AudioConfiguration audioConfiguration) : IAudioTranscoderService { - record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); + private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] private static partial Regex DurationRegex(); public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { - try + Directory.CreateDirectory(destinationPath); + + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + + var executionResult = await ExecuteFfmpegAsync( + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", + cancellationToken); + + if (executionResult.ExitCode != 0) { - Directory.CreateDirectory(destinationPath); - - logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); - - var executionResult = await ExecuteFfmpegWithInputAsync( - BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), - destinationPath, - audioStream, - "TranscodeToAudioFile", - cancellationToken); - - if (executionResult.ExitCode != 0) - { - logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", - executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); - return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); - } - - logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); - - var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); - var duration = await GetAudioDurationAsync(outputPath, cancellationToken); - return new AudioTranscodeResult(executionResult.ExitCode, duration); - } - catch (TimeoutException timeoutException) - { - logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioConfiguration.TranscodingTimeout, destinationPath); - throw; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to transcode audio for {DestinationPath}", destinationPath); - throw; + logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", + executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); + return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); } + + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + + var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); + var duration = await GetAudioDurationAsync(outputPath, cancellationToken); + return new AudioTranscodeResult(executionResult.ExitCode, duration); } - async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) + private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) { var executionResult = await ExecuteFfmpegAsync( BuildProbeDurationArguments(filePath), Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + input: null, "ProbeAudioDuration", cancellationToken); @@ -75,89 +64,34 @@ namespace Musify.Infrastructure.Services return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); } - public async Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken) - { - if (!File.Exists(filePath)) - { - logger.LogWarning("Audio validation requested for non-existing file {FilePath}", filePath); - return false; - } - - try - { - logger.LogDebug("Validating audio file {FilePath}", filePath); - - var executionResult = await ExecuteFfmpegAsync( - BuildValidateAudioArguments(filePath), - Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, - "ValidateAudioFile", - cancellationToken); - - var isValid = executionResult.ExitCode == 0; - if (!isValid) - { - logger.LogWarning("Audio validation failed for {FilePath}: {StandardError}", filePath, executionResult.StandardError); - } - else - { - logger.LogDebug("Audio validation successful for {FilePath}", filePath); - } - - return isValid; - } - catch (TimeoutException timeoutException) - { - logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath); - } - return false; - } - - async Task ExecuteFfmpegWithInputAsync( + private async Task ExecuteFfmpegAsync( string arguments, string workingDirectory, - Stream inputStream, + Stream? input, string operationName, CancellationToken cancellationToken) { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); process.Start(); - logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId} ) in {WorkingDirectory}", operationName, process.Id, workingDirectory); + logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - if (inputStream.CanSeek) - inputStream.Position = 0; + if (input != null) + { + if (input.CanSeek) + input.Position = 0; - await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); - process.StandardInput.Close(); + await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + } return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); } - async Task ExecuteFfmpegAsync( - string arguments, - string workingDirectory, - string operationName, - CancellationToken cancellationToken) - { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); - process.Start(); - - logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId} ) in {WorkingDirectory}", operationName, process.Id, workingDirectory); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); - } - - Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) + private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) => new() { StartInfo = new ProcessStartInfo @@ -173,7 +107,7 @@ namespace Musify.Infrastructure.Services } }; - async Task WaitForProcessResultAsync( + private async Task WaitForProcessResultAsync( Process process, Task outputTask, Task errorTask, @@ -189,36 +123,33 @@ namespace Musify.Infrastructure.Services } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId} )", - operationName, audioConfiguration.TranscodingTimeout, process.Id); - TryKillProcess(process); - throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout}."); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); } var standardError = await errorTask; var standardOutput = await outputTask; - logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId} ). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", + logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); } - void TryKillProcess(Process process) + private void TryKillProcess(Process process) { try { if (!process.HasExited) process.Kill(); } - catch (Exception exception) + catch (InvalidOperationException exception) { - logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId} )", process.Id); + logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); } } - static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) + private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) { var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName); var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments) @@ -239,10 +170,7 @@ namespace Musify.Infrastructure.Services $" \"{outputPath}\""; } - static string BuildValidateAudioArguments(string filePath) - => $"-v error -i \"{filePath}\" -map 0:a:0 -f null -"; - - static string BuildProbeDurationArguments(string filePath) + private static string BuildProbeDurationArguments(string filePath) => $"-hide_banner -i \"{filePath}\" -f null -"; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Services/PictureService.cs b/backend/Core/Musify.Infrastructure/Services/PictureService.cs index 4237519..fff427d 100644 --- a/backend/Core/Musify.Infrastructure/Services/PictureService.cs +++ b/backend/Core/Musify.Infrastructure/Services/PictureService.cs @@ -10,31 +10,23 @@ namespace Musify.Infrastructure.Services { public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - try + logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); + if (pictureStream.CanSeek && pictureStream.Position > 0) + pictureStream.Position = 0; + + using var picture = await Image.LoadAsync(pictureStream, cancellationToken); + picture.Mutate(options => options.Resize(new ResizeOptions { - logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); - if (pictureStream.CanSeek && pictureStream.Position > 0) - pictureStream.Position = 0; + Size = new Size(width, height), + Mode = ResizeMode.Max + })); - using var picture = await Image.LoadAsync(pictureStream, cancellationToken); - picture.Mutate(options => options.Resize(new ResizeOptions - { - Size = new Size(width, height), - Mode = ResizeMode.Max - } )); + var memoryStream = new MemoryStream(); + await picture.SaveAsWebpAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; - var memoryStream = new MemoryStream(); - await picture.SaveAsWebpAsync(memoryStream, cancellationToken); - memoryStream.Position = 0; - - logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); - return memoryStream; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height); - throw; - } + logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); + return memoryStream; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 734692e..9c07da3 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -3,28 +3,20 @@ using Hangfire; using Hangfire.PostgreSql; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Jobs; -using Musify.Application.Contracts; namespace Musify.Infrastructure.Services { public static class ServicesDependencyInjection { - public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddSingleton(serviceProvider => + services.AddSingleton(serviceProvider => { var configuration = serviceProvider.GetRequiredService(); var s3Configuration = new AmazonS3Config @@ -34,77 +26,51 @@ namespace Musify.Infrastructure.Services UseHttp = configuration.UseHttp, }; return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); - } ); + }); - serviceDescriptors.AddScoped(); - return serviceDescriptors; + services.AddSingleton(); + return services; } - public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddPictureService(this IServiceCollection services) { - serviceDescriptors.AddScoped(); - return serviceDescriptors; + services.AddScoped(); + return services; } - public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddJobs(this IServiceCollection services) { - serviceDescriptors.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); - - return serviceDescriptors; - } - - public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddUploadIntentConfiguration(configuration); - - serviceDescriptors.AddHostedService(); - serviceDescriptors.AddHostedService(); - - return serviceDescriptors; - } - - public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddHangfire((serviceProvider, hangfireConfiguration) => + services.AddHangfire((serviceProvider, hangfireConfiguration) => { var databaseConfiguration = serviceProvider.GetRequiredService(); hangfireConfiguration.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); }); - serviceDescriptors.AddHangfireServer(); - serviceDescriptors.AddScoped(); - serviceDescriptors.AddScoped(); + services.AddHangfireServer(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); - return serviceDescriptors; + return services; } - public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddSingleton(); - return serviceDescriptors; + services.AddSingleton(); + return services; } - public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) + public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration) { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(AudioConfiguration.SectionName)) - .ValidateDataAnnotations(); + services.AddValidatedOptions(configuration); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddScoped(); - return serviceDescriptors; + services.AddScoped(); + return services; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs b/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs deleted file mode 100644 index 97693d1..0000000 --- a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Concurrent; -using ErrorOr; -using Microsoft.Extensions.Caching.Memory; - -namespace Musify.Infrastructure.Services -{ - public sealed class SingleFlightCache(IMemoryCache cache) - { - private readonly ConcurrentDictionary inFlight = new(); - - public async Task> GetOrCreateAsync( - string key, - TimeSpan duration, - Func>> factory) - { - if (duration <= TimeSpan.Zero) - return await factory(); - - if (cache.TryGetValue(key, out TValue? cached) && cached != null) - return cached; - - var pending = (Lazy>>)inFlight.GetOrAdd( - key, - _ => new Lazy>>(factory)); - - try - { - var result = await pending.Value; - - if (!result.IsError) - cache.Set(key, result.Value, duration); - - return result; - } - finally - { - inFlight.TryRemove(key, out _); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 04a7034..5284297 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -1,62 +1,44 @@ +using System.Runtime.CompilerServices; using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Transfer; using Microsoft.Extensions.Logging; using MimeMapping; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData; namespace Musify.Infrastructure.Services { - public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger logger, + public sealed class StorageService( + IAmazonS3 amazonS3, + ILogger logger, InfrastructureStorageConfiguration storageClientConfiguration) : IStorageService { - public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var request = new GetObjectRequest - { - BucketName = bucket, - Key = key, - }; - try { - var response = await amazonS3.GetObjectAsync(request, cancellationToken); + var response = await amazonS3.GetObjectAsync( + new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); return response.ResponseStream; } - catch (Exception exception) + catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey") { - logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); - throw; + logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); + return null; } } - public async Task GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken) - { - var request = new GetPreSignedUrlRequest - { - BucketName = bucket, - Key = key, - Expires = DateTime.UtcNow + expirationTime, - Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS - }; - - logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key); - return await amazonS3.GetPreSignedURLAsync(request); - } - public async Task GetUploadUrlAsync( string bucket, string key, string contentType, TimeSpan expirationTime, - CancellationToken cancellationToken, - bool preventOverwrite = true) + bool preventOverwrite = true, + CancellationToken cancellationToken = default) { var request = new GetPreSignedUrlRequest { @@ -82,167 +64,81 @@ namespace Musify.Infrastructure.Services var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); } - catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } - catch (Exception exception) - { - logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); - throw; - } } public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var request = new DeleteObjectRequest - { - BucketName = bucket, - Key = key, - }; - logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); - await amazonS3.DeleteObjectAsync(request, cancellationToken); + await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); } - public async Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) + public async Task> TransferFilesAsync( + string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) { - var trasnsferUtility = new TransferUtility(amazonS3); - - var files = Directory.GetFiles(sourceDirectory, "*"); + using var transferUtility = new TransferUtility(amazonS3); var uploadedKeys = new List(); - var successCount = 0; - var failedCount = 0; - foreach (var file in files) + foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) { - string fileName = Path.GetFileName(file); - string filePath = Path.Combine(sourceDirectory, file); + var key = StorageKey.Combine(route, Path.GetFileName(filePath)); - var key = string.Join('/', new[] { route, fileName } - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); - var contentType = MimeUtility.GetMimeMapping(filePath); - var upload = new Upload - { - Id = Guid.NewGuid(), - Bucket = bucket, - Key = key, - ContentType = contentType - }; - await database.Uploads.AddAsync(upload, cancellationToken); + await transferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = MimeUtility.GetMimeMapping(filePath) + }, + cancellationToken); - try - { - await trasnsferUtility.UploadAsync( - new TransferUtilityUploadRequest - { - FilePath = filePath, - BucketName = bucket, - Key = key, - ContentType = contentType - }, - cancellationToken); - upload.State = UploadState.Successful; - uploadedKeys.Add(key); - successCount++; - logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); - } - catch (Exception exception) - { - failedCount++; - upload.State = UploadState.Failed; - logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key); - throw; - } + uploadedKeys.Add(key); + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); } - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount); + logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); return uploadedKeys; } public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) { - try - { - using var fileStream = File.OpenRead(filePath); - var contentType = MimeUtility.GetMimeMapping(filePath); - await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); - throw; - } + await using var fileStream = File.OpenRead(filePath); + await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); } public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) { - var request = new PutObjectRequest - { - BucketName = bucket, - ContentType = contentType, - InputStream = sourceStream, - Key = key - }; - - var upload = new Upload - { - Id = Guid.NewGuid(), - Bucket = bucket, - Key = key, - ContentType = contentType - }; - await database.Uploads.AddAsync(upload, cancellationToken); - - try - { - var response = await amazonS3.PutObjectAsync(request, cancellationToken); - logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); - upload.State = UploadState.Failed; - await database.SaveChangesAsync(cancellationToken); - throw; - } - - upload.State = UploadState.Successful; - await database.SaveChangesAsync(cancellationToken); - logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key); + await amazonS3.PutObjectAsync( + new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, + cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); } - public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, - CancellationToken cancellationToken) + public async Task CopyFileAsync( + string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken) { - var request = new CopyObjectRequest - { - SourceBucket = sourceBucket, - SourceKey = sourceKey, - DestinationBucket = destinationBucket, - DestinationKey = destinationKey - }; + await amazonS3.CopyObjectAsync( + new CopyObjectRequest + { + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey + }, + cancellationToken); - try - { - var response = await amazonS3.CopyObjectAsync(request, cancellationToken); - logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - throw; - } + logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); } public async IAsyncEnumerable ListObjectsAsync( - string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) { var listRequest = new ListObjectsV2Request { @@ -254,51 +150,12 @@ namespace Musify.Infrastructure.Services do { listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); - foreach (var s3Object in listResponse.S3Objects ?? [] ) + foreach (var s3Object in listResponse.S3Objects ?? []) yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime()); listRequest.ContinuationToken = listResponse.NextContinuationToken; } - while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); - } - - public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken) - { - var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/"; - var listRequest = new ListObjectsV2Request - { - BucketName = bucket, - Prefix = prefix - }; - - ListObjectsV2Response listResponse; - do - { - listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); - - if (listResponse.S3Objects == null|| listResponse.S3Objects.Count == 0) - break; - - var deleteRequest = new DeleteObjectsRequest - { - BucketName = bucket, - Objects = listResponse.S3Objects - .Select(o => new KeyVersion { Key = o.Key } ) - .ToList() - }; - - var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); - - var deleteErrors = deleteResponse.DeleteErrors ?? []; - if (deleteErrors.Count > 0) - { - var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")); - throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); - } - - listRequest.ContinuationToken = listResponse.NextContinuationToken; - - } while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + while (listResponse.IsTruncated == true); } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index 67d6c16..575fad7 100644 --- a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -30,9 +31,9 @@ namespace Musify.Infrastructure.Services var claims = new Dictionary { ["prefix"] = keyPrefix }; if (userId != null) - claims["sub"] = userId.Value.ToString(); + claims["sub"] = userId.Value.ToString(CultureInfo.InvariantCulture); if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); var descriptor = new SecurityTokenDescriptor { diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index b818461..1bd9491 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -4,6 +4,11 @@ net10.0 enable enable + latest-recommended + true + true + true + $(NoWarn);CS1591 diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index aa0fa86..eb7442c 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -45,8 +45,6 @@ - - diff --git a/backend/Hosts/Musify.Api/Api.csproj b/backend/Hosts/Musify.Api/Api.csproj index b693556..ed556be 100644 --- a/backend/Hosts/Musify.Api/Api.csproj +++ b/backend/Hosts/Musify.Api/Api.csproj @@ -8,29 +8,12 @@ - - - - - - - - - - - - - - - - - diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs index 5646260..8c3f4c1 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs @@ -1,39 +1,41 @@ using System.ComponentModel.DataAnnotations; +using Musify.Application.Configuration; -namespace Musify.Api.Authentication; - -public sealed class AuthenticationConfiguration +namespace Musify.Api.Authentication { - public const string SectionName = "Authentication"; + public sealed class AuthenticationConfiguration : IConfigurationOptions + { + public static string SectionName => "Authentication"; - [Required] - [Url] - public required string MetadataAddress { get; set; } + [Required] + [Url] + public required string MetadataAddress { get; set; } - [Required] - public required string IssuerAddress { get; set; } + [Required] + public required string IssuerAddress { get; set; } - [Required] - public required string AudienceAddress { get; set; } + [Required] + public required string AudienceAddress { get; set; } - [Required] - public required string ClientId { get; set; } + [Required] + public required string ClientId { get; set; } - public string? ClientSecret { get; set; } + public string? ClientSecret { get; set; } - [Required] - [Url] - public required string AuthorizationEndpoint { get; set; } + [Required] + [Url] + public required string AuthorizationEndpoint { get; set; } - [Required] - [Url] - public required string TokenEndpoint { get; set; } + [Required] + [Url] + public required string TokenEndpoint { get; set; } - [Required] - [MinLength(1)] - public required string[] Scopes { get; set; } + [Required] + [MinLength(1)] + public required string[] Scopes { get; set; } - public string? ScalarRedirectUri { get; set; } + public string? ScalarRedirectUri { get; set; } - public bool RequireHttpsMetadata { get; set; } = true; + public bool RequireHttpsMetadata { get; set; } = true; + } } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs index 45a31b9..7cc0362 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -1,33 +1,28 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; +using Musify.Application.Configuration; -namespace Musify.Api.Authentication; - -public static class AuthenticationExtensions +namespace Musify.Api.Authentication { - public static IServiceCollection AddAuthenticationConfiguration( - this IServiceCollection services, - IConfiguration configuration) + public static class AuthenticationExtensions { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName)) - .ValidateDataAnnotations(); + public static IServiceCollection AddAuthenticationConfiguration( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddScoped(); + services.ConfigureOptions(); - services.AddMemoryCache(); - services.AddHttpClient(); - services.AddScoped(); - services.ConfigureOptions(); + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(); + services.AddAuthorization(); - services.AddAuthorization(); - - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index 04ea670..f0f949a 100644 --- a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -1,42 +1,36 @@ using System.Reflection; using System.Security.Claims; -namespace Musify.Api.Authentication; - -public sealed class CurrentUser : IBindableFromHttpContext +namespace Musify.Api.Authentication { - private CurrentUser(ClaimsPrincipal principal) + public sealed class CurrentUser : IBindableFromHttpContext { - Principal = principal; - IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; + private CurrentUser(ClaimsPrincipal principal) + { + IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (long.TryParse(rawId, out var id) && id != 0) - Id = id; + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (long.TryParse(rawId, out var id) && id != 0) + Id = id; - Username = principal.FindFirstValue(ClaimTypes.Name); - FirstName = principal.FindFirstValue(ClaimTypes.GivenName); - LastName = principal.FindFirstValue(ClaimTypes.Surname); + Username = principal.FindFirstValue(ClaimTypes.Name); + FirstName = principal.FindFirstValue(ClaimTypes.GivenName); + LastName = principal.FindFirstValue(ClaimTypes.Surname); + } + + public bool IsAuthenticated { get; } + + public long? Id { get; } + + public string? Username { get; } + + public string? FirstName { get; } + + public string? LastName { get; } + + public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); + + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => + ValueTask.FromResult(new CurrentUser(context.User)); } - - public ClaimsPrincipal Principal { get; } - - public bool IsAuthenticated { get; } - - public long? Id { get; } - - public string? Username { get; } - - public string? FirstName { get; } - - public string? LastName { get; } - - public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); - - public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type); - - public string? FindClaim(string type) => Principal.FindFirstValue(type); - - public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => - ValueTask.FromResult(new CurrentUser(context.User)); -} \ No newline at end of file +} diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index b8cd402..c4d003b 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -1,127 +1,142 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json.Serialization; using Mediator; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Caching.Memory; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Musify.Application.Users; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Security.Claims; -using System.Text.Json.Serialization; -namespace Musify.Api.Authentication; - -public sealed class JwtBearerEventsHandler( - IMediator mediator, - IMemoryCache cache, - IHttpClientFactory httpClientFactory, - AuthenticationConfiguration authenticationConfiguration, - ILogger logger) : JwtBearerEvents +namespace Musify.Api.Authentication { - private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - - private sealed record UserInfoResponse( - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("preferred_username")] string? PreferredUsername, - [property: JsonPropertyName("email")] string? Email, - [property: JsonPropertyName("given_name")] string? GivenName, - [property: JsonPropertyName("family_name")] string? FamilyName, - [property: JsonPropertyName("picture")] string? Picture); - - public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) + public sealed class JwtBearerEventsHandler( + IMediator mediator, + IMemoryCache cache, + IHttpClientFactory httpClientFactory, + AuthenticationConfiguration authenticationConfiguration, + ILogger logger) : JwtBearerEvents { - var principal = tokenValidatedContext.Principal!; + private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (!long.TryParse(rawId, out var userId)) + private sealed record UserInfoResponse( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("preferred_username")] string? PreferredUsername, + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("given_name")] string? GivenName, + [property: JsonPropertyName("family_name")] string? FamilyName, + [property: JsonPropertyName("picture")] string? Picture); + + public override async Task TokenValidated(TokenValidatedContext context) { - logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); - return; - } + var principal = context.Principal!; - var cacheKey = $"user-synced:{userId}"; - if (cache.TryGetValue(cacheKey, out _)) - return; - - var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted); - - var username = userInfo?.Name - ?? userInfo?.PreferredUsername - ?? principal.FindFirstValue("name") - ?? principal.FindFirstValue(ClaimTypes.Name) - ?? principal.FindFirstValue("preferred_username") - ?? userInfo?.Email - ?? principal.FindFirstValue("email") - ?? principal.FindFirstValue(ClaimTypes.Email) - ?? rawId!; - - var firstName = userInfo?.GivenName - ?? principal.FindFirstValue("given_name") - ?? principal.FindFirstValue(ClaimTypes.GivenName); - var lastName = userInfo?.FamilyName - ?? principal.FindFirstValue("family_name") - ?? principal.FindFirstValue(ClaimTypes.Surname); - var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); - - try - { - await mediator.Send( - new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), - tokenValidatedContext.HttpContext.RequestAborted); - - cache.Set(cacheKey, true, UserSyncCacheTtl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); - } - } - - private async Task FetchUserInfoAsync( - TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) - { - var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString(); - if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return null; - - var accessToken = authorizationHeader["Bearer ".Length..].Trim(); - if (accessToken.Length == 0) - return null; - - try - { - var client = httpClientFactory.CreateClient(); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo") + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(rawId, out var userId)) { - Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } - }; - - var response = await client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); - return null; + logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); + return; } - return await response.Content.ReadFromJsonAsync(cancellationToken); + var cacheKey = $"user-synced:{userId}"; + if (cache.TryGetValue(cacheKey, out _)) + return; + + var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted); + + var username = userInfo?.Name + ?? userInfo?.PreferredUsername + ?? principal.FindFirstValue("name") + ?? principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("preferred_username") + ?? userInfo?.Email + ?? principal.FindFirstValue("email") + ?? principal.FindFirstValue(ClaimTypes.Email) + ?? rawId!; + + var firstName = userInfo?.GivenName + ?? principal.FindFirstValue("given_name") + ?? principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = userInfo?.FamilyName + ?? principal.FindFirstValue("family_name") + ?? principal.FindFirstValue(ClaimTypes.Surname); + var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); + + try + { + await mediator.Send( + new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), + context.HttpContext.RequestAborted); + + cache.Set(cacheKey, true, UserSyncCacheTtl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); + } } - catch (Exception exception) + + private async Task FetchUserInfoAsync( + TokenValidatedContext context, CancellationToken cancellationToken) { - logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); - return null; + var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); + if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var accessToken = authorizationHeader["Bearer ".Length..].Trim(); + if (accessToken.Length == 0) + return null; + + try + { + var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken); + + var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } + }; + + using var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); + return null; + } } - } - public override Task AuthenticationFailed(AuthenticationFailedContext context) - { - logger.LogWarning(context.Exception, "JWT authentication failed"); - return Task.CompletedTask; - } + private async Task ResolveUserInfoEndpointAsync( + TokenValidatedContext context, CancellationToken cancellationToken) + { + var configurationManager = context.Options.ConfigurationManager; + if (configurationManager != null) + { + var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); + if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) + return endpoint; + } - public override Task Challenge(JwtBearerChallengeContext context) - { - if (context.AuthenticateFailure != null) - logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); - return Task.CompletedTask; + return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; + } + + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + logger.LogWarning(context.Exception, "JWT authentication failed"); + return Task.CompletedTask; + } + + public override Task Challenge(JwtBearerChallengeContext context) + { + if (context.AuthenticateFailure != null) + logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); + return Task.CompletedTask; + } } } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs index c2b0ffe..c46dc02 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs @@ -1,37 +1,36 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -using System.Security.Claims; -namespace Musify.Api.Authentication; - -public sealed class JwtBearerOptionsSetup(IOptions options) - : IConfigureNamedOptions +namespace Musify.Api.Authentication { - private readonly AuthenticationConfiguration _configuration = options.Value; - - public void Configure(string? name, JwtBearerOptions options) + public sealed class JwtBearerOptionsSetup(IOptions options) + : IConfigureNamedOptions { - if (name != JwtBearerDefaults.AuthenticationScheme) + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(string? name, JwtBearerOptions options) { - return; + if (name != JwtBearerDefaults.AuthenticationScheme) + return; + + Configure(options); } - Configure(options); - } - - public void Configure(JwtBearerOptions options) - { - options.MetadataAddress = _configuration.MetadataAddress; - options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata; - options.Audience = _configuration.AudienceAddress; - options.TokenValidationParameters = new TokenValidationParameters + public void Configure(JwtBearerOptions options) { - ValidIssuer = _configuration.IssuerAddress, - ValidAudience = _configuration.AudienceAddress, - RoleClaimType = ClaimTypes.Role, - NameClaimType = ClaimTypes.Name, - }; - options.EventsType = typeof(JwtBearerEventsHandler); + options.MetadataAddress = configuration.MetadataAddress; + options.RequireHttpsMetadata = configuration.RequireHttpsMetadata; + options.Audience = configuration.AudienceAddress; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = configuration.IssuerAddress, + ValidAudience = configuration.AudienceAddress, + RoleClaimType = ClaimTypes.Role, + NameClaimType = ClaimTypes.Name, + }; + options.EventsType = typeof(JwtBearerEventsHandler); + } } } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs new file mode 100644 index 0000000..3c25332 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects +{ + public record AddTracksRequest(IReadOnlyList TrackIds); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs index fa3292e..e8e8852 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs @@ -1,7 +1,8 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record CreateAlbumRequest( - string Title, - string? Description, - int? ReleaseYear, - Guid PictureIntentId); +namespace Musify.Api.DataTransferObjects.Albums +{ + public record CreateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs deleted file mode 100644 index a1fb913..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record RequestAlbumPictureUploadRequest( - string FileType, - string ContentType, - long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index d01812e..1d6d03d 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -1,7 +1,8 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record UpdateAlbumRequest( - string NewTitle, - string? NewDescription, - int? NewReleaseYear, - Guid? NewPictureIntentId); +namespace Musify.Api.DataTransferObjects.Albums +{ + public record UpdateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid? PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs index 2ce34e2..d397a17 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects.Likes; - -public record ToggleLikeRequest(Guid TrackId); +namespace Musify.Api.DataTransferObjects.Likes +{ + public record ToggleLikeRequest(Guid TrackId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs new file mode 100644 index 0000000..54ea555 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs @@ -0,0 +1,7 @@ +namespace Musify.Api.DataTransferObjects +{ + public record PictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs deleted file mode 100644 index d000ed6..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record AddPlayListTrackRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs index bc5910e..8cafadb 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -1,9 +1,10 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record CreatePlayListRequest( - string Name, - string? Description, - Guid? PictureIntentId, - PlaylistVisibility Visibility = PlaylistVisibility.Private); +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record CreatePlayListRequest( + string Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility Visibility = PlayListVisibility.Private); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs deleted file mode 100644 index a72e25c..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record RequestPlayListPictureUploadRequest( - string FileType, - string ContentType, - long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index 0ccf8fc..a49124c 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -1,9 +1,10 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record UpdatePlayListRequest( - string? NewName, - string? NewDescription, - Guid? NewPictureIntentId, - PlaylistVisibility? NewVisibility = null); +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record UpdatePlayListRequest( + string? Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility? Visibility = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 5e95895..2180bec 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -1,10 +1,11 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.Tracks; - -public record CreateTrackRequest( - string Title, - Guid PictureIntentId, - Guid AudioIntentId, - IReadOnlyCollection Tags, - bool IsExplicit = false); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record CreateTrackRequest( + string Title, + Guid PictureIntentId, + Guid AudioIntentId, + IReadOnlyCollection Tags, + bool IsExplicit = false); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs index 32eff3c..951a990 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects.Tracks; - -public record RecordListeningProgressRequest(double PlayedSeconds); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record RecordListeningProgressRequest(double PlayedSeconds); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs index bd65125..dc7b887 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs @@ -1,9 +1,10 @@ -namespace Musify.Api.DataTransferObjects.Tracks; - -public record RequestTrackUploadUrlsRequest( - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, - long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record RequestTrackUploadUrlsRequest( + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs deleted file mode 100644 index 9a6155c..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Api.DataTransferObjects.Users; - -public record CreateUserRequest( - long Id, - string Name, - string? FirstName, - string? SecondName); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index fb02cb4..9fdd920 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -1,262 +1,276 @@ using Mediator; using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; using Musify.Application.Albums; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Pictures.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class AlbumEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) + public static class AlbumEndpoints { - var group = app.MapGroup("/albums") - .WithTags("Albums"); + private const int MaxRecentAlbumsLimit = 50; - group.MapGet("/", GetAlbums) - .WithName("GetAlbums") - .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") - .Produces(); + public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/albums") + .WithTags("Albums"); - group.MapGet("/{id}", GetAlbumById) - .WithName("GetAlbumById") - .WithSummary("Get An Album By Id.") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/", GetAlbums) + .AddEndpointFilter>() + .WithName("GetAlbums") + .WithSummary("Get paginated albums, optionally filtered by title") + .Produces>(); - group.MapGet("/users/{userId}", GetAlbumsByUserId) - .WithName("GetAlbumsByUserId") - .WithSummary("Get Paginated Albums For A User.") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetAlbumById) + .WithName("GetAlbumById") + .WithSummary("Get an album by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/recent", GetRecentlyListenedAlbums) - .WithName("GetRecentlyListenedAlbums") - .WithSummary("Get The Albums The Current User Listened To Most Recently.") - .RequireAuthorization() - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/users/{userId:long}", GetAlbumsByUserId) + .AddEndpointFilter>() + .WithName("GetAlbumsByUserId") + .WithSummary("Get paginated albums for a user") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{albumId}/tracks", GetAlbumTracks) - .WithName("GetAlbumTracks") - .WithSummary("Get The Tracks Of An Album Ordered By Track Number.") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/recent", GetRecentlyListenedAlbums) + .WithName("GetRecentlyListenedAlbums") + .WithSummary("Get the albums the current user listened to most recently") + .RequireAuthorization() + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapGet("/{id}/cover", GetAlbumCover) - .WithName("GetAlbumCover") - .WithSummary("Get An Album Cover Image.") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/tracks", GetAlbumTracks) + .AddEndpointFilter>() + .WithName("GetAlbumTracks") + .WithSummary("Get the tracks of an album ordered by track number") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/upload-picture", RequestAlbumPictureUpload) - .WithName("RequestAlbumPictureUpload") - .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetAlbumCover) + .WithName("GetAlbumCover") + .WithSummary("Get an album cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/", CreateAlbum) - .WithName("CreateAlbum") - .WithSummary("Create A New Album.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/upload-picture", RequestAlbumPictureUpload) + .WithName("RequestAlbumPictureUpload") + .WithSummary("Request a pre-signed URL to upload an album picture") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{albumId}", UpdateAlbum) - .WithName("UpdateAlbum") - .WithSummary("Update An Album.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreateAlbum) + .WithName("CreateAlbum") + .WithSummary("Create a new album") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{albumId}", DeleteAlbum) - .WithName("DeleteAlbum") - .WithSummary("Delete An Album.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPut("/{id:guid}", UpdateAlbum) + .WithName("UpdateAlbum") + .WithSummary("Update an album") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum) - .WithName("AddTrackToAlbum") - .WithSummary("Add One Of Your Uploaded Tracks To An Album.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapDelete("/{id:guid}", DeleteAlbum) + .WithName("DeleteAlbum") + .WithSummary("Delete an album") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{albumId}/tracks/{trackId}", RemoveTrackFromAlbum) - .WithName("RemoveTrackFromAlbum") - .WithSummary("Remove A Track From An Album.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/{id:guid}/tracks", AddAlbumTracks) + .WithName("AddAlbumTracks") + .WithSummary("Add your uploaded tracks to an album, skipping the ones it already has") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - return app; - } + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum) + .WithName("RemoveTrackFromAlbum") + .WithSummary("Remove a track from an album") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - private static async Task GetAlbums( - IMediator mediator, - CancellationToken cancellationToken, - string? title, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + return app; + } - private static async Task GetAlbumById( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetAlbums( + IMediator mediator, + string? title, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } - private static async Task GetAlbumsByUserId( - IMediator mediator, - long userId, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetAlbumById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetRecentlyListenedAlbums( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken, - int limit = 12) - { - var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetAlbumsByUserId( + IMediator mediator, + long userId, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetAlbumTracks( - IMediator mediator, - Guid albumId, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 50) - { - var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetRecentlyListenedAlbums( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int limit = 12) + { + if (limit is < 1 or > MaxRecentAlbumsLimit) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] + }); + } - private static async Task GetAlbumCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - var result = await mediator.Send(new GetAlbumCoverQuery(id, size), cancellationToken); - if (result.IsError) - return Results.NotFound(); + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); + return Results.Ok(result); + } - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); - } + private static async Task GetAlbumTracks( + IMediator mediator, + Guid id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestAlbumPictureUpload( - IMediator mediator, - CurrentUser currentUser, - RequestAlbumPictureUploadRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - cancellationToken); + private static Task GetAlbumCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); + } - return result.ToHttpResult(); - } + private static async Task RequestAlbumPictureUpload( + IMediator mediator, + CurrentUser currentUser, + PictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); - private static async Task CreateAlbum( - IMediator mediator, - CurrentUser currentUser, - CreateAlbumRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), - cancellationToken); + return result.ToHttpResult(); + } - return result.ToCreatedResult(album => $"/albums/{album.Id}"); - } + private static async Task CreateAlbum( + IMediator mediator, + CurrentUser currentUser, + CreateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); - private static async Task UpdateAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid albumId, - UpdateAlbumRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear, request.NewPictureIntentId), - cancellationToken); + return result.ToCreatedResult(album => $"/albums/{album.Id}"); + } - return result.ToHttpResult(); - } + private static async Task UpdateAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); - private static async Task DeleteAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid albumId, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, albumId), cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task AddTrackToAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid albumId, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId), - cancellationToken); + private static async Task DeleteAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - return result.ToHttpResult(); - } + private static async Task AddAlbumTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + AddTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), + cancellationToken); - private static async Task RemoveTrackFromAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid albumId, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RemoveTrackFromAlbumCommand(currentUser.RequiredId, albumId, trackId), - cancellationToken); + return result.ToHttpResult(); + } - return result.ToHttpResult(); + private static async Task RemoveTrackFromAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId), + cancellationToken); + + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs index ed4220a..78f8474 100644 --- a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs @@ -2,27 +2,29 @@ using Mediator; using Musify.Application.Configuration; using Musify.Application.Configuration.Responses; -namespace Musify.Api.Endpoints; - -public static class ConfigEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + public static class ConfigEndpoints { - var group = app.MapGroup("/config") - .WithTags("Config"); + public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/config") + .WithTags("Config"); - group.MapGet("/playback", GetPlaybackPublicConfig) - .WithName("GetPlaybackPublicConfig") - .WithSummary("Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously.") - .Produces(); + group.MapGet("/playback", GetPlaybackPublicConfig) + .WithName("GetPlaybackPublicConfig") + .WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously") + .Produces(); - return app; - } + return app; + } - private static async Task GetPlaybackPublicConfig( - IMediator mediator, - CancellationToken cancellationToken) - { - return await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + private static async Task GetPlaybackPublicConfig( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + return Results.Ok(result); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs new file mode 100644 index 0000000..474a699 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs @@ -0,0 +1,32 @@ +using Mediator; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Pictures; + +namespace Musify.Api.Endpoints +{ + internal static class CoverEndpoint + { + public static async Task Stream( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + CoverOwner owner, + Guid id, + string size, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken); + if (result.IsError) + return Results.NotFound(); + + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs index da31c5b..3b99a5f 100644 --- a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -1,39 +1,43 @@ using Mediator; using Musify.Application.Genres; +using Musify.Application.Genres.Responses; -namespace Musify.Api.Endpoints; - -public static class GenreEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + public static class GenreEndpoints { - var group = app.MapGroup("/genres") - .WithTags("Genres"); + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/genres") + .WithTags("Genres"); - group.MapGet("/", GetGenres) - .WithName("GetGenres") - .WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.") - .Produces>(); + group.MapGet("/", GetGenres) + .WithName("GetGenres") + .WithSummary("Get the genres that have at least one track, most populated first. Works anonymously") + .Produces>(); - group.MapGet("/available", GetAvailableGenres) - .WithName("GetAvailableGenres") - .WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.") - .Produces>(); + group.MapGet("/available", GetAvailableGenres) + .WithName("GetAvailableGenres") + .WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously") + .Produces>(); - return app; - } + return app; + } - private static async Task> GetAvailableGenres( - IMediator mediator, - CancellationToken cancellationToken) - { - return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); - } + private static async Task GetAvailableGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + return Results.Ok(result); + } - private static async Task> GetGenres( - IMediator mediator, - CancellationToken cancellationToken) - { - return await mediator.Send(new GetGenresQuery(), cancellationToken); + private static async Task GetGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetGenresQuery(), cancellationToken); + return Results.Ok(result); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index ac74df6..f2c1a9c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -1,59 +1,61 @@ using Mediator; -using Musify.Application.Likes; -using Musify.Application.Shared; -using Musify.Application.Tracks.Responses; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Likes; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; +using Musify.Application.Likes; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class LikeEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + public static class LikeEndpoints { - var group = app.MapGroup("/likes") - .WithTags("Likes") - .RequireAuthorization(); + public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/likes") + .WithTags("Likes") + .RequireAuthorization(); - group.MapGet("/", GetLikedTracks) - .WithName("GetLikedTracks") - .WithSummary("Get The Current User'S Liked Tracks.") - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/", GetLikedTracks) + .AddEndpointFilter>() + .WithName("GetLikedTracks") + .WithSummary("Get the current user's liked tracks") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/toggle", ToggleTrackLike) - .WithName("ToggleTrackLike") - .WithSummary("Like Or Unlike A Track By Id.") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/toggle", ToggleTrackLike) + .WithName("ToggleTrackLike") + .WithSummary("Like or unlike a track by id") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetLikedTracks( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetLikedTracks( + IMediator mediator, + CurrentUser currentUser, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } - private static async Task ToggleTrackLike( - IMediator mediator, - CurrentUser currentUser, - ToggleLikeRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), - cancellationToken); + private static async Task ToggleTrackLike( + IMediator mediator, + CurrentUser currentUser, + ToggleLikeRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs index 41a79cd..1924e2c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -4,48 +4,49 @@ using Musify.Api.Extensions; using Musify.Application.Mixes; using Musify.Application.Mixes.Responses; -namespace Musify.Api.Endpoints; - -public static class MixEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + public static class MixEndpoints { - var group = app.MapGroup("/mixes") - .WithTags("Mixes") - .RequireAuthorization(); + public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/mixes") + .WithTags("Mixes") + .RequireAuthorization(); - group.MapGet("/", GetMixes) - .WithName("GetMixes") - .WithSummary("Get The Server-Generated Mixes Of The Current User.") - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/", GetMixes) + .WithName("GetMixes") + .WithSummary("Get the server-generated mixes of the current user") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapGet("/{id}", GetMixById) - .WithName("GetMixById") - .WithSummary("Get A Mix With All Of Its Songs.") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetMixById) + .WithName("GetMixById") + .WithSummary("Get a mix with all of its songs") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetMixes( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetMixes( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); + return Results.Ok(result); + } - private static async Task GetMixById( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); + private static async Task GetMixById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 7ea110b..0976c59 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -1,272 +1,259 @@ using Mediator; -using Musify.Application.PlayLists; -using Musify.Application.PlayLists.Responses; -using Musify.Application.Tracks; -using Musify.Application.Tracks.Responses; -using Musify.Application.Shared; -using Musify.Application.Contracts; using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; +using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Pictures.Responses; +using Musify.Application.PlayLists; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class PlayListEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + public static class PlayListEndpoints { - var group = app.MapGroup("/playlists") - .WithTags("PlayLists"); + public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/playlists") + .WithTags("PlayLists"); - group.MapGet("/", GetPlayLists) - .WithName("GetPlayLists") - .WithSummary("Get Paginated PlayLists.") - .Produces>(); + group.MapGet("/{id:guid}", GetPlayListById) + .WithName("GetPlayListById") + .WithSummary("Get a playlist by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}", GetPlayListById) - .WithName("GetPlayListById") - .WithSummary("Get A PlayList By Id.") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/users/{userId:long}", GetPlayListsByUserId) + .AddEndpointFilter>() + .WithName("GetPlayListsByUserId") + .WithSummary("Get paginated playlists for a user. Private playlists are only returned to their owner") + .Produces>(); - group.MapGet("/users/{userId}", GetPlayListsByUserId) - .WithName("GetPlayListsByUserId") - .WithSummary("Get Paginated PlayLists For A User.") - .Produces>(); + group.MapGet("/{id:guid}/tracks", GetPlayListTracks) + .AddEndpointFilter>() + .WithName("GetPlayListTracks") + .WithSummary("Get paginated tracks of a playlist") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{playlistId}/tracks", GetPlayListTracks) - .WithName("GetPlayListTracks") - .WithSummary("Get Paginated Tracks Of A PlayList.") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetPlayListCover) + .WithName("GetPlayListCover") + .WithSummary("Get a playlist cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/cover", GetPlayListCover) - .WithName("GetPlayListCover") - .WithSummary("Get A PlayList Cover Image.") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreatePlayList) + .WithName("CreatePlayList") + .WithSummary("Create a new playlist") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/", CreatePlayList) - .WithName("CreatePlayList") - .WithSummary("Create A New PlayList.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPut("/{id:guid}", UpdatePlayList) + .WithName("UpdatePlayList") + .WithSummary("Update an existing playlist") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{playlistId}", UpdatePlayList) - .WithName("UpdatePlayList") - .WithSummary("Update An Existing PlayList.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}", DeletePlayList) + .WithName("DeletePlayList") + .WithSummary("Delete a playlist") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{playlistId}", DeletePlayList) - .WithName("DeletePlayList") - .WithSummary("Delete A PlayList.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/upload-picture", RequestPlayListPictureUpload) + .WithName("RequestPlayListPictureUpload") + .WithSummary("Request a pre-signed URL to upload a playlist picture") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/upload-picture", RequestPlayListPictureUpload) - .WithName("RequestPlayListPictureUpload") - .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/{id:guid}/tracks", AddPlayListTracks) + .WithName("AddPlayListTracks") + .WithSummary("Add tracks to a playlist, skipping the ones it already has") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapPost("/{playlistId}/tracks", AddPlayListTrack) - .WithName("AddPlayListTrack") - .WithSummary("Add One Of Your Tracks To A PlayList.") - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/{id:guid}/albums/{albumId:guid}", AddAlbumToPlayList) + .WithName("AddAlbumToPlayList") + .WithSummary("Add all the tracks of an album to a playlist, skipping the ones it already has") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList) - .WithName("AddAlbumToPlayList") - .WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList) + .WithName("RemoveTrackFromPlayList") + .WithSummary("Remove a track from a playlist") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) - .WithName("RemoveTrackFromPlayList") - .WithSummary("Remove A Track From A PlayList.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + return app; + } - return app; - } + private static async Task GetPlayListById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetPlayLists( - IMediator mediator, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetPlayListsByUserId( + IMediator mediator, + CurrentUser currentUser, + long userId, + string? name, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetPlayListById( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetPlayListTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetPlayListsByUserId( - IMediator mediator, - long userId, - CancellationToken cancellationToken, - string? name, - int pageNumber = 1, - int pageSize = 10, - bool onlyPublic = false) - { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken); - return result.ToHttpResult(); - } + private static Task GetPlayListCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); + } - private static async Task GetPlayListTracks( - IMediator mediator, - Guid playlistId, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task CreatePlayList( + IMediator mediator, + CurrentUser currentUser, + CreatePlayListRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), + cancellationToken); - private static async Task GetPlayListCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - var result = await mediator.Send(new GetPlayListCoverQuery(id, size), cancellationToken); - if (result.IsError) - return Results.NotFound(); + return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); + } - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); - } + private static async Task UpdatePlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdatePlayListRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), + cancellationToken); - private static async Task CreatePlayList( - IMediator mediator, - CurrentUser currentUser, - CreatePlayListRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), - cancellationToken); + return result.ToHttpResult(); + } - return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); - } + private static async Task DeletePlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task UpdatePlayList( - IMediator mediator, - CurrentUser currentUser, - Guid playlistId, - UpdatePlayListRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId, request.NewVisibility), - cancellationToken); + private static async Task RequestPlayListPictureUpload( + IMediator mediator, + CurrentUser currentUser, + PictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task DeletePlayList( - IMediator mediator, - CurrentUser currentUser, - Guid playlistId, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, playlistId), cancellationToken); - return result.ToHttpResult(); - } + private static async Task AddPlayListTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + AddTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), + cancellationToken); - private static async Task RequestPlayListPictureUpload( - IMediator mediator, - CurrentUser currentUser, - RequestPlayListPictureUploadRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - cancellationToken); + return result.ToHttpResult(); + } - return result.ToHttpResult(); - } + private static async Task AddAlbumToPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid albumId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddAlbumToPlayListCommand(currentUser.RequiredId, id, albumId), + cancellationToken); - private static async Task AddPlayListTrack( - IMediator mediator, - CurrentUser currentUser, - Guid playlistId, - AddPlayListTrackRequest request, - CancellationToken cancellationToken) - { - var addResult = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId), - cancellationToken); - if (addResult.IsError) - return addResult.ToHttpResult(); + return result.ToHttpResult(); + } - var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken); - return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); - } + private static async Task RemoveTrackFromPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId), + cancellationToken); - private static async Task AddAlbumToPlayList( - IMediator mediator, - CurrentUser currentUser, - Guid playlistId, - Guid albumId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId), - cancellationToken); - - return result.ToHttpResult(); - } - - private static async Task RemoveTrackFromPlayList( - IMediator mediator, - CurrentUser currentUser, - Guid playlistId, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RemoveTrackFromPlayListCommand(currentUser.RequiredId, playlistId, trackId), - cancellationToken); - - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index eaa8325..7a15b54 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,223 +1,206 @@ using Mediator; -using Musify.Domain.ValueObjects; -using Musify.Application.Tracks; -using Musify.Application.Tracks.Responses; -using Musify.Application.Shared; -using Musify.Application.Contracts; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; +using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Shared; +using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class TrackEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) + public static class TrackEndpoints { - var group = app.MapGroup("/tracks") - .WithTags("Tracks"); - - group.MapGet("/", GetTracks) - .WithName("GetTracks") - .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") - .Produces() - .ProducesValidationProblem(); - - group.MapGet("/{id}", GetTrackById) - .WithName("GetTrackById") - .WithSummary("Get A Track By Id.") - .Produces() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/{id}/cover", GetTrackCover) - .WithName("GetTrackCover") - .WithSummary("Get A Track Cover Image.") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/{id}/stream", GetTrackStream) - .WithName("GetTrackStream") - .WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapPut("/listens/{listenId}/progress", RecordListeningProgress) - .WithName("RecordListeningProgress") - .WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/users/{userId}", GetTracksByUserId) - .WithName("GetTracksByUserId") - .WithSummary("Get Paginated Tracks For A User.") - .Produces>(); - - group.MapPost("/", CreateTrack) - .WithName("CreateTrack") - .WithSummary("Create A New Track.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - group.MapDelete("/{trackId}", DeleteTrack) - .WithName("DeleteTrack") - .WithSummary("Delete A Track.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - group.MapPost("/upload-urls", RequestTrackUploadUrls) - .WithName("RequestTrackUploadUrls") - .WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - return app; - } - - private static async Task GetTracks( - IMediator mediator, - CancellationToken cancellationToken, - string? name, - string? genre, - int pageNumber = 1, - int pageSize = 10) - { - Genre? parsedGenre = null; - if (!string.IsNullOrWhiteSpace(genre)) + public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) { - if (!Enum.TryParse(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value)) - { - return Results.ValidationProblem(new Dictionary - { - [nameof(genre)] = [$"'{genre}' is not a valid genre."] - }); - } + var group = app.MapGroup("/tracks") + .WithTags("Tracks"); - parsedGenre = value; + group.MapGet("/", GetTracks) + .AddEndpointFilter>() + .WithName("GetTracks") + .WithSummary("Get paginated tracks, optionally filtered by name and genre") + .Produces>() + .ProducesValidationProblem(); + + group.MapGet("/{id:guid}", GetTrackById) + .WithName("GetTrackById") + .WithSummary("Get a track by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id:guid}/cover", GetTrackCover) + .WithName("GetTrackCover") + .WithSummary("Get a track cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id:guid}/stream", GetTrackStream) + .WithName("GetTrackStream") + .WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress) + .AddEndpointFilter>() + .WithName("RecordListeningProgress") + .WithSummary("Report the total seconds actually played for a listen started by the stream endpoint") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/users/{userId:long}", GetTracksByUserId) + .AddEndpointFilter>() + .WithName("GetTracksByUserId") + .WithSummary("Get paginated tracks for a user") + .Produces>(); + + group.MapPost("/", CreateTrack) + .WithName("CreateTrack") + .WithSummary("Create a new track") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapDelete("/{id:guid}", DeleteTrack) + .WithName("DeleteTrack") + .WithSummary("Delete a track") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapPost("/upload-urls", RequestTrackUploadUrls) + .WithName("RequestTrackUploadUrls") + .WithSummary("Request pre-signed URLs to upload track picture and audio") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + return app; } - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTracks( + IMediator mediator, + string? name, + GenreParameter? genre, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); + return Results.Ok(result); + } - private static async Task GetTrackById( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTrackById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetTrackCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken); - if (result.IsError) - return Results.NotFound(); + private static Task GetTrackCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); + } - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); - } + private static async Task GetTrackStream( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetTrackStream( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task RecordListeningProgress( + IMediator mediator, + CurrentUser currentUser, + Guid listenId, + RecordListeningProgressRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), + cancellationToken); + return result.ToHttpResult(); + } - private static async Task RecordListeningProgress( - IMediator mediator, - CurrentUser currentUser, - Guid listenId, - RecordListeningProgressRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), - cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTracksByUserId( + IMediator mediator, + long userId, + string? name, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetTracksByUserId( - IMediator mediator, - long userId, - CancellationToken cancellationToken, - string? name, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task CreateTrack( + IMediator mediator, + CurrentUser currentUser, + CreateTrackRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), + cancellationToken); - private static async Task CreateTrack( - IMediator mediator, - CurrentUser currentUser, - CreateTrackRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), - cancellationToken); + return result.ToCreatedResult(track => $"/tracks/{track.Id}"); + } - return result.ToCreatedResult(track => $"/tracks/{track.Id}"); - } + private static async Task DeleteTrack( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task DeleteTrack( - IMediator mediator, - CurrentUser currentUser, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken); - return result.ToHttpResult(); - } + private static async Task RequestTrackUploadUrls( + IMediator mediator, + CurrentUser currentUser, + RequestTrackUploadUrlsRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestTrackUploadUrlsCommand( + currentUser.RequiredId, + request.PictureFileType, + request.PictureContentType, + request.AudioFileType, + request.AudioContentType, + request.ExpectedPictureSizeBytes, + request.ExpectedAudioSizeBytes), + cancellationToken); - private static async Task RequestTrackUploadUrls( - IMediator mediator, - CurrentUser currentUser, - RequestTrackUploadUrlsRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestTrackUploadUrlsCommand( - currentUser.RequiredId, - request.PictureFileType, - request.PictureContentType, - request.AudioFileType, - request.AudioContentType, - request.ExpectedPictureSizeBytes, - request.ExpectedAudioSizeBytes), - cancellationToken); - - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 63448eb..bbd8e72 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,222 +1,213 @@ using Mediator; using Musify.Api.Authentication; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Users; using Musify.Application.Users.Responses; -namespace Musify.Api.Endpoints; - -public static class UserEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + public static class UserEndpoints { - var group = app.MapGroup("/users") - .WithTags("Users"); + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/users") + .WithTags("Users"); - group.MapGet("/", GetUsers) - .WithName("GetUsers") - .WithSummary("Get Paginated Users.") - .Produces>(); + group.MapGet("/", GetUsers) + .AddEndpointFilter>() + .WithName("GetUsers") + .WithSummary("Get paginated users") + .Produces>(); - group.MapGet("/{id}", GetUserById) - .WithName("GetUserById") - .WithSummary("Get A User By Id.") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}", GetUserById) + .WithName("GetUserById") + .WithSummary("Get a user by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/listening-history", GetListeningHistory) - .WithName("GetListeningHistory") - .WithSummary("Get A User'S Listening History.") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/listening-history", GetListeningHistory) + .WithName("GetListeningHistory") + .WithSummary("Get a user's listening history") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/listening-stats", GetListeningStats) - .WithName("GetListeningStats") - .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") - .Produces(); + group.MapGet("/{id:long}/listening-stats", GetListeningStats) + .WithName("GetListeningStats") + .WithSummary("Get a user's listening stats for the current week and streak") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); - group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId) - .WithName("GetLastTrackListenedByUserId") - .WithSummary("Get The Last Track A User Listened To.") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId) + .WithName("GetLastTrackListenedByUserId") + .WithSummary("Get the last track a user listened to") + .Produces() + .Produces(StatusCodes.Status204NoContent); - group.MapGet("/{id}/profile", GetUserProfile) - .WithName("GetUserProfile") - .WithSummary("Get A User'S Public Profile, Including Follower Counts.") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/profile", GetUserProfile) + .WithName("GetUserProfile") + .WithSummary("Get a user's public profile, including follower counts") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/followers", GetFollowers) - .WithName("GetUserFollowers") - .WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.") - .Produces>() - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/followers", GetFollowers) + .AddEndpointFilter>() + .WithName("GetUserFollowers") + .WithSummary("Get the users following a user. Visible to the user and to mutual followers") + .Produces>() + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/following", GetFollowing) - .WithName("GetUserFollowing") - .WithSummary("Get The Users A User Follows.") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/following", GetFollowing) + .AddEndpointFilter>() + .WithName("GetUserFollowing") + .WithSummary("Get the users a user follows") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id}/is-following", IsFollowing) - .WithName("IsFollowingUser") - .WithSummary("Check Whether The Current User Follows Another User.") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/{id:long}/is-following", IsFollowing) + .WithName("IsFollowingUser") + .WithSummary("Check whether the current user follows another user") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/{id}/follow", FollowUser) - .WithName("FollowUser") - .WithSummary("Follow A User.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .ProducesValidationProblem(); + group.MapPost("/{id:long}/follow", FollowUser) + .WithName("FollowUser") + .WithSummary("Follow a user") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); - group.MapDelete("/{id}/follow", UnfollowUser) - .WithName("UnfollowUser") - .WithSummary("Unfollow A User.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized); + group.MapDelete("/{id:long}/follow", UnfollowUser) + .WithName("UnfollowUser") + .WithSummary("Unfollow a user") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/", CreateUser) - .WithName("CreateUser") - .WithSummary("Create A New User.") - .AddEndpointFilter>() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status409Conflict); + return app; + } - return app; - } + private static async Task GetUsers( + IMediator mediator, + CurrentUser currentUser, + string? usernameSearch, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetUsers( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken, - string? usernameSearch, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetUserById( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetUserById( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetListeningHistory( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); + return Results.Ok(result); + } - private static async Task> GetListeningHistory( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); - } + private static async Task GetListeningStats( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + if (currentUser.RequiredId != id) + return Results.Problem(statusCode: StatusCodes.Status403Forbidden); - private static async Task GetListeningStats( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); - } + var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetLastTrackListenedByUserId( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetLastTrackListenedByUserId( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return track == null ? Results.NoContent() : Results.Ok(track); + } - private static async Task GetUserProfile( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetUserProfile( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetFollowers( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 20) - { - var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetFollowers( + IMediator mediator, + CurrentUser currentUser, + long id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetFollowing( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 20) - { - var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task IsFollowing( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - return await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); - } + private static async Task IsFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + return Results.Ok(result); + } - private static async Task FollowUser( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task FollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task UnfollowUser( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task CreateUser( - IMediator mediator, - CreateUserRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), - cancellationToken); - - return result.ToCreatedResult(user => $"/users/{user.Id}"); + private static async Task UnfollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs index 33d2da5..4e5d2f2 100644 --- a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs @@ -1,43 +1,44 @@ using ErrorOr; using IResult = Microsoft.AspNetCore.Http.IResult; -namespace Musify.Api.Extensions; - -public static class ErrorOrHttpExtensions +namespace Musify.Api.Extensions { - public static IResult ToHttpResult(this ErrorOr result) + public static class ErrorOrHttpExtensions { - if (result.IsError) - return Problem(result.Errors); - - return result.Value is Success - ? Results.NoContent() - : Results.Ok(result.Value); - } - - public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => - result.IsError - ? Problem(result.Errors) - : Results.Created(locationFactory(result.Value), result.Value); - - private static IResult Problem(List errors) - { - if (errors.All(e => e.Type == ErrorType.Validation)) - return Results.ValidationProblem(ToValidationDictionary(errors)); - - var error = errors[0]; - return error.Type switch + public static IResult ToHttpResult(this ErrorOr result) { - ErrorType.NotFound => Results.NotFound(error.Description), - ErrorType.Unauthorized => Results.Unauthorized(), - ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden), - ErrorType.Conflict => Results.Conflict(error.Description), - _ => Results.Problem(error.Description) - }; - } + if (result.IsError) + return Problem(result.Errors); - private static Dictionary ToValidationDictionary(IEnumerable errors) => - errors - .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) - .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); + return result.Value is Success + ? Results.NoContent() + : Results.Ok(result.Value); + } + + public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => + result.IsError + ? Problem(result.Errors) + : Results.Created(locationFactory(result.Value), result.Value); + + private static IResult Problem(List errors) + { + if (errors.All(e => e.Type == ErrorType.Validation)) + return Results.ValidationProblem(ToValidationDictionary(errors)); + + var error = errors[0]; + return error.Type switch + { + ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), + ErrorType.Unauthorized => Results.Unauthorized(), + ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), + ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), + _ => Results.Problem(error.Description) + }; + } + + private static Dictionary ToValidationDictionary(IEnumerable errors) => + errors + .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) + .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); + } } diff --git a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs index 1ceb89e..b5e0084 100644 --- a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,29 +1,30 @@ using FluentValidation; -using Musify.Infrastructure.MassTransit; -using Musify.Infrastructure.Persistence; -using Musify.Application; -using Musify.Infrastructure.Services; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.OpenApi; using Musify.Api.Scalar; +using Musify.Application; +using Musify.Infrastructure.MassTransit; +using Musify.Infrastructure.Persistence; +using Musify.Infrastructure.Services; -namespace Musify.Api.Extensions; - -public static class ServiceCollectionExtensions +namespace Musify.Api.Extensions { - public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + public static class ServiceCollectionExtensions { - services.AddApplication(configuration); - services.AddDatabase(configuration); - services.AddStorageService(configuration); - services.AddStreamTicketService(configuration); - services.AddMassTransitClient(configuration); - services.AddAuthenticationConfiguration(configuration); - services.AddOpenApiConfiguration(); - services.AddScalarConfiguration(); - services.AddValidatorsFromAssemblyContaining(); + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddApplication(configuration); + services.AddDatabase(configuration); + services.AddStorageService(configuration); + services.AddStreamTicketService(configuration); + services.AddMassTransitClient(configuration); + services.AddAuthenticationConfiguration(configuration); + services.AddOpenApiConfiguration(); + services.AddScalarConfiguration(); + services.AddValidatorsFromAssemblyContaining(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs index cd66124..3aeb2bd 100644 --- a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs +++ b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs @@ -1,20 +1,21 @@ using FluentValidation; -namespace Musify.Api.Filters; - -public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class +namespace Musify.Api.Filters { - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class { - var argument = context.Arguments.OfType().FirstOrDefault(); + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var argument = context.Arguments.OfType().FirstOrDefault(); - if (argument == null) - return await next(context); + if (argument == null) + return await next(context); - var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); + var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); - return result.IsValid - ? await next(context) - : Results.ValidationProblem(result.ToDictionary()); + return result.IsValid + ? await next(context) + : Results.ValidationProblem(result.ToDictionary()); + } } -} \ No newline at end of file +} diff --git a/backend/Hosts/Musify.Api/Models/GenreParameter.cs b/backend/Hosts/Musify.Api/Models/GenreParameter.cs new file mode 100644 index 0000000..e866882 --- /dev/null +++ b/backend/Hosts/Musify.Api/Models/GenreParameter.cs @@ -0,0 +1,20 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Api.Models +{ + /// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. + public readonly record struct GenreParameter(Genre Value) + { + public static bool TryParse(string? value, out GenreParameter result) + { + if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + { + result = new GenreParameter(genre); + return true; + } + + result = default; + return false; + } + } +} diff --git a/backend/Hosts/Musify.Api/Models/PageQuery.cs b/backend/Hosts/Musify.Api/Models/PageQuery.cs new file mode 100644 index 0000000..2c21750 --- /dev/null +++ b/backend/Hosts/Musify.Api/Models/PageQuery.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; +using Musify.Application.Shared; + +namespace Musify.Api.Models +{ + /// Query-string binding for , keeping the camelCase parameter names of the API. + public record PageQuery( + [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, + [property: FromQuery(Name = "pageSize")] int PageSize = 20); +} diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs index 6f00c38..8f1751c 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs @@ -1,12 +1,13 @@ -namespace Musify.Api.OpenApi; - -public static class OpenApiExtensions +namespace Musify.Api.OpenApi { - public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + public static class OpenApiExtensions { - services.AddOpenApi(); - services.ConfigureOptions(); + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(); + services.ConfigureOptions(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index ed9a5af..acc2c72 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -3,76 +3,90 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; -using Musify.Application.Serialization; -namespace Musify.Api.OpenApi; - -public sealed class OpenApiOptionsSetup(IOptions options) - : IConfigureNamedOptions +namespace Musify.Api.OpenApi { - private const string SecuritySchemeId = "OAuth2"; - - private readonly AuthenticationConfiguration _configuration = options.Value; - - public void Configure(string? name, OpenApiOptions options) => Configure(options); - - public void Configure(OpenApiOptions options) + public sealed class OpenApiOptionsSetup(IOptions options) + : IConfigureNamedOptions { - options.AddDocumentTransformer((document, _, _) => + internal const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(string? name, OpenApiOptions options) => Configure(options); + + public void Configure(OpenApiOptions options) { - document.Components ??= new OpenApiComponents(); - document.Components.SecuritySchemes ??= new Dictionary(); - document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme + options.AddDocumentTransformer((document, _, _) => { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme { - AuthorizationCode = new OpenApiOAuthFlow + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows { - AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint), - TokenUrl = new Uri(_configuration.TokenEndpoint), - Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope), + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint), + TokenUrl = new Uri(configuration.TokenEndpoint), + Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope), + }, }, - }, - }; + }; - return Task.CompletedTask; - } ); - - options.AddSchemaTransformer((schema, context, _) => - { - if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) - { - schema.Type = JsonSchemaType.String; - schema.Format = null; - } - - return Task.CompletedTask; - } ); - - options.AddOperationTransformer((operation, context, _) => - { - var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata - .OfType() - .Any(); - - var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata - .OfType() - .Any(); - - if (!requiresAuthorization || allowsAnonymous) - { return Task.CompletedTask; - } + }); - operation.Security ??= new List(); - operation.Security.Add(new OpenApiSecurityRequirement + options.AddSchemaTransformer((schema, context, _) => { - [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = - _configuration.Scopes.ToList(), - } ); + // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). + // Request DTOs only carry byte sizes, which may be sent as numbers or strings. + if (context.JsonPropertyInfo is { } property + && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) + { + var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; + schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; + if (property.PropertyType == typeof(long?)) + schema.Type |= JsonSchemaType.Null; + schema.Format = isRequest ? "int64" : null; + } - return Task.CompletedTask; - } ); + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, context, _) => + { + // Route parameters constrained with :long are user ids, which travel as strings like every other long. + foreach (var parameter in operation.Parameters?.OfType() ?? []) + { + if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema) + { + schema.Type = JsonSchemaType.String; + schema.Format = null; + } + } + + var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (!requiresAuthorization || allowsAnonymous) + return Task.CompletedTask; + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = + configuration.Scopes.ToList(), + }); + + return Task.CompletedTask; + }); + } } } diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 7e8772b..fd874db 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -1,25 +1,31 @@ using System.Text.Json.Serialization; -using Musify.Infrastructure.Observability; -using Scalar.AspNetCore; using Musify.Api.Endpoints; using Musify.Api.Extensions; +using Musify.Infrastructure.Observability; +using Scalar.AspNetCore; -var builder = WebApplication.CreateBuilder(); +const string DevCorsPolicy = "dev-cors"; + +var builder = WebApplication.CreateBuilder(args); builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); - -const string devCorsPolicy = "dev-cors"; +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.NumberHandling = + JsonNumberHandling.AllowReadingFromString | + JsonNumberHandling.WriteAsString; +} ); builder.Services .AddApplicationServices(builder.Configuration) .AddObservability(builder.Configuration); builder.Services.AddProblemDetails(); +builder.Services.Configure(options => options.ThrowOnBadRequest = false); if (builder.Environment.IsDevelopment()) { - builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy => + builder.Services.AddCors(options => options.AddPolicy(DevCorsPolicy, policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); } @@ -29,7 +35,7 @@ app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) { - app.UseCors(devCorsPolicy); + app.UseCors(DevCorsPolicy); app.MapOpenApi(); app.MapScalarApiReference(); } @@ -49,4 +55,4 @@ app app.Run(); -public partial class Program; \ No newline at end of file +public partial class Program; diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs index 13f2db8..f1aba32 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs @@ -1,11 +1,12 @@ -namespace Musify.Api.Scalar; - -public static class ScalarExtensions +namespace Musify.Api.Scalar { - public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + public static class ScalarExtensions { - services.ConfigureOptions(); + public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + { + services.ConfigureOptions(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs index 2ee2c0a..6f33eef 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs @@ -1,32 +1,32 @@ using Microsoft.Extensions.Options; -using Scalar.AspNetCore; using Musify.Api.Authentication; +using Musify.Api.OpenApi; +using Scalar.AspNetCore; -namespace Musify.Api.Scalar; - -public sealed class ScalarOptionsSetup(IOptions options) - : IConfigureOptions +namespace Musify.Api.Scalar { - private const string SecuritySchemeId = "OAuth2"; - - private readonly AuthenticationConfiguration _configuration = options.Value; - - public void Configure(ScalarOptions scalarOptions) + public sealed class ScalarOptionsSetup(IOptions options) + : IConfigureOptions { - scalarOptions - .WithTitle("Musify API") - .AddPreferredSecuritySchemes(SecuritySchemeId) - .AddAuthorizationCodeFlow(SecuritySchemeId, flow => - { - flow.ClientId = _configuration.ClientId; - flow.ClientSecret = _configuration.ClientSecret; - flow.AuthorizationUrl = _configuration.AuthorizationEndpoint; - flow.TokenUrl = _configuration.TokenEndpoint; - flow.Pkce = Pkce.Sha256; - flow.SelectedScopes = _configuration.Scopes; - flow.RedirectUri = _configuration.ScalarRedirectUri; - flow.RefreshUrl = _configuration.TokenEndpoint; - } ) - .HideModels(); + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(ScalarOptions scalarOptions) + { + scalarOptions + .WithTitle("Musify API") + .AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId) + .AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow => + { + flow.ClientId = configuration.ClientId; + flow.ClientSecret = configuration.ClientSecret; + flow.AuthorizationUrl = configuration.AuthorizationEndpoint; + flow.TokenUrl = configuration.TokenEndpoint; + flow.Pkce = Pkce.Sha256; + flow.SelectedScopes = configuration.Scopes; + flow.RedirectUri = configuration.ScalarRedirectUri; + flow.RefreshUrl = configuration.TokenEndpoint; + }) + .HideModels(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs new file mode 100644 index 0000000..92f8990 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects; + +namespace Musify.Api.Validators +{ + public sealed class AddTracksRequestValidator : AbstractValidator + { + public const int MaxTracksPerRequest = 500; + + public AddTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs b/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs deleted file mode 100644 index 222c8be..0000000 --- a/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Api.Validators.Albums; - -public static class AlbumReleaseYear -{ - public const int Earliest = 1877; - - public static int Latest => DateTime.UtcNow.Year + 1; -} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs index 10550eb..f65887d 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -1,24 +1,25 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; -namespace Musify.Api.Validators.Albums; - -public sealed class CreateAlbumRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Albums { - public CreateAlbumRequestValidator() + public sealed class CreateAlbumRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(200); + public CreateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.Description) - .MaximumLength(256); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.ReleaseYear) - .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) - .When(x => x.ReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); - RuleFor(x => x.PictureIntentId) - .NotEmpty(); + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs deleted file mode 100644 index d747056..0000000 --- a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Albums; - -namespace Musify.Api.Validators.Albums; - -public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator -{ - public RequestAlbumPictureUploadRequestValidator() - { - RuleFor(x => x.FileType) - .NotEmpty(); - - RuleFor(x => x.ContentType) - .NotEmpty(); - - RuleFor(x => x.ExpectedSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedSizeBytes.HasValue); - } -} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs index 122a906..e9b6458 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs @@ -1,21 +1,22 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; -namespace Musify.Api.Validators.Albums; - -public sealed class UpdateAlbumRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Albums { - public UpdateAlbumRequestValidator() + public sealed class UpdateAlbumRequestValidator : AbstractValidator { - RuleFor(x => x.NewTitle) - .NotEmpty() - .MaximumLength(200); + public UpdateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.NewDescription) - .MaximumLength(256); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.NewReleaseYear) - .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) - .When(x => x.NewReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Limits.cs b/backend/Hosts/Musify.Api/Validators/Limits.cs new file mode 100644 index 0000000..30f91fb --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Limits.cs @@ -0,0 +1,13 @@ +namespace Musify.Api.Validators +{ + public static class Limits + { + public const int TrackTitle = 200; + public const int AlbumTitle = 200; + public const int PlayListName = 50; + public const int Description = 256; + public const int EarliestReleaseYear = 1877; + + public static int LatestReleaseYear => DateTime.UtcNow.Year + 1; + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs new file mode 100644 index 0000000..c144a7d --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using Musify.Api.Models; +using Musify.Application.Shared; + +namespace Musify.Api.Validators +{ + public sealed class PageQueryValidator : AbstractValidator + { + public PageQueryValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1); + + RuleFor(x => x.PageSize) + .InclusiveBetween(1, PageRequest.MaxPageSize); + } + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs new file mode 100644 index 0000000..d574a85 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects; +using Musify.Application.Configuration; + +namespace Musify.Api.Validators +{ + public sealed class PictureUploadRequestValidator : AbstractValidator + { + public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) + { + RuleFor(x => x.FileType) + .MustBeFileType(Uploads.PictureFileTypes); + + RuleFor(x => x.ContentType) + .MustBeContentType(Uploads.PictureContentTypes); + + RuleFor(x => x.ExpectedSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + } + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index b0003b7..f2d854c 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -1,17 +1,21 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; -namespace Musify.Api.Validators.PlayLists; - -public sealed class CreatePlayListRequestValidator : AbstractValidator +namespace Musify.Api.Validators.PlayLists { - public CreatePlayListRequestValidator() + public sealed class CreatePlayListRequestValidator : AbstractValidator { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(50); + public CreatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(Limits.PlayListName); - RuleFor(x => x.Description) - .MaximumLength(256); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); + + RuleFor(x => x.Visibility) + .IsInEnum(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs deleted file mode 100644 index 94c7529..0000000 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.PlayLists; - -namespace Musify.Api.Validators.PlayLists; - -public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator -{ - public RequestPlayListPictureUploadRequestValidator() - { - RuleFor(x => x.FileType) - .NotEmpty(); - - RuleFor(x => x.ContentType) - .NotEmpty(); - - RuleFor(x => x.ExpectedSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedSizeBytes.HasValue); - } -} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index c886937..fd1116a 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -1,19 +1,24 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; -namespace Musify.Api.Validators.PlayLists; - -public sealed class UpdatePlayListRequestValidator : AbstractValidator +namespace Musify.Api.Validators.PlayLists { - public UpdatePlayListRequestValidator() + public sealed class UpdatePlayListRequestValidator : AbstractValidator { - RuleFor(x => x.NewName) - .NotEmpty() - .MaximumLength(50) - .When(x => x.NewName != null); + public UpdatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(Limits.PlayListName) + .When(x => x.Name != null); - RuleFor(x => x.NewDescription) - .MaximumLength(256) - .When(x => x.NewDescription != null); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description) + .When(x => x.Description != null); + + RuleFor(x => x.Visibility) + .IsInEnum() + .When(x => x.Visibility != null); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index 6584cff..f9d9876 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -2,33 +2,34 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; using Musify.Domain.ValueObjects; -namespace Musify.Api.Validators.Tracks; - -public sealed class CreateTrackRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public CreateTrackRequestValidator() + public sealed class CreateTrackRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(200); + public CreateTrackRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.TrackTitle); - RuleFor(x => x.PictureIntentId) - .NotEmpty(); + RuleFor(x => x.PictureIntentId) + .NotEmpty(); - RuleFor(x => x.AudioIntentId) - .NotEmpty(); + RuleFor(x => x.AudioIntentId) + .NotEmpty(); - RuleFor(x => x.Tags) - .NotEmpty() - .WithMessage("At least one tag is required."); + RuleFor(x => x.Tags) + .NotEmpty() + .WithMessage("At least one tag is required."); - RuleFor(x => x.Tags) - .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) - .When(x => x.Tags is { Count: > 0 }) - .WithMessage(x => - { - var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); - return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; - }); + RuleFor(x => x.Tags) + .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) + .When(x => x.Tags is { Count: > 0 }) + .WithMessage(x => + { + var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); + return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; + }); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs new file mode 100644 index 0000000..96e152e --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks +{ + public sealed class RecordListeningProgressRequestValidator : AbstractValidator + { + public RecordListeningProgressRequestValidator() + { + RuleFor(x => x.PlayedSeconds) + .Must(seconds => double.IsFinite(seconds) && seconds >= 0) + .WithMessage("'Played Seconds' must be a non-negative number."); + } + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs index e84a60b..3e78902 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -1,30 +1,30 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; +using Musify.Application.Configuration; -namespace Musify.Api.Validators.Tracks; - -public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public RequestTrackUploadUrlsRequestValidator() + public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator { - RuleFor(x => x.PictureFileType) - .NotEmpty(); + public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) + { + RuleFor(x => x.PictureFileType) + .MustBeFileType(Uploads.PictureFileTypes); - RuleFor(x => x.PictureContentType) - .NotEmpty(); + RuleFor(x => x.PictureContentType) + .MustBeContentType(Uploads.PictureContentTypes); - RuleFor(x => x.AudioFileType) - .NotEmpty(); + RuleFor(x => x.AudioFileType) + .MustBeFileType(Uploads.AudioFileTypes); - RuleFor(x => x.AudioContentType) - .NotEmpty(); + RuleFor(x => x.AudioContentType) + .MustBeContentType(Uploads.AudioContentTypes); - RuleFor(x => x.ExpectedPictureSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedPictureSizeBytes.HasValue); + RuleFor(x => x.ExpectedPictureSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); - RuleFor(x => x.ExpectedAudioSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedAudioSizeBytes.HasValue); + RuleFor(x => x.ExpectedAudioSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Uploads.cs b/backend/Hosts/Musify.Api/Validators/Uploads.cs new file mode 100644 index 0000000..c5ca018 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Uploads.cs @@ -0,0 +1,43 @@ +using FluentValidation; + +namespace Musify.Api.Validators +{ + public static class Uploads + { + public static readonly IReadOnlySet PictureFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; + + public static readonly IReadOnlySet PictureContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; + + public static readonly IReadOnlySet AudioFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; + + public static readonly IReadOnlySet AudioContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", + "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" + }; + + public static IRuleBuilderOptions MustBeFileType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Length(1, 10) + .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") + .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); + + public static IRuleBuilderOptions MustBeContentType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); + + public static IRuleBuilderOptions MustBeValidUploadSize( + this IRuleBuilder rule, long maxUploadBytes) => + rule + .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) + .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs deleted file mode 100644 index bd8de92..0000000 --- a/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Users; - -namespace Musify.Api.Validators.Users; - -public sealed class CreateUserRequestValidator : AbstractValidator -{ - public CreateUserRequestValidator() - { - RuleFor(x => x.Id) - .NotEmpty(); - - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(48); - - RuleFor(x => x.FirstName) - .MaximumLength(48) - .When(x => !string.IsNullOrWhiteSpace(x.FirstName)); - - RuleFor(x => x.SecondName) - .MaximumLength(48) - .When(x => !string.IsNullOrWhiteSpace(x.SecondName)); - } -} diff --git a/backend/Hosts/Musify.Api/appsettings.json b/backend/Hosts/Musify.Api/appsettings.json index 4c0c5aa..c981d4a 100644 --- a/backend/Hosts/Musify.Api/appsettings.json +++ b/backend/Hosts/Musify.Api/appsettings.json @@ -59,37 +59,37 @@ "PlayList": { "Routes": { "UploadsFolder": "uploads", - "ParentFolders": "PlayLists", + "ParentFolder": "PlayLists", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "Album": { "Routes": { "UploadsFolder": "uploads", - "ParentFolders": "Albums", + "ParentFolder": "Albums", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "Track": { @@ -105,28 +105,15 @@ "PresetMediumPicture": "PresetMediumPicture.webp", "PresetLargePicture": "PresetLargePicture.webp", "OriginalAudiosFolder": "OriginalAudios", - "ProcessedAudioFolder": "ProcessedAudios" + "ProcessedAudiosFolder": "ProcessedAudios" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 - } - }, - "AudioTranscoder": { - "TranscodingTimeout": "00:05:00", - "Ffmpeg": { - "ExecutableName": "ffmpeg", - "OutputFileName": "audio.m4a", - "AudioCodec": "aac", - "AudioBitrate": "128k", - "AudioChannels": 2, - "AudioSampleRate": 48000, - "AudioProfile": "aac_low", - "AdditionalArguments": "" + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "UploadIntent": { @@ -141,10 +128,5 @@ "TempRootPrefix": "temp", "TempUploadsRetentionDays": 3, "TempCleanupJobIntervalSeconds": 3600 - }, - "Workers": { - "Routes": { - "TemporaryFilesDirectory": "" - } } } diff --git a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index b2d3a59..d458ae0 100644 --- a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -1,64 +1,61 @@ using System.Security.Cryptography; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Configuration; -namespace Musify.StreamingGateway.Authentication; - -public sealed record TicketValidationResult(string Prefix, long? MaxBytes) +namespace Musify.StreamingGateway.Authentication { - public static readonly TicketValidationResult Invalid = new(string.Empty, null); - - public bool IsValid => Prefix.Length > 0; -} - -public sealed class TicketValidator : IDisposable -{ - private readonly StreamTicketValidationOptions _options; - private readonly RSA _rsa; - private readonly TokenValidationParameters _validationParameters; - private readonly JsonWebTokenHandler _handler = new(); - - public TicketValidator(IOptions options) + public sealed record TicketValidationResult(string Prefix, long? MaxBytes) { - _options = options.Value; + public static readonly TicketValidationResult Invalid = new(string.Empty, null); - _rsa = RSA.Create(); - _rsa.ImportFromPem(File.ReadAllText(_options.PublicKeyPath)); + public bool IsValid => Prefix.Length > 0; + } - _validationParameters = new TokenValidationParameters + public sealed class TicketValidator : IDisposable + { + private readonly RSA rsa; + private readonly TokenValidationParameters validationParameters; + private readonly JsonWebTokenHandler handler = new(); + + public TicketValidator(StreamTicketValidationConfiguration configuration) { - ValidateIssuer = true, - ValidIssuer = _options.Issuer, - ValidateAudience = true, - ValidAudience = _options.Audience, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new RsaSecurityKey(_rsa), - ValidAlgorithms = [SecurityAlgorithms.RsaSha256], - ClockSkew = TimeSpan.FromSeconds(30), - }; + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath)); + + validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = configuration.Issuer, + ValidateAudience = true, + ValidAudience = configuration.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new RsaSecurityKey(rsa), + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], + ClockSkew = TimeSpan.FromSeconds(30), + }; + } + + public async Task ValidateTicketAsync(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + return TicketValidationResult.Invalid; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + if (!result.IsValid) + return TicketValidationResult.Invalid; + + var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; + if (string.IsNullOrEmpty(prefix)) + return TicketValidationResult.Invalid; + + var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; + var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; + + return new TicketValidationResult(prefix, maxBytes); + } + + public void Dispose() => rsa.Dispose(); } - - public async Task ValidateTicketAsync(string? token) - { - if (string.IsNullOrWhiteSpace(token)) - return TicketValidationResult.Invalid; - - var result = await _handler.ValidateTokenAsync(token, _validationParameters); - if (!result.IsValid) - return TicketValidationResult.Invalid; - - var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; - if (string.IsNullOrEmpty(prefix)) - return TicketValidationResult.Invalid; - - var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; - var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; - - return new TicketValidationResult(prefix, maxBytes); - } - - public void Dispose() => _rsa.Dispose(); } diff --git a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs new file mode 100644 index 0000000..927e7f4 --- /dev/null +++ b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.StreamingGateway.Configuration +{ + public sealed class StreamTicketValidationConfiguration + { + public const string SectionName = "StreamTicket"; + + [Required] + public required string PublicKeyPath { get; set; } + + [Required] + public string Audience { get; set; } = "media-gateway"; + + [Required] + public string Issuer { get; set; } = "musify-webapi"; + + public string QueryParameterName { get; set; } = "t"; + + public string HeaderName { get; set; } = "X-Stream-Ticket"; + + public string MediaPathPrefix { get; set; } = "/media"; + } +} diff --git a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs deleted file mode 100644 index 8f21c17..0000000 --- a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.StreamingGateway.Configuration; - -public sealed class StreamTicketValidationOptions -{ - public const string SectionName = "StreamTicket"; - - [Required] - public required string PublicKeyPath { get; set; } - - [Required] - public string Audience { get; set; } = "media-gateway"; - - [Required] - public string Issuer { get; set; } = "musify-webapi"; - - public string QueryParameterName { get; set; } = "t"; - - public string HeaderName { get; set; } = "X-Stream-Ticket"; - - public string MediaPathPrefix { get; set; } = "/media"; -} diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 94b9644..b4cabc1 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -1,109 +1,115 @@ -using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; -namespace Musify.StreamingGateway.Middleware; - -public sealed class TicketValidationMiddleware( - RequestDelegate next, - TicketValidator ticketValidator, - IOptions options, - ILogger logger) +namespace Musify.StreamingGateway.Middleware { - private readonly StreamTicketValidationOptions options = options.Value; - - public async Task InvokeAsync(HttpContext context) + public sealed class TicketValidationMiddleware( + RequestDelegate next, + TicketValidator ticketValidator, + StreamTicketValidationConfiguration options, + ILogger logger) { - var path = context.Request.Path; - - if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + public async Task InvokeAsync(HttpContext context) { + var path = context.Request.Path; + + if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + { + await next(context); + return; + } + + var token = context.Request.Query[options.QueryParameterName].ToString(); + if (string.IsNullOrEmpty(token)) + { + token = context.Request.Headers[options.HeaderName].ToString(); + } + + var validation = await ticketValidator.ValidateTicketAsync(token); + + if (!validation.IsValid) + { + logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var prefix = validation.Prefix; + var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + + if (HasTraversalSegment(objectKey)) + { + logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/"; + if (!string.Equals(objectKey, prefix, StringComparison.Ordinal) + && !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal)) + { + logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) + { + logger.LogInformation( + "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", + objectKey, maxBytes); + context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; + return; + } + + StripQueryParameter(context, options.QueryParameterName); await next(context); - return; } - var token = context.Request.Query[options.QueryParameterName].ToString(); - if (string.IsNullOrEmpty(token)) + private static bool TryClampRange(HttpContext context, long maxBytes) { - token = context.Request.Headers[options.HeaderName].ToString(); - } + var rangeHeader = context.Request.Headers.Range; + if (StringValues.IsNullOrEmpty(rangeHeader)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } - var validation = await ticketValidator.ValidateTicketAsync(token); + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) + || range.Ranges.Count != 1 + || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } - if (!validation.IsValid) - { - logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } + var requested = range.Ranges.Single(); + if (requested.From is null) + return false; - var prefix = validation.Prefix; - var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + var start = requested.From.Value; + if (start >= maxBytes) + return false; - if (HasTraversalSegment(objectKey)) - { - logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/"; - if (!string.Equals(objectKey, prefix, StringComparison.Ordinal) - && !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal)) - { - logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); - context.Response.StatusCode = StatusCodes.Status403Forbidden; - return; - } - - if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) - { - logger.LogInformation( - "Rejected media request {ObjectKey}: requested range starts beyond the {MaxBytes}-byte preview limit", - objectKey, maxBytes); - context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; - context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; - return; - } - - StripQueryParameter(context, options.QueryParameterName); - await next(context); - } - - private static bool TryClampRange(HttpContext context, long maxBytes) - { - var rangeHeader = context.Request.Headers.Range; - if (StringValues.IsNullOrEmpty(rangeHeader)) - { - context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; + context.Request.Headers.Range = $"bytes={start}-{end}"; return true; } - if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1) - return true; + private static bool HasTraversalSegment(string objectKey) => + objectKey.Split('/').Any(segment => segment is ".." or "."); - var requested = range.Ranges.Single(); - var start = requested.From ?? 0; - if (start >= maxBytes) - return false; + private static void StripQueryParameter(HttpContext context, string name) + { + var remaining = context.Request.Query + .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) + .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) + .ToList(); - var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; - context.Request.Headers.Range = $"bytes={start}-{end}"; - return true; - } - - private static bool HasTraversalSegment(string objectKey) => - objectKey.Split('/').Any(segment => segment is ".." or "."); - - private static void StripQueryParameter(HttpContext context, string name) - { - var remaining = context.Request.Query - .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) - .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) - .ToList(); - - context.Request.QueryString = QueryString.Create(remaining); + context.Request.QueryString = QueryString.Create(remaining); + } } } diff --git a/backend/Hosts/Musify.StreamingGateway/Program.cs b/backend/Hosts/Musify.StreamingGateway/Program.cs index 0ee5a1c..c127bd7 100644 --- a/backend/Hosts/Musify.StreamingGateway/Program.cs +++ b/backend/Hosts/Musify.StreamingGateway/Program.cs @@ -1,20 +1,25 @@ +using Microsoft.Extensions.Options; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Middleware; -var builder = WebApplication.CreateBuilder(); +const string CorsPolicy = "media-clients"; + +var builder = WebApplication.CreateBuilder(args); builder.Services - .AddOptions() - .Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationOptions.SectionName)) + .AddOptions() + .Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationConfiguration.SectionName)) .ValidateDataAnnotations() .ValidateOnStart(); +builder.Services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + builder.Services.AddSingleton(); -const string corsPolicy = "media-clients"; var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; -builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => +builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => { if (builder.Environment.IsDevelopment()) policy.AllowAnyOrigin(); @@ -24,7 +29,7 @@ builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy.AllowAnyHeader() .WithMethods("GET", "HEAD") .WithExposedHeaders("Content-Range", "Accept-Ranges", "Content-Length"); -} )); +})); builder.Services .AddReverseProxy() @@ -33,9 +38,9 @@ builder.Services var app = builder.Build(); app.MapGet("/health", () => Results.Ok("healthy")) - .WithSummary("Health Check Endpoint"); + .WithSummary("Health check endpoint"); -app.UseCors(corsPolicy); +app.UseCors(CorsPolicy); app.UseMiddleware(); app.MapReverseProxy(); diff --git a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json index 0bac6c4..9a8d81e 100644 --- a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json @@ -1,5 +1,14 @@ { "StreamTicket": { "PublicKeyPath": "../../../deploy/keys/stream_public.pem" + }, + "ReverseProxy": { + "Routes": { + "media": { + "Transforms": [ + { "PathPattern": "/buckets/webapi-storage/{**rest}" } + ] + } + } } } diff --git a/backend/Hosts/Musify.StreamingGateway/appsettings.json b/backend/Hosts/Musify.StreamingGateway/appsettings.json index a2eeaa5..52df1f0 100644 --- a/backend/Hosts/Musify.StreamingGateway/appsettings.json +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.json @@ -26,7 +26,7 @@ "ClusterId": "seaweed-filer", "Match": { "Path": "/media/{**rest}" }, "Transforms": [ - { "PathPattern": "/buckets/webapi-storage/{**rest}" } + { "PathPattern": "/buckets/CHANGE_ME/{**rest}" } ] } }, diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index bda423d..f377677 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,51 +1,28 @@ -using Hangfire; -using Musify.Application.Configuration; -using Musify.Infrastructure.Jobs; +using Musify.Application; using Musify.Infrastructure.MassTransit; +using Musify.Infrastructure.Observability; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; -var builder = Host.CreateApplicationBuilder(); +var builder = Host.CreateApplicationBuilder(args); builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions { - ValidateOnBuild = false, + ValidateOnBuild = true, ValidateScopes = true })); -var configuration = builder.Configuration; - -var services = builder.Services; - -services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); -services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); -services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); -services.AddValidatedOptions(configuration, MixConfiguration.SectionName); -services.AddStorageService(configuration); -services.AddAudioTranscoder(configuration); -services.AddHttpClient(); -services.AddPictureService(configuration); -services.AddDatabase(configuration); -services.AddMassTransitConsumers(configuration); -services.AddUploadIntentJobs(configuration); -services.AddMediator(); -services.AddDailyMixGenerationJob(configuration); +builder.Services + .AddApplication(builder.Configuration) + .AddObservability(builder.Configuration) + .AddDatabase(builder.Configuration) + .AddStorageService(builder.Configuration) + .AddAudioTranscoder(builder.Configuration) + .AddStreamTicketService(builder.Configuration) + .AddPictureService() + .AddMassTransitConsumers(builder.Configuration) + .AddJobs(); var host = builder.Build(); -using (var scope = host.Services.CreateScope()) -{ - var recurringJobManager = scope.ServiceProvider.GetRequiredService(); - recurringJobManager.AddOrUpdate( - "daily-mix-generation", - job => job.RunAsync(CancellationToken.None), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - recurringJobManager.AddOrUpdate( - "listening-history-cleanup", - job => job.RunAsync(CancellationToken.None), - Cron.Daily(3), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); -} - host.Run(); diff --git a/backend/Hosts/Musify.Worker/Worker.csproj b/backend/Hosts/Musify.Worker/Worker.csproj index e264aa3..00dadc0 100644 --- a/backend/Hosts/Musify.Worker/Worker.csproj +++ b/backend/Hosts/Musify.Worker/Worker.csproj @@ -7,17 +7,8 @@ - - - - - - - - - diff --git a/backend/Hosts/Musify.Worker/appsettings.Development.json b/backend/Hosts/Musify.Worker/appsettings.Development.json index 92ee27c..624ea0e 100644 --- a/backend/Hosts/Musify.Worker/appsettings.Development.json +++ b/backend/Hosts/Musify.Worker/appsettings.Development.json @@ -26,10 +26,11 @@ "ApplicationStorage": { "Bucket": "webapi-storage" }, - "AudioTranscoder": { - "Routes": { - "WorkingDirectory": "../../../deploy/.tmp" - } + "StreamGateway": { + "PublicBaseUrl": "http://localhost:8081" + }, + "StreamTicket": { + "PrivateKeyPath": "../../../deploy/keys/stream_private.pem" }, "Workers": { "Routes": { diff --git a/backend/Hosts/Musify.Worker/appsettings.json b/backend/Hosts/Musify.Worker/appsettings.json index 5a93fc4..d4fccda 100644 --- a/backend/Hosts/Musify.Worker/appsettings.json +++ b/backend/Hosts/Musify.Worker/appsettings.json @@ -26,28 +26,63 @@ "ApplicationStorage": { "Bucket": "" }, + "StreamGateway": { + "PublicBaseUrl": "", + "AudioFileName": "audio.m4a" + }, + "StreamTicket": { + "PrivateKeyPath": "", + "Audience": "media-gateway", + "Issuer": "musify-webapi", + "TicketTtlSeconds": 3600 + }, + "Playback": { + "AllowAnonymousListening": false, + "AnonymousFragmentSeconds": 0, + "EstimatedAudioBytesPerSecond": 16000 + }, "Mix": { "ItemsPerMix": 12 }, "PlayList": { "Routes": { - "ParentFolders": "PlayLists", + "UploadsFolder": "uploads", + "ParentFolder": "PlayLists", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 + } + }, + "Album": { + "Routes": { + "UploadsFolder": "uploads", + "ParentFolder": "Albums", + "OriginalPicturesFolder": "OriginalPictures", + "SmallPicturesFolder": "SmallPictures", + "MediumPicturesFolder": "MediumPictures", + "LargePicturesFolder": "LargePictures" + }, + "PicturesSizes": { + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "Track": { "Routes": { + "UploadsFolder": "uploads", "ParentFolder": "Tracks", "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", @@ -58,21 +93,18 @@ "PresetMediumPicture": "PresetMediumPicture.webp", "PresetLargePicture": "PresetLargePicture.webp", "OriginalAudiosFolder": "OriginalAudios", - "ProcessedAudioFolder": "ProcessedAudios" + "ProcessedAudiosFolder": "ProcessedAudios" }, "PicturesSizes": { - "SmallPictureWidth": 128, - "SmallPictureHeight": 128, - "MediumPictureWidth": 256, - "MediumPictureHeight": 256, - "LargePictureWidth": 512, - "LargePictureHeight": 512 + "SmallWidth": 128, + "SmallHeight": 128, + "MediumWidth": 256, + "MediumHeight": 256, + "LargeWidth": 512, + "LargeHeight": 512 } }, "AudioTranscoder": { - "Routes": { - "WorkingDirectory": "" - }, "TranscodingTimeout": "00:05:00" }, "Workers": { diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs index aecba63..c6fc742 100644 --- a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -63,23 +63,5 @@ namespace Musify.Api.Tests.Authentication Assert.Throws(() => user.RequiredId); } - - [Fact] - public async Task HasClaim_ExistingClaimType_ReturnsTrue() - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); - - Assert.True(user.HasClaim("role")); - Assert.False(user.HasClaim("missing")); - } - - [Fact] - public async Task FindClaim_ExistingClaimType_ReturnsItsValue() - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); - - Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); - Assert.Null(user.FindClaim("missing")); - } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 9230f5b..ae9389e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -1,10 +1,11 @@ using System.Net; using System.Net.Http.Json; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Application.Pictures.Responses; using NSubstitute; using Xunit; @@ -13,19 +14,12 @@ namespace Musify.Api.Tests.Endpoints [Collection(ApiCollection.Name)] public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { - private async Task CreateUserAsync() - { - var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); - response.EnsureSuccessStatusCode(); - return userId; - } + private Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task CreatePictureIntentAsync(HttpClient client) { fixture.StorageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); fixture.StorageService .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -33,11 +27,11 @@ namespace Musify.Api.Tests.Endpoints var response = await client.PostAsJsonAsync( "/albums/upload-picture", - new RequestAlbumPictureUploadRequest("webp", "image/webp"), + new PictureUploadRequest("webp", "image/webp"), TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); return body!.IntentId; } @@ -103,7 +97,7 @@ namespace Musify.Api.Tests.Endpoints } [Fact] - public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() { var owner = await CreateUserAsync(); var stranger = await CreateUserAsync(); @@ -114,7 +108,7 @@ namespace Musify.Api.Tests.Endpoints var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index 539cd97..f5fed4f 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -4,7 +4,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Musify.Api.Tests.TestSupport; -using Musify.Application.Genres; +using Musify.Application.Genres.Responses; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -39,7 +40,6 @@ namespace Musify.Api.Tests.Endpoints tag.TrackId = track.Id; database.Users.Add(owner); database.Tracks.Add(track); - database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); await database.SaveChangesAsync(TestContext.Current.CancellationToken); return title; } @@ -85,8 +85,8 @@ namespace Musify.Api.Tests.Endpoints var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Track.Title == title); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); } [Fact] @@ -98,9 +98,9 @@ namespace Musify.Api.Tests.Endpoints var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Track.Title == title); - Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags)); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs index 01cdda4..7d43fbf 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -3,25 +3,17 @@ using System.Net.Http.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Musify.Api.DataTransferObjects.Tracks; -using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Tests.TestSupport; using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -using Musify.Api.Tests.TestSupport; namespace Musify.Api.Tests.Endpoints { [Collection(ApiCollection.Name)] public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) { - private async Task CreateUserAsync() - { - var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await fixture.CreateAnonymousClient().PostAsJsonAsync( - "/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); - return userId; - } + private Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task SeedListenAsync(long userId, double durationSeconds) { diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs new file mode 100644 index 0000000..d876e01 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs @@ -0,0 +1,157 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Musify.Api.DataTransferObjects.PlayLists; +using Musify.Api.Tests.TestSupport; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class PlayListEndpointsTests(ApiTestFixture fixture) + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) + { + var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( + "/playlists", + new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), + Ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; + } + + [Fact] + public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); + var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); + } + + [Fact] + public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + } + + [Fact] + public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); + var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); + + Assert.Equal([publicPlayList.Id], asStranger); + Assert.Contains(publicPlayList.Id, asOwner); + Assert.Contains(privatePlayList.Id, asOwner); + } + + [Fact] + public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task UpdatePlayList_Owner_RenamesIt() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); + Assert.Equal("Renamed", body!.Name); + } + + [Fact] + public async Task GetPlayListById_NotAGuid_ReturnsNotFound() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() + { + var owner = await fixture.SeedUserAsync(); + + var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( + "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) + { + var response = await client.GetAsync($"/playlists/users/{userId}", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); + return page!.Items.Select(playList => playList.Id).ToList(); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index e398f2e..43498e2 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -4,7 +4,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Musify.Application.Users.Responses; @@ -39,46 +38,20 @@ namespace Musify.Api.Tests.Endpoints { UserId = userId, TrackId = track.Id, - ListenedAt = listenedAt + ListenedAt = listenedAt, + IsCounted = true }); await database.SaveChangesAsync(TestContext.Current.CancellationToken); } [Fact] - public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("Jane Doe", body.Name); - } - - [Fact] - public async Task PostUsers_BlankName_ReturnsValidationProblem() + public async Task PostUsers_IsNotExposed() { var client = fixture.CreateAnonymousClient(); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task PostUsers_DuplicateId_ReturnsConflict() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken); - - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); } [Fact] @@ -86,7 +59,7 @@ namespace Musify.Api.Tests.Endpoints { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); + await fixture.SeedUserAsync(userId, "Findable User"); var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); @@ -110,7 +83,7 @@ namespace Musify.Api.Tests.Endpoints { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + await fixture.SeedUserAsync(userId); await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); @@ -122,15 +95,48 @@ namespace Musify.Api.Tests.Endpoints } [Fact] - public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound() + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + await fixture.SeedUserAsync(userId); var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Theory] + [InlineData("pageSize=0")] + [InlineData("pageSize=101")] + [InlineData("pageNumber=0")] + public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetListeningStats_AnotherUser_ReturnsForbidden() + { + var userId = await fixture.SeedUserAsync(); + var other = await fixture.SeedUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } } diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index c0272af..5a3887b 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -44,10 +45,27 @@ namespace Musify.Api.Tests.TestSupport public HttpClient CreateAnonymousClient() => CreateClient(); + public async Task SeedUserAsync(long? userId = null, string? name = null) + { + var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); + var userName = name ?? $"user-{id}"; + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + database.Users.Add(new Musify.Domain.Entities.User + { + Id = id, + Name = userName, + NormalizedName = userName.ToUpperInvariant() + }); + await database.SaveChangesAsync(); + return id; + } + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") { var client = CreateClient(); - client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString()); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture)); client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); return client; } diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs index c2d3ee1..e591762 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -1,4 +1,5 @@ using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.Validators; using Musify.Api.Validators.Albums; using Xunit; @@ -44,7 +45,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid())); Assert.False(result.IsValid); } @@ -52,7 +53,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -60,7 +61,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearAtTheLatestBoundary_Passes() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -68,7 +69,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearPastTheLatestBoundary_Fails() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid())); + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid())); Assert.False(result.IsValid); } diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs deleted file mode 100644 index fffb0bf..0000000 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Musify.Api.DataTransferObjects.Users; -using Musify.Api.Validators.Users; -using Xunit; - -namespace Musify.Api.Tests.Validators -{ - public sealed class CreateUserRequestValidatorTests - { - private readonly CreateUserRequestValidator validator = new(); - - [Fact] - public void Validate_ValidRequest_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); - - Assert.True(result.IsValid); - } - - [Fact] - public void Validate_ZeroId_Fails() - { - var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); - - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); - } - - [Fact] - public void Validate_BlankName_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, "", null, null)); - - Assert.False(result.IsValid); - } - - [Fact] - public void Validate_NameOverMaxLength_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); - - Assert.False(result.IsValid); - } - - [Fact] - public void Validate_OptionalNamesOmitted_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); - - Assert.True(result.IsValid); - } - } -} diff --git a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs new file mode 100644 index 0000000..59534b7 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs @@ -0,0 +1,49 @@ +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators.Tracks; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Validators +{ + public sealed class TrackRequestValidatorTests + { + [Fact] + public void CreateTrack_ValidRequest_Passes() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); + + Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_NoTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_IncompatibleTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) + { + Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); + } + + [Fact] + public void RecordListeningProgress_ZeroSeconds_Passes() + { + Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs new file mode 100644 index 0000000..8a99892 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs @@ -0,0 +1,50 @@ +using Musify.Api.DataTransferObjects; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators; +using Musify.Api.Validators.Tracks; +using Musify.Application.Configuration; +using Xunit; + +namespace Musify.Api.Tests.Validators +{ + public sealed class UploadRequestValidatorTests + { + private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; + + [Fact] + public void PictureUpload_ValidRequest_Passes() + { + var validator = new PictureUploadRequestValidator(configuration); + + var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500)); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("exe", "image/webp", 500L)] + [InlineData("averyveryveryloongextension", "image/webp", 500L)] + [InlineData("we bp", "image/webp", 500L)] + [InlineData("webp", "text/html", 500L)] + [InlineData("webp", "image/webp", 0L)] + [InlineData("webp", "image/webp", 1_001L)] + public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) + { + var validator = new PictureUploadRequestValidator(configuration); + + var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size)); + + Assert.False(result.IsValid); + } + + [Fact] + public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() + { + var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + + Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs similarity index 75% rename from backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs index 3d78fd9..dacfaaf 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs @@ -7,9 +7,9 @@ using Xunit; namespace Musify.Application.Tests.Albums { - public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase + public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase { - private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); [Fact] public async Task Handle_FirstTrack_AddsItAtNumberOne() @@ -19,7 +19,7 @@ namespace Musify.Application.Tests.Albums var track = TestEntities.Track(owner); await SeedAsync(owner, album, track); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); @@ -33,13 +33,13 @@ namespace Musify.Application.Tests.Albums var track = TestEntities.Track(owner); await SeedAsync(owner, track); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -47,9 +47,9 @@ namespace Musify.Application.Tests.Albums var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, album, track); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -59,13 +59,13 @@ namespace Musify.Application.Tests.Albums var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } [Fact] - public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() + public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var otherOwner = TestEntities.User(2, "other"); @@ -73,13 +73,13 @@ namespace Musify.Application.Tests.Albums var foreignTrack = TestEntities.Track(otherOwner); await SeedAsync(owner, otherOwner, album, foreignTrack); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] - public async Task Handle_AlreadyInAlbum_ReturnsConflict() + public async Task Handle_AlreadyInAlbum_IsSkipped() { var owner = TestEntities.User(); var album = TestEntities.Album(owner.Id); @@ -87,9 +87,10 @@ namespace Musify.Application.Tests.Albums var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; await SeedAsync(owner, album, track, link); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.False(result.IsError); + Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -102,7 +103,7 @@ namespace Musify.Application.Tests.Albums var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 }; await SeedAsync(owner, album, firstTrack, secondTrack, existingLink); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index b242565..fcf5d1d 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -16,11 +16,9 @@ namespace Musify.Application.Tests.Albums private CreateAlbumCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - TestConfigurations.UploadIntent()); + TestConfigurations.Album()); [Fact] public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index 6d2a371..d7ac89c 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -1,16 +1,21 @@ using ErrorOr; using Musify.Application.Albums; +using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); [Fact] - public async Task Handle_Owner_RemovesAlbum() + public async Task Handle_Owner_MarksAlbumAsRemoving() { var owner = TestEntities.User(); var album = TestEntities.Album(owner.Id); @@ -19,7 +24,10 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -31,7 +39,7 @@ namespace Musify.Application.Tests.Albums } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() + public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -40,7 +48,7 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs deleted file mode 100644 index 4a7610a..0000000 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ErrorOr; -using Musify.Application.Albums; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.Albums -{ - public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase - { - private GetAlbumCoverQueryHandler CreateHandler() => - new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album()); - - [Theory] - [InlineData("small", "small.webp")] - [InlineData("large", "large.webp")] - [InlineData("medium", "medium.webp")] - public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName) - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); - - var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - } - - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index 42f2484..cda4557 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -15,10 +15,8 @@ namespace Musify.Application.Tests.Albums await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); + Assert.Equal(2, result.TotalItemCount); + Assert.All(result.Items, item => Assert.NotNull(item)); } [Fact] @@ -28,10 +26,8 @@ namespace Musify.Application.Tests.Albums await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Rock Classics", item.Album.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Rock Classics", item.Title); } [Fact] @@ -41,10 +37,8 @@ namespace Musify.Application.Tests.Albums await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.True(result.Value.HasNextPage); + Assert.Equal(2, result.Items.Count); + Assert.True(result.HasNextPage); } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index cff1661..2cc2427 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -25,9 +25,7 @@ namespace Musify.Application.Tests.Albums TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1))); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); + Assert.Equal(["Newer", "Older"], result.Select(album => album.Title)); } [Fact] @@ -37,9 +35,7 @@ namespace Musify.Application.Tests.Albums await SeedAsync(owner); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); + Assert.Empty(result); } [Fact] @@ -53,9 +49,7 @@ namespace Musify.Application.Tests.Albums await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Count); + Assert.Equal(2, result.Count); } [Fact] @@ -75,7 +69,7 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); + Assert.Equal(["Counted album"], result.Select(album => album.Title)); } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index 62516f2..f5e6a85 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -46,7 +46,7 @@ namespace Musify.Application.Tests.Albums } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -55,7 +55,7 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 4c0a83c..711218b 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -17,17 +17,20 @@ namespace Musify.Application.Tests.Albums private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + return new RequestAlbumPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.Album()); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index 3d304d0..feb5986 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -16,11 +16,9 @@ namespace Musify.Application.Tests.Albums private UpdateAlbumCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - TestConfigurations.UploadIntent()); + TestConfigurations.Album()); [Fact] public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() @@ -77,7 +75,7 @@ namespace Musify.Application.Tests.Albums } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -88,7 +86,7 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } } } diff --git a/backend/Tests/Musify.Application.Tests/Application.Tests.csproj b/backend/Tests/Musify.Application.Tests/Application.Tests.csproj index d6f7af5..9a4f183 100644 --- a/backend/Tests/Musify.Application.Tests/Application.Tests.csproj +++ b/backend/Tests/Musify.Application.Tests/Application.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs new file mode 100644 index 0000000..2c34e5f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs @@ -0,0 +1,51 @@ +using Musify.Application.Configuration; +using Xunit; + +namespace Musify.Application.Tests.Configuration +{ + public sealed class PicturesConfigurationTests + { + [Fact] + public void AlbumRoutes_BuildTheSameKeysAsBefore() + { + var routes = new AlbumConfiguration().Routes; + + Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp")); + Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp")); + Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp")); + Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp")); + Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp")); + } + + [Fact] + public void TrackRoutes_KeepTheTrackFolderLayout() + { + var routes = new TrackConfiguration().Routes; + + Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder")); + Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3")); + Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath); + } + + [Fact] + public void ToImageSizes_UsesTheRoutesFolders() + { + var configuration = new PlayListConfiguration(); + + var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes); + + Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath); + Assert.Equal(128, sizes.Small.Width); + Assert.Equal(512, sizes.Large.Width); + } + + [Theory] + [InlineData("small", PictureSize.Small)] + [InlineData("LARGE", PictureSize.Large)] + [InlineData("medium", PictureSize.Medium)] + [InlineData("whatever", PictureSize.Medium)] + [InlineData(null, PictureSize.Medium)] + public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) => + Assert.Equal(expected, PictureSizeParser.Parse(value)); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index 0f8d698..3b1473c 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -1,4 +1,5 @@ using Musify.Application.Genres; +using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; using Xunit; diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs index cb4d6e9..4086ba1 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Genres; using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Xunit; @@ -26,10 +25,7 @@ namespace Musify.Application.Tests.Genres var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); await SeedAsync( - owner, rockA, rockB, jazz, - new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id }, - new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id }, - new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + owner, rockA, rockB, jazz); var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); @@ -38,32 +34,5 @@ namespace Musify.Application.Tests.Genres result.Select(r => (r.Genre, r.TrackCount))); } - [Fact] - public async Task Handle_TrackInSeveralLibraries_IsCountedOnce() - { - var owner = TestEntities.User(); - var other = TestEntities.User(id: 2, name: "other-user"); - var track = TestEntities.Track(owner, tags: [Genre.Pop]); - await SeedAsync( - owner, other, track, - new UserHasTrack { UserId = owner.Id, TrackId = track.Id }, - new UserHasTrack { UserId = other.Id, TrackId = track.Id }); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Equal(1, Assert.Single(result).TrackCount); - } - - [Fact] - public async Task Handle_TrackNotInAnyLibrary_IsIgnored() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, tags: [Genre.Metal]); - await SeedAsync(owner, track); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Empty(result); - } } } diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index 158ee08..30f1782 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -21,9 +21,7 @@ namespace Musify.Application.Tests.Likes new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); + Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); } [Fact] @@ -33,9 +31,7 @@ namespace Musify.Application.Tests.Likes await SeedAsync(owner); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + Assert.Empty(result.Items); } [Fact] @@ -47,9 +43,7 @@ namespace Musify.Application.Tests.Likes await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + Assert.Empty(result.Items); } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 70bd2d1..47ee9e6 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes @@ -17,9 +17,7 @@ namespace Musify.Application.Tests.Mixes var user = TestEntities.User(); await SeedAsync(user); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); } @@ -30,15 +28,12 @@ namespace Musify.Application.Tests.Mixes var track = TestEntities.Track(user); await SeedAsync( user, track, - new UserHasTrack { UserId = user.Id, TrackId = track.Id }, TestEntities.ListeningHistory(user.Id, track.Id)); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(mixes); - Assert.Equal("Tu mezcla diaria", mixes[0].Title); + Assert.Equal(MixKind.Daily, mixes[0].Kind); } [Fact] @@ -48,25 +43,21 @@ namespace Musify.Application.Tests.Mixes var listenedTrack = TestEntities.Track(user, "Listened Track"); var unheardTrack = TestEntities.Track(user, "Unheard Track"); - var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); + var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); await SeedAsync( user, listenedTrack, unheardTrack, staleMix, staleItem, - new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id }, - new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id }, TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); - Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria"); + Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); + var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); var discoveryItems = await Database.MixItems .Where(item => item.MixId == discoveryMix.Id) .ToListAsync(TestContext.Current.CancellationToken); @@ -80,13 +71,12 @@ namespace Musify.Application.Tests.Mixes var skipped = TestEntities.Track(user, "Only skipped"); await SeedAsync( user, skipped, - new UserHasTrack { UserId = user.Id, TrackId = skipped.Id }, TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index 97ba344..2d456d8 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -1,6 +1,7 @@ using ErrorOr; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes @@ -15,7 +16,7 @@ namespace Musify.Application.Tests.Mixes var owner = TestEntities.User(); var firstTrack = TestEntities.Track(owner, "First"); var secondTrack = TestEntities.Track(owner, "Second"); - var mix = TestEntities.Mix(owner.Id, "Discovery"); + var mix = TestEntities.Mix(owner.Id, MixKind.Daily); var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); @@ -23,7 +24,7 @@ namespace Musify.Application.Tests.Mixes var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal("Discovery", result.Value.Title); + Assert.Equal(MixKind.Daily, result.Value.Kind); Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index d5ec0d9..baa8c34 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes @@ -13,15 +14,13 @@ namespace Musify.Application.Tests.Mixes { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - var second = TestEntities.Mix(owner.Id, "Second", position: 1); - var first = TestEntities.Mix(owner.Id, "First", position: 0); - var theirs = TestEntities.Mix(other.Id, "Theirs"); + var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1); + var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); + var theirs = TestEntities.Mix(other.Id); await SeedAsync(owner, other, second, first, theirs); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); + Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); } [Fact] @@ -31,9 +30,7 @@ namespace Musify.Application.Tests.Mixes await SeedAsync(owner); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); + Assert.Empty(result); } } } diff --git a/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs new file mode 100644 index 0000000..386627f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs @@ -0,0 +1,105 @@ +using ErrorOr; +using Musify.Application.Configuration; +using Musify.Application.Pictures; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Pictures +{ + public sealed class GetCoverQueryHandlerTests : HandlerTestBase + { + private GetCoverQueryHandler CreateHandler() => new( + Database, + TestConfigurations.Storage("covers-bucket"), + TestConfigurations.Track(), + TestConfigurations.Album(), + TestConfigurations.PlayList()); + + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + Assert.Equal("image/webp", result.Value.ContentType); + } + + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.EndsWith(expectedFileName, result.Value.Key); + } + + [Fact] + public async Task Handle_ProcessedPlayList_ReturnsRequestedSize() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.EndsWith("large.webp", result.Value.Key); + } + + [Theory] + [InlineData(CoverOwner.Track)] + [InlineData(CoverOwner.Album)] + [InlineData(CoverOwner.PlayList)] + public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner) + { + var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png")); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Contains($"{owner.Id}", result.Value.Key); + Assert.EndsWith("original.png", result.Value.Key); + Assert.Equal("image/png", result.Value.ContentType); + } + + [Fact] + public async Task Handle_RemovingAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + album.LifeCycleStatus = LifeCycleStatus.Removing; + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs index b71d492..f4f179c 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs @@ -63,7 +63,7 @@ namespace Musify.Application.Tests.PlayLists } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -73,7 +73,7 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs similarity index 50% rename from backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs index 2e1fad1..81e228d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs @@ -7,9 +7,9 @@ using Xunit; namespace Musify.Application.Tests.PlayLists { - public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase + public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase { - private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); [Fact] public async Task Handle_FirstTrack_AddsItAtPositionZero() @@ -19,7 +19,7 @@ namespace Musify.Application.Tests.PlayLists var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); @@ -37,7 +37,7 @@ namespace Musify.Application.Tests.PlayLists owner, playList, existingTrack, newTrack, new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 }); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); @@ -47,22 +47,22 @@ namespace Musify.Application.Tests.PlayLists [Fact] public async Task Handle_PlayListMissing_ReturnsNotFound() { - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, stranger, playList); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -72,22 +72,59 @@ namespace Musify.Application.Tests.PlayLists var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, playList); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } [Fact] - public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() + public async Task Handle_TrackAlreadyInPlayList_IsSkipped() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.False(result.IsError); + Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); + + var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); + + Assert.Equal("Test.Duplicate", result.FirstError.Code); + } + + [Fact] + public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, existing, first, second, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); + + var result = await CreateHandler().Handle( + new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1, 2], links.Select(link => link.Position)); } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index 697a4de..986c877 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -16,11 +16,9 @@ namespace Musify.Application.Tests.PlayLists private CreatePlayListCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); + TestConfigurations.PlayList()); [Fact] public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() @@ -71,7 +69,7 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Private, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); } [Fact] @@ -80,12 +78,12 @@ namespace Musify.Application.Tests.PlayLists var user = TestEntities.User(); await SeedAsync(user); - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs index 2c637a8..b451aa6 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -39,7 +39,7 @@ namespace Musify.Application.Tests.PlayLists } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -48,7 +48,7 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 0610a17..ef95972 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tests.PlayLists public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); @@ -37,7 +37,7 @@ namespace Musify.Application.Tests.PlayLists public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); await SeedAsync(owner, playList); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); @@ -50,7 +50,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); await SeedAsync(owner, stranger, playList); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); @@ -62,7 +62,7 @@ namespace Musify.Application.Tests.PlayLists public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); await SeedAsync(owner, playList); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); @@ -74,7 +74,7 @@ namespace Musify.Application.Tests.PlayLists public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); await SeedAsync(owner, playList); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs deleted file mode 100644 index 6c7dbbe..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ErrorOr; -using Musify.Application.PlayLists; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.PlayLists -{ - public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase - { - private GetPlayListCoverQueryHandler CreateHandler() => - new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList()); - - [Theory] - [InlineData("small", "small.webp")] - [InlineData("large", "large.webp")] - [InlineData("medium", "medium.webp")] - public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName) - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); - - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - } - - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - - [Fact] - public async Task Handle_PlayListWithoutPictures_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - playList.Pictures = null; - await SeedAsync(owner, playList); - - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index bedf50f..0589d68 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -2,6 +2,7 @@ using ErrorOr; using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.PlayLists @@ -22,7 +23,7 @@ namespace Musify.Application.Tests.PlayLists new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }); - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); @@ -35,5 +36,29 @@ namespace Musify.Application.Tests.PlayLists Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 4e051e5..4e1d4b9 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -20,39 +20,46 @@ namespace Musify.Application.Tests.PlayLists TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing), TestEntities.PlayList(other.Id, "Theirs")); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); Assert.Equal("Mine", playList.Name); } [Fact] - public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists() + public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() { var owner = TestEntities.User(); await SeedAsync( owner, - TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public), - TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private)); + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); Assert.Equal("Public one", playList.Name); } + [Fact] + public async Task Handle_Owner_SeesPrivatePlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Items.Count); + } + [Fact] public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() { var owner = TestEntities.User(); await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); Assert.Equal("Road Trip", playList.Name); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs deleted file mode 100644 index ba64fa9..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Musify.Application.PlayLists; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.PlayLists -{ - public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase - { - private GetPlayListsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsEveryPlayListPaged() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two")); - - var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - } - - [Fact] - public async Task Handle_NoPlayLists_ReturnsEmptyPage() - { - var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 67bab6e..e73a127 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -33,7 +33,7 @@ namespace Musify.Application.Tests.PlayLists } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -42,7 +42,7 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -56,5 +56,27 @@ namespace Musify.Application.Tests.PlayLists Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); + await SeedAsync( + owner, playList, first, second, third, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); + + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([0, 1], remaining.Select(link => link.Position)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 7a73cb4..1b967bc 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -17,17 +17,20 @@ namespace Musify.Application.Tests.PlayLists private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + return new RequestPlayListPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.PlayList()); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index ccee5d0..f7951bf 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -16,11 +16,9 @@ namespace Musify.Application.Tests.PlayLists private UpdatePlayListCommandHandler CreateHandler() => new( eventBus, Database, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); + TestConfigurations.PlayList()); [Fact] public async Task Handle_Owner_RenamesPlayList() @@ -48,7 +46,7 @@ namespace Musify.Application.Tests.PlayLists } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -59,7 +57,7 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -69,19 +67,19 @@ namespace Musify.Application.Tests.PlayLists var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, playList); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); } [Fact] public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); await SeedAsync(owner, playList); var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); @@ -89,11 +87,11 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); } [Fact] - public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() + public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); @@ -105,7 +103,7 @@ namespace Musify.Application.Tests.PlayLists Assert.False(result.IsError); Assert.Equal("Original Name", result.Value.Name); - Assert.Equal("Original description", result.Value.Description); + Assert.Null(result.Value.Description); } [Fact] @@ -126,7 +124,7 @@ namespace Musify.Application.Tests.PlayLists var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName); + Assert.Null(result.Value.SmallImageKeyName); var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index 23e4924..eaaf27b 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; @@ -8,90 +7,56 @@ using Xunit; namespace Musify.Application.Tests.Services { - public sealed class TrackStreamIssuerTests : HandlerTestBase + public sealed class TrackStreamIssuerTests { private readonly IStreamTicketService ticketService = Substitute.For(); + public TrackStreamIssuerTests() => + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"), playback ?? TestConfigurations.Playback()); [Fact] - public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() + public void Issue_BuildsManifestUrlAndCarriesTheListenId() { - ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - var owner = TestEntities.User(7); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var listenId = Guid.NewGuid(); - var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); + var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); Assert.Equal("signed-token", response.Ticket); Assert.Equal(120, response.ExpiresInSeconds); - Assert.Equal( - "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", - response.ManifestUrl); - - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(7, history.UserId); - Assert.Equal(track.Id, history.TrackId); + Assert.Equal(listenId, response.ListenId); + Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); } [Fact] - public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() + public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService() { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - - await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + CreateIssuer().Issue("folder-42", userId: 1, listenId: null); ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } [Fact] - public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() + public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - - var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) - .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); - - Assert.Equal("token", response.Ticket); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); } [Fact] - public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + + CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index 441885b..fc439b9 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -1,4 +1,5 @@ using ErrorOr; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tests.TestSupport; @@ -13,7 +14,8 @@ namespace Musify.Application.Tests.Services private readonly IStorageService storageService = Substitute.For(); private readonly Domain.Entities.User owner = TestEntities.User(); - private UploadIntentValidator CreateValidator() => new(Database, storageService); + private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) => + new(Database, storageService, config ?? TestConfigurations.UploadIntent()); [Fact] public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() @@ -25,16 +27,27 @@ namespace Musify.Application.Tests.Services var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(intent.Id, result.Value.Id); } + [Fact] + public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -46,7 +59,7 @@ namespace Musify.Application.Tests.Services var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, stranger, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -57,7 +70,7 @@ namespace Musify.Application.Tests.Services var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -68,7 +81,7 @@ namespace Musify.Application.Tests.Services var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -83,7 +96,7 @@ namespace Musify.Application.Tests.Services var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -101,7 +114,7 @@ namespace Musify.Application.Tests.Services var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -111,7 +124,7 @@ namespace Musify.Application.Tests.Services { await SeedAsync(owner); - var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); + var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.False(result.IsError); } @@ -123,7 +136,7 @@ namespace Musify.Application.Tests.Services config.MaxActiveUploadIntentsPerUser = 1; await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); - var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -138,7 +151,7 @@ namespace Musify.Application.Tests.Services TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); - var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.False(result.IsError); } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs index 653dfeb..6926283 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -12,7 +12,11 @@ namespace Musify.Application.Tests.TestSupport public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - public ValueTask DisposeAsync() => database.DisposeAsync(); + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return database.DisposeAsync(); + } protected static ILogger NoOpLogger() => NullLogger.Instance; diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 177c128..a08d919 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -1,7 +1,9 @@ +using ErrorOr; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.Entities; +using Musify.Infrastructure.Persistence; namespace Musify.Application.Tests.TestSupport { @@ -33,99 +35,7 @@ namespace Musify.Application.Tests.TestSupport { base.OnModelCreating(modelBuilder); - modelBuilder.Entity().OwnsOne(track => track.Pictures); - modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); - - modelBuilder.Entity().OwnsOne(track => track.Audio); - modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity().OwnsOne(playList => playList.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).IsRequired(false); - pictures.Property(p => p.SmallName).IsRequired(false); - pictures.Property(p => p.MediumName).IsRequired(false); - pictures.Property(p => p.LargeName).IsRequired(false); - }); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); - - modelBuilder.Entity().OwnsOne(album => album.Pictures); - modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Album) - .WithMany(album => album.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.AlbumId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Track) - .WithMany(track => track.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(mix => new { mix.UserId, mix.Position }); - - modelBuilder.Entity() - .HasOne(item => item.Mix) - .WithMany(mix => mix.Items) - .HasForeignKey(item => item.MixId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(item => item.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); - - modelBuilder.Entity() - .HasIndex(like => new { like.UserId, like.TrackId }) - .IsUnique(); - - modelBuilder.Entity() - .HasOne(trackTag => trackTag.Track) - .WithMany(track => track.Tags) - .HasForeignKey(trackTag => trackTag.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) - .IsUnique(); - - modelBuilder.Entity() - .HasOne(follow => follow.Follower) - .WithMany() - .HasForeignKey(follow => follow.FollowerId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasOne(follow => follow.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) - .IsUnique(); + ModelConfiguration.Apply(modelBuilder); } public DbSet Users => Set(); @@ -138,16 +48,12 @@ namespace Musify.Application.Tests.TestSupport public DbSet Tracks => Set(); - public DbSet UserHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); public DbSet Mixes => Set(); public DbSet MixItems => Set(); - public DbSet Uploads => Set(); - public DbSet UploadIntents => Set(); public DbSet ListeningHistories => Set(); @@ -164,6 +70,19 @@ namespace Musify.Application.Tests.TestSupport return new SqliteDatabaseTransaction(transaction); } + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + { + return onUniqueViolation; + } + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index c10efba..1d1d104 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -79,7 +79,7 @@ namespace Musify.Application.Tests.TestSupport return track; } - public static PlayListPictures PlayListPictures() => new() + public static EntityPictures PlayListPictures() => new() { OriginalName = "original.webp", SmallName = "small.webp", @@ -91,12 +91,12 @@ namespace Musify.Application.Tests.TestSupport long userId, string name = "Test Playlist", string? description = null, - PlayListPictures? pictures = null, + EntityPictures? pictures = null, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - PlaylistVisibility visibility = PlaylistVisibility.Private) => + PlayListVisibility visibility = PlayListVisibility.Private) => new() { - UserId = userId, + OwnerUserId = userId, Name = name, NormalizedName = name.ToUpperInvariant(), Description = description, @@ -105,7 +105,7 @@ namespace Musify.Application.Tests.TestSupport Visibility = visibility }; - public static AlbumPictures AlbumPictures() => new() + public static EntityPictures AlbumPictures() => new() { OriginalName = "original.webp", SmallName = "small.webp", @@ -118,7 +118,7 @@ namespace Musify.Application.Tests.TestSupport string title = "Test Album", string? description = null, int? releaseYear = 2024, - AlbumPictures? pictures = null) => + EntityPictures? pictures = null) => new() { Title = title, @@ -154,14 +154,12 @@ namespace Musify.Application.Tests.TestSupport public static Mix Mix( long userId, - string title = "Test Mix", - string? subtitle = null, + MixKind kind = MixKind.Discovery, int position = 0) => new() { UserId = userId, - Title = title, - Subtitle = subtitle, + Kind = kind, Position = position }; diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 6b9a610..59a74c3 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -24,17 +24,28 @@ namespace Musify.Application.Tests.Tracks return new CreateTrackCommandHandler( Database, eventBus, - new UploadIntentValidator(Database, storageService), + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Track(), - TestConfigurations.UploadIntent()); + TestConfigurations.Track()); } private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); + [Fact] + public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() { @@ -78,34 +89,6 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(isExplicit, stored.IsExplicit); } - [Fact] - public async Task Handle_NoTags_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); - - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - - [Fact] - public async Task Handle_IncompatibleTags_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); - - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index da76df3..9af73d7 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -39,7 +39,7 @@ namespace Musify.Application.Tests.Tracks } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -48,7 +48,7 @@ namespace Musify.Application.Tests.Tracks var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs index 07d1fdf..8e80f4a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; @@ -22,19 +21,19 @@ namespace Musify.Application.Tests.Tracks var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.False(result.IsError); - Assert.Equal("Newer listen", result.Value.Title); + Assert.NotNull(result); + Assert.Equal("Newer listen", result.Title); } [Fact] - public async Task Handle_NoHistory_ReturnsNotFound() + public async Task Handle_NoHistory_ReturnsNull() { var owner = TestEntities.User(); await SeedAsync(owner); var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + Assert.Null(result); } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs deleted file mode 100644 index cd3b6fd..0000000 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using ErrorOr; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Tracks; -using Xunit; - -namespace Musify.Application.Tests.Tracks -{ - public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase - { - private GetTrackCoverQueryHandler CreateHandler() => - new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track()); - - [Theory] - [InlineData("small", "small.webp")] - [InlineData("medium", "medium.webp")] - [InlineData("large", "large.webp")] - [InlineData("unknown-size-falls-back-to-medium", "medium.webp")] - public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName) - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - - var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - Assert.Equal("image/webp", result.Value.ContentType); - } - - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - - [Fact] - public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures()); - await SeedAsync(owner, track); - - var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 1e6f20e..8a71777 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; -using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Tracks @@ -17,9 +16,7 @@ namespace Musify.Application.Tests.Tracks var mine = TestEntities.Track(owner, "Mine"); var theirs = TestEntities.Track(other, "Theirs"); await SeedAsync( - owner, other, mine, theirs, - new UserHasTrack { UserId = owner.Id, TrackId = mine.Id }, - new UserHasTrack { UserId = other.Id, TrackId = theirs.Id }); + owner, other, mine, theirs); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); @@ -27,20 +24,5 @@ namespace Musify.Application.Tests.Tracks var track = Assert.Single(result.Value.Items); Assert.Equal("Mine", track.Title); } - - [Theory] - [InlineData(0, 1)] - [InlineData(-5, 1)] - public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); - - var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(expectedPageNumber, result.Value.PageNumber); - } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 90f828e..0d5e7b8 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -16,15 +16,11 @@ namespace Musify.Application.Tests.Tracks var trackA = TestEntities.Track(owner, "Alpha"); var trackB = TestEntities.Track(owner, "Beta"); await SeedAsync( - owner, trackA, trackB, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id }); + owner, trackA, trackB); var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.Equal(2, result.Value.Items.Count); + Assert.Equal(2, result.TotalItemCount); + Assert.Equal(2, result.Items.Count); } [Fact] @@ -34,15 +30,11 @@ namespace Musify.Application.Tests.Tracks var match = TestEntities.Track(owner, "Bohemian Rhapsody"); var other = TestEntities.Track(owner, "Imagine"); await SeedAsync( - owner, match, other, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); + owner, match, other); var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Bohemian Rhapsody", item.Track.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Bohemian Rhapsody", item.Title); } [Fact] @@ -52,15 +44,11 @@ namespace Musify.Application.Tests.Tracks var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); await SeedAsync( - owner, rock, jazz, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + owner, rock, jazz); var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Loud", item.Track.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Loud", item.Title); } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs index d8f4050..e24ee6a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -110,18 +110,5 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } - [Theory] - [InlineData(-1)] - [InlineData(double.NaN)] - [InlineData(double.PositiveInfinity)] - public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds) - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index 8867b86..31aed91 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -18,17 +18,22 @@ namespace Musify.Application.Tests.Tracks private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + return new RequestTrackUploadUrlsCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), TestConfigurations.Storage(), TestConfigurations.Track(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); + config); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs similarity index 77% rename from backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs index de52989..dd2f28f 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs @@ -10,22 +10,21 @@ using Xunit; namespace Musify.Application.Tests.Tracks { - public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase + public sealed class StartListeningCommandHandlerTests : HandlerTestBase { private readonly IStreamTicketService ticketService = Substitute.For(); - private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) + private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null) { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); var issuer = new TrackStreamIssuer( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test"), playback ?? TestConfigurations.Playback()); - return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); } [Fact] @@ -35,7 +34,7 @@ namespace Musify.Application.Tests.Tracks var track = TestEntities.Track(owner); await SeedAsync(owner, track); - var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal("ticket-token", result.Value.Ticket); @@ -53,7 +52,7 @@ namespace Musify.Application.Tests.Tracks [Fact] public async Task Handle_TrackMissing_ReturnsNotFound() { - var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -65,7 +64,7 @@ namespace Musify.Application.Tests.Tracks var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); await SeedAsync(owner, track); - var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -79,7 +78,7 @@ namespace Musify.Application.Tests.Tracks var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } @@ -93,7 +92,7 @@ namespace Musify.Application.Tests.Tracks var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal("ticket-token", result.Value.Ticket); diff --git a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs new file mode 100644 index 0000000..389cbab --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tests.TestSupport; +using Musify.Application.UploadIntents; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.UploadIntents +{ + public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase + { + [Fact] + public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + { + var owner = TestEntities.User(); + var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); + var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); + await SeedAsync(owner, overdue, active); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); + + var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, changed); + var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); + Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); + } + + [Fact] + public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() + { + var owner = TestEntities.User(); + var old = TestEntities.UploadIntent( + owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); + await SeedAsync(owner, old); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); + + await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() + { + var storage = Substitute.For(); + storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( + new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), + new StorageObject("temp/new", DateTime.UtcNow))); + var handler = new DeleteStaleTempUploadsCommandHandler( + storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), + NoOpLogger()); + + var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); + await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); + } + + private static async IAsyncEnumerable Objects(params StorageObject[] objects) + { + foreach (var item in objects) + yield return item; + + await Task.CompletedTask; + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs deleted file mode 100644 index eb15fa6..0000000 --- a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ErrorOr; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Users; -using Xunit; - -namespace Musify.Application.Tests.Users -{ - public sealed class CreateUserCommandHandlerTests : HandlerTestBase - { - private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_NewId_CreatesUser() - { - var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("Jane Doe", result.Value.Name); - Assert.Equal(1, result.Value.Id); - - var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("JANE DOE", stored.NormalizedName); - } - - [Fact] - public async Task Handle_IdAlreadyExists_ReturnsConflict() - { - var existing = TestEntities.User(1); - await SeedAsync(existing); - - var command = new CreateUserCommand(1, "Someone Else", null, null); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index c27d93f..55910f1 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -102,7 +102,7 @@ namespace Musify.Application.Tests.Users } [Fact] - public async Task Handle_NoListenToday_StreakIsZero() + public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() { var user = TestEntities.User(); var track = TestEntities.Track(user); @@ -112,6 +112,20 @@ namespace Musify.Application.Tests.Users var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(1, result.StreakDays); + } + + [Fact] + public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(0, result.StreakDays); } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index a57cfb4..20a5f2e 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -15,9 +15,7 @@ namespace Musify.Application.Tests.Users await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); + Assert.Equal(2, result.TotalItemCount); } [Fact] @@ -26,9 +24,7 @@ namespace Musify.Application.Tests.Users await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var user = Assert.Single(result.Value.Items); + var user = Assert.Single(result.Items); Assert.Equal(1, user.Id); } @@ -40,9 +36,7 @@ namespace Musify.Application.Tests.Users new UserFollow { FollowerId = 1, FollowedId = 2 }); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs new file mode 100644 index 0000000..a9ac622 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs @@ -0,0 +1,23 @@ +using Musify.Infrastructure.Jobs; +using Xunit; + +namespace Musify.Infrastructure.Tests.Jobs +{ + public sealed class RecurringJobsRegistrarTests + { + [Theory] + [InlineData(1, "*/1 * * * *")] + [InlineData(59, "*/1 * * * *")] + [InlineData(60, "*/1 * * * *")] + [InlineData(61, "*/2 * * * *")] + [InlineData(300, "*/5 * * * *")] + [InlineData(3599, "0 */1 * * *")] + [InlineData(3600, "0 */1 * * *")] + [InlineData(7200, "0 */2 * * *")] + [InlineData(0, "*/1 * * * *")] + public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + { + Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs new file mode 100644 index 0000000..c829886 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -0,0 +1,206 @@ +using Musify.Application.Configuration; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit +{ + [Collection(InfrastructureCollection.Name)] + public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() + { + var album = await SeedAlbumAsync(new EntityPictures + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkAlbumAsRemoving, + ActivityNames.RemoveAlbumOriginalPicture, + ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, + ActivityNames.RemoveAlbumLargePicture, + ActivityNames.DeleteAlbumFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() + { + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() + { + var playList = await SeedPlayListAsync(pictures: null); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() + { + var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkPlayListAsRemoving, + ActivityNames.RemovePlayListOriginalPicture, + ActivityNames.RemovePlayListSmallPicture, + ActivityNames.DeletePlayListFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() + { + var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() + { + var track = await SeedTrackAsync( + new TrackPictures + { + OriginalName = "original.webp", + SmallName = "s.webp", + MediumName = "m.webp", + LargeName = "l.webp", + ProcessingStatus = ProcessingStatus.Completed + }, + new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackSmallPicture, + ActivityNames.RemoveTrackMediumPicture, + ActivityNames.RemoveTrackLargePicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.RemoveTrackProcessedAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => + new( + database, + new TrackConfiguration(), + new AlbumConfiguration(), + new PlayListConfiguration(), + new ApplicationStorageConfiguration { Bucket = "bucket" }); + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"delete-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = pictures, + Audio = audio + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs new file mode 100644 index 0000000..8c3930f --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs @@ -0,0 +1,186 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit +{ + [Collection(InfrastructureCollection.Name)] + public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task MarkTrack_ExistingTrack_SetsTheStatus() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task Mark_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + [Fact] + public async Task DeleteTrack_ExistingTrack_RemovesTheRow() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); + } + + [Fact] + public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteAlbumActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); + } + + [Fact] + public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeletePlayListActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); + } + + [Fact] + public async Task Delete_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + private static ExecuteContext ExecuteContextFor(TArguments arguments) + where TArguments : class + { + var context = Substitute.For>(); + context.Arguments.Returns(arguments); + context.CancellationToken.Returns(Ct); + return context; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + private async Task SeedAlbumAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs new file mode 100644 index 0000000..975319c --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs @@ -0,0 +1,109 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit +{ + public sealed class ProcessingSlipFaultConsumerTests + { + private static readonly Guid SubjectId = Guid.NewGuid(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); + + [Fact] + public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_UnknownKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Consume_WithoutProcessKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + private static async Task AssertPublishedAsync(string processKind, Func matches) + where TEvent : class + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); + + Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); + } + + private static Dictionary Variables(string processKind) => new() + { + [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, + [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, + [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", + [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", + [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", + }; + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddConsumer()) + .BuildServiceProvider(true); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs new file mode 100644 index 0000000..25ff4f9 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs @@ -0,0 +1,208 @@ +using MassTransit; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Infrastructure.MassTransit.Sagas; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit +{ + public sealed class ProcessingStateMachineTests + { + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Track + + [Fact] + public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } + && published.Context.Message.TrackId == id, Ct)); + } + + [Fact] + public async Task Track_AudioFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + // Album + + [Fact] + public async Task Album_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Album_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.AlbumId == id, Ct)); + } + + [Fact] + public async Task Album_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + // PlayList + + [Fact] + public async Task PlayList_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task PlayList_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.PlayListId == id, Ct)); + } + + [Fact] + public async Task PlayList_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + private static CreateTrackResourcesEvent TrackStarted(Guid id) => + new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private static ServiceProvider CreateProvider() + where TMachine : class, SagaStateMachine + where TState : class, SagaStateMachineInstance => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) + .BuildServiceProvider(true); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs new file mode 100644 index 0000000..de09a5b --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs @@ -0,0 +1,130 @@ +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit +{ + public sealed class RoutingSlipBuilderTests + { + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); + + private static readonly Guid SubjectId = Guid.NewGuid(); + private static readonly Guid IntentId = Guid.NewGuid(); + private static readonly Guid CorrelationId = Guid.NewGuid(); + + [Fact] + public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + { + var slip = RoutingSlips.Create(CorrelationId).Build(); + + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + var subscription = Assert.Single(slip.Subscriptions); + Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); + Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); + } + + [Fact] + public void Create_WithoutCorrelationId_UsesEmptyGuid() + { + var slip = RoutingSlips.Create(null).Build(); + + Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + } + + [Fact] + public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() + { + var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); + + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Contains(slip.Subscriptions, subscription => + subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) + && subscription.Events == RoutingSlipEvents.Faulted); + } + + [Fact] + public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() + { + var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); + } + + [Fact] + public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() + { + var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); + } + + [Fact] + public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() + { + var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); + } + + [Fact] + public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() + { + var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); + } + + [Fact] + public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() + { + var configuration = new WorkerConfiguration(); + var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); + + var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); + + Assert.Equal( + [ + ActivityNames.GenerateAudioWorkflowPaths, + ActivityNames.DownloadFile, + ActivityNames.TranscodeAudio, + ActivityNames.TransferFiles, + ActivityNames.UpdateTrackAudio, + ], + slip.Itinerary.Select(activity => activity.Name)); + Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); + } + + private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) + { + Assert.Equal( + [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], + slip.Itinerary.Select(activity => activity.Name)); + + Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); + Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + + var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal("source-key", copy["SourceKey"]?.ToString()); + Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs new file mode 100644 index 0000000..0f85942 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs @@ -0,0 +1,261 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using MassTransit.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.Pictures; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit +{ + /// Runs the picture-update activities inside real routing slips on the MassTransit test harness. + [Collection(InfrastructureCollection.Name)] + public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) + { + private const string SmallVariable = "Picture.Small"; + private const string MediumVariable = "Picture.Medium"; + private const string LargeVariable = "Picture.Large"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("new-original.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.Equal("medium.webp", stored.MediumName); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); + } + + [Fact] + public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() + { + var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; + Assert.Equal("new.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); + } + + [Fact] + public async Task Track_Execute_MarksThePicturesAsCompleted() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); + } + + [Fact] + public async Task Execute_MissingEntity_FaultsTheSlip() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + } + + [Fact] + public async Task Execute_MissingVariable_FaultsTheSlip() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = RoutingSlips.Create(null).AddStep( + "Update", + UpdateAlbumPictureActivity.ExecuteEndpointName, + new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); + } + + [Fact] + public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("old-original.webp", stored.OriginalName); + Assert.Null(stored.SmallName); + Assert.Null(stored.MediumName); + Assert.Null(stored.LargeName); + } + + [Fact] + public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); + Assert.Equal("old.webp", stored.OriginalName); + } + + private static async Task WaitUntilAsync(Func> condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!await condition() && DateTime.UtcNow < deadline) + await Task.Delay(100, Ct); + } + + private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) + { + var builder = RoutingSlips.Create(null).AddStep( + "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); + builder.AddVariable(SmallVariable, "pictures/small.webp"); + builder.AddVariable(MediumVariable, "pictures/medium.webp"); + builder.AddVariable(LargeVariable, "pictures/large.webp"); + return builder; + } + + private ServiceProvider CreateProvider() => + new ServiceCollection() + .AddScoped(_ => fixture.CreateDatabase()) + .AddMassTransitTestHarness(bus => + { + bus.SetKebabCaseEndpointNameFormatter(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddExecuteActivity(); + }) + .BuildServiceProvider(true); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"pictures-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures { OriginalName = "old.webp" }, + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + public sealed record AlwaysFailsArguments; + + public sealed class AlwaysFailsActivity : IExecuteActivity + { + public const string ExecuteEndpointName = "always-fails"; + + public Task Execute(ExecuteContext context) => + throw new InvalidOperationException("Forced failure"); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs deleted file mode 100644 index 50d7158..0000000 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using ErrorOr; -using Microsoft.Extensions.Caching.Memory; -using Musify.Infrastructure.Services; -using Xunit; - -namespace Musify.Infrastructure.Tests.Services -{ - public sealed class SingleFlightCacheTests - { - private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); - - [Fact] - public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() { calls++; return Task.FromResult>(calls); } - - var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); - var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); - - Assert.Equal(1, first.Value); - Assert.Equal(1, second.Value); - Assert.Equal(1, calls); - } - - [Fact] - public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() - { - var cache = CreateCache(); - var calls = 0; - var gate = new TaskCompletionSource(); - - async Task> Factory() - { - Interlocked.Increment(ref calls); - await gate.Task; - return 7; - } - - var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); - var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); - gate.SetResult(); - - var results = await Task.WhenAll(first, second); - - Assert.Equal(1, calls); - Assert.All(results, result => Assert.Equal(7, result.Value)); - } - - [Fact] - public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() - { - calls++; - return Task.FromResult>(Error.Failure(description: "boom")); - } - - await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); - await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); - - Assert.Equal(2, calls); - } - - [Fact] - public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() { calls++; return Task.FromResult>(calls); } - - await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); - await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); - - Assert.Equal(2, calls); - } - } -} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index d2420f0..f58fd78 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -3,7 +3,6 @@ using System.Net.Http.Headers; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; using Musify.Infrastructure.Tests.TestSupport; using Xunit; @@ -13,12 +12,10 @@ namespace Musify.Infrastructure.Tests.Services [Collection(InfrastructureCollection.Name)] public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime { - private Database database = null!; private StorageService service = null!; public ValueTask InitializeAsync() { - database = fixture.CreateDatabase(); var storageConfiguration = new InfrastructureStorageConfiguration { Address = fixture.S3ServiceUrl, @@ -27,11 +24,11 @@ namespace Musify.Infrastructure.Tests.Services ForcePathStyle = true, UseHttp = true, }; - service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + service = new StorageService(fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); return ValueTask.CompletedTask; } - public ValueTask DisposeAsync() => database.DisposeAsync(); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Fact] public async Task UploadThenGetFile_RoundTripsTheSameBytes() @@ -42,12 +39,21 @@ namespace Musify.Infrastructure.Tests.Services await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + Assert.NotNull(stream); using var reader = new StreamReader(stream, Encoding.UTF8); var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); Assert.Equal("hello seaweedfs", readBack); } + [Fact] + public async Task GetFileAsync_MissingObject_ReturnsNull() + { + var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(stream); + } + [Fact] public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() { @@ -105,28 +111,13 @@ namespace Musify.Infrastructure.Tests.Services Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order()); } - [Fact] - public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix() - { - var prefix = $"tests/{Guid.NewGuid():N}"; - await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); - await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken); - - await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken); - - var remaining = new List(); - await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) - remaining.Add(item.Key); - Assert.Empty(remaining); - } - [Fact] public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() { var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; var uploadUrl = await service.GetUploadUrlAsync( - InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); using var httpClient = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) diff --git a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs index 1e1f45d..622507f 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -1,13 +1,13 @@ using System.Text.Json; using Amazon.S3; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Persistence; using Testcontainers.PostgreSql; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Containers; using Xunit; namespace Musify.Infrastructure.Tests.TestSupport @@ -18,6 +18,8 @@ namespace Musify.Infrastructure.Tests.TestSupport public const string S3SecretKey = "musify-test-secret"; public const string S3Bucket = "musify-test-bucket"; + private static readonly string[] S3Actions = ["Admin", "Read", "Write"]; + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") .WithDatabase("musify_test") .WithUsername("postgres") @@ -83,17 +85,17 @@ namespace Musify.Infrastructure.Tests.TestSupport { name = "musify-test", credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, - actions = new[] { "Admin", "Read", "Write" } + actions = S3Actions } } }); return $""" - cat > /tmp/s3.conf <<'JSON' - {identity} - JSON - exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf - """; + cat > /tmp/s3.conf <<'JSON' + {identity} + JSON + exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf + """; } } diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs index c454c9a..3bcd9fc 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -1,5 +1,5 @@ +using System.Globalization; using System.Security.Cryptography; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Authentication; @@ -25,19 +25,19 @@ namespace Musify.StreamingGateway.Tests.Authentication File.Delete(publicKeyPath); } - private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions + private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration { PublicKeyPath = publicKeyPath, Audience = Audience, Issuer = Issuer - })); + }); private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) { var claims = new Dictionary { ["prefix"] = prefix }; if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); var descriptor = new SecurityTokenDescriptor { diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 9082339..248e480 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -1,7 +1,7 @@ +using System.Globalization; using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Authentication; @@ -17,12 +17,12 @@ namespace Musify.StreamingGateway.Tests.Middleware private readonly string publicKeyPath = Path.GetTempFileName(); private readonly RSA rsa = RSA.Create(2048); - private readonly StreamTicketValidationOptions options; + private readonly StreamTicketValidationConfiguration options; public TicketValidationMiddlewareTests() { File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - options = new StreamTicketValidationOptions + options = new StreamTicketValidationConfiguration { PublicKeyPath = publicKeyPath, Audience = "media-gateway", @@ -40,7 +40,7 @@ namespace Musify.StreamingGateway.Tests.Middleware { var claims = new Dictionary { ["prefix"] = prefix }; if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { @@ -57,8 +57,8 @@ namespace Musify.StreamingGateway.Tests.Middleware { var nextCalled = false; RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; - var validator = new TicketValidator(Options.Create(options)); - var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); + var validator = new TicketValidator(options); + var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger.Instance); return (middleware, () => nextCalled); } @@ -216,5 +216,38 @@ namespace Musify.StreamingGateway.Tests.Middleware Assert.True(wasNextCalled()); Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); } + + [Theory] + [InlineData("bytes=0-10,20-30")] + [InlineData("garbage")] + [InlineData("items=0-10")] + public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = requestedRange; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=-500"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } } } diff --git a/backend/docs/backend-refactor-handoff.md b/backend/docs/backend-refactor-handoff.md new file mode 100644 index 0000000..4ab7eea --- /dev/null +++ b/backend/docs/backend-refactor-handoff.md @@ -0,0 +1,235 @@ +# Handoff: backend refactor, parts P12 to P15 + +> For the next AI. Read this file first, then `backend/docs/backend-refactor-plan.md` +> (section A, B, and the parts P12–P15, plus the Log in section D). Both files are +> **untracked on purpose**: never commit them. + +## 1. Where things stand + +- Worktree: `C:\Users\XShin\OneDrive\Documentos\Musify\MusifyM\.claude\worktrees\backend-refactor-plan-9b4465` +- Branch: `claude/backend-refactor-plan-9b4465` (base `master` at `87c3f97`). Nothing is pushed. +- Done and committed: **P0 to P11** (one commit per part, see `git log --oneline`). +- Left: **P12** (API layer), **P13** (Worker and Gateway hosts), **P14** (tests), **P15** (final sweep and docs). +- State at the last commit (`405df08`): build with 0 warnings, 365 tests green + (Infrastructure tests need Docker), `npm run check` in `web-player/` clean. +- The Log (section D of the plan) already has notes for P0–P11 and is the source of truth + for decisions taken. Update it (checkbox, date, notes) for every part you finish. + +### Deviations from the plan already taken (do not undo) + +- `IOwnedEntity` lives in `Domain/Abstractions` and has no `Id`; `IHasLifeCycle` provides it. + `FindOwnedAsync` requires both and treats non-active entities as NotFound. +- List query records keep `int PageNumber, int PageSize`; the handler builds a `PageRequest`. + The API binds them through `Api/Models/PageQuery` (camelCase names) + `PageQueryValidator`. +- Events and `Publish*Arguments` carry `ImageSizes Sizes`. +- Cover endpoint helper `CoverEndpoint.Stream` already exists (this was P12.2). +- Mix titles were replaced by `MixKind`; the web-player renders the text (`src/lib/data/mixes.ts`). +- The two migrations planned in P4 were merged into `DropUploadTableAndTrackAudioRetryColumns`. +- Adding a duplicate track to a playlist or album is now a silent success (bulk endpoints). +- Hangfire cron has 1-minute resolution, so job intervals in seconds are rounded up to minutes. +- Pending inside P11: log-and-rethrow still present in `MassTransit/Activities/Files/*` + (Copy, Download, Remove, Transfer, Upload). Clean it in P15 or when touching them. + +## 2. Rules the user gave (follow them exactly) + +1. **Language of the conversation: Spanish.** Talk to the user in Spanish. +2. **Commit messages: English, only about the code that changed.** Never mention the plan, + part numbers (P0, P1...), the Log, or "step X". One commit per part, imperative mood, + e.g. `Simplify infrastructure services and move all recurring jobs to Hangfire`. + (This is also saved in the memory file `feedback_commit_messages.md`.) +3. **Never include the plan documents in commits.** `backend/docs/backend-refactor-plan.md` + and `backend/docs/backend-refactor-handoff.md` stay untracked. Also never commit the + temporary test `backend/Tests/Musify.Api.Tests/Endpoints/ZzOpenApiDumpTests.cs`. + Stage with `git add backend web-player` and then + `git reset -q backend/docs backend/Tests/Musify.Api.Tests/Endpoints/ZzOpenApiDumpTests.cs`. +4. **Do the plan in parts, one part per commit, in order.** Do not mix parts. Never mix + formatting with behaviour. +5. **When the user says to stop at a point, stop there** (the last message was "para en esta + parte cuando termines", so work stopped after P11). Only continue when asked. +6. Follow the plan's own rules: read section A first; create a shared helper from section B + yourself if it does not exist yet; keep the Log and the findings index (section E) up to date; + a contract change needs the web-player regeneration (section 4 below); an EF model change + needs a migration and `TestDatabase` kept in sync; a MassTransit message change must be + noted in the Log ("drain queues before deploying"). +7. Pronouns: use they/them for anyone whose pronouns are not stated. +8. Outward-facing or hard-to-reverse actions (push, PR, deleting things) need explicit approval. + Nothing has been pushed; do not push or open a PR unless asked. +9. Report faithfully: if a test fails or something was skipped or unverifiable + (e.g. manual end-to-end runs), say so in the Log and in the final message. + +## 3. Coding conventions (from the plan, section B.2, keep applying them) + +- File-scoped namespaces, usings outside, sorted (`.editorconfig` enforces it; the build treats + warnings as errors, including unused usings IDE0005). +- Private fields camelCase without underscore; constants PascalCase; explicit accessibility. +- Application: `/.cs` holds record + handler; responses in `Responses/`. +- Errors via `AppErrors` (`NotFound`, `Forbidden` = 403 for "not owner", `Conflict`); + `Unauthorized` (401) only for "not signed in". Success value is `Result.Success`. +- No try/catch around `SaveChangesAsync`/`PublishAsync` in handlers (outbox: publish then save + is atomic). Infrastructure either handles or rethrows, never log-and-rethrow. +- Handlers that cannot fail return plain types; lists are paginated, ordered with `Id` tiebreak, + public reads return only `LifeCycleStatus.Active`. +- Text: trim stored names; normalized columns use `TextNormalizer.Normalize`. +- `ArgumentNullException.ThrowIfNull(x)` without `nameof`. +- User-facing strings in English (the web-player owns translation). +- Naming: `PlayList` everywhere; config POCOs end in `Configuration`; injected services are + named after their type (`storageService`). + +## 4. Practical procedures (learned the hard way) + +Run everything from `backend/` unless stated. + +### Build, format fixes, tests + +```bash +dotnet build Musify.slnx +dotnet test Musify.slnx +``` + +- Do **not** chain `dotnet test ... | grep && commit`: a failing run can look successful. + Read the totals (`total:`, `succeeded:`) before committing. +- Infrastructure and Api tests need Docker Desktop running. If `docker info` fails, start + `C:\Users\XShin\AppData\Local\Programs\DockerDesktop\Docker Desktop.exe` and wait. +- Unused-using errors (IDE0005) are common after edits. A helper script removes them: + `python /fixusings.py` (loops `dotnet build`, deletes the flagged using lines). + If the scratchpad is gone, do it by hand or with `dotnet format`. + +### Editing files from the shell (important) + +- The Bash tool **fails to parse** many commands that combine several heredocs, inline + `python - <<'EOF'` with backslashes/quotes, or big C# blocks ("unexpected EOF while looking + for matching `'`"). When that happens nothing runs. Use the **Write tool** for files and put + Python edit scripts in a file (in the scratchpad) and run `python that_file.py`. +- Working tree files use LF while git has autocrlf on; warnings like "LF will be replaced by + CRLF" are noise. Redirect stderr (`2>/dev/null`) on `git add/commit`. Python edit helpers + should read/write with `newline=''` and preserve CRLF if present. + +### EF migrations + +```bash +export Database__ConnectionString="Host=localhost;Database=x;Username=x;Password=x" +dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure +``` + +- `dotnet ef migrations remove` needs a DB connection and fails offline: delete the migration + files by hand and `git checkout` the snapshot, then re-add. +- To verify a model refactor keeps the schema: add a migration named `Check`, confirm `Up` + is empty, delete its two files (keep snapshot changes if they only rename types). +- Hand-edit migrations when data must be preserved (see `MixKind`, `PlayListTrackUnique`). +- `Application.Tests/TestSupport/TestDatabase.cs` mirrors part of `Database.OnModelCreating` + (P14.3 will extract `ModelConfiguration.Apply`). + +### Web-player contract regeneration (when an HTTP contract changes) + +1. `web-player/node_modules` is a junction to the main checkout's `node_modules` + (git-ignored). If missing: + `cmd //c "mklink /J web-player\node_modules ..\..\..\web-player\node_modules"` from the worktree root. +2. The OpenAPI document is dumped by the temporary test `ZzOpenApiDumpTests` (untracked; it + returns early unless env `OPENAPI_OUT` is set and runs the API in Development). Script used: + +```bash +cd backend +export ASPNETCORE_ENVIRONMENT=Development OPENAPI_OUT="/openapi.json" +dotnet test Tests/Musify.Api.Tests --filter "FullyQualifiedName~ZzOpenApiDump" +cd ../web-player +npx -y openapi-typescript@7 "/openapi.json" -o src/lib/api/schema.d.ts +npm run check +``` + + If the test file is gone, recreate it: it fetches `/openapi/v1.json` through + `fixture.CreateAnonymousClient()` and writes it to `OPENAPI_OUT`. + P12.1 changes `WebApplication.CreateBuilder(args)`; keep the dump working afterwards. +3. Fix web-player call sites, run `npx prettier --write` on touched files, then `npm run check`. +4. Delete `ZzOpenApiDumpTests.cs` at the very end (P15). + +## 5. What is left, part by part + +### P12: API layer (plan section C, P12) + +1. `Program.cs`: `WebApplication.CreateBuilder(args)`, sorted usings, CORS constant at top. +2. Cover endpoints: **already done** with `CoverEndpoint.Stream`. +3. Routes: resource id is `{id}`, sub-resources `{trackId}`; Guid routes use `:guid`, + user ids `:long`. Playlist routes keep `/playlists`. (Contract change: regenerate.) +4. Every endpoint returns `IResult` through `ToHttpResult` (some still return raw types, + e.g. `GetListeningHistory`, `IsFollowing`); use `Results.Ok` where the handler cannot fail. +5. DTOs: drop the `New...` prefix (`UpdateAlbumRequest(Title, Description, ReleaseYear, + PictureIntentId)`; same for playlists). Decision recommended in the plan: rename. +6. Validators: merge the album/playlist picture upload DTOs into one + `PictureUploadRequest` and one validator (the responses are already merged). +7. `OpenApiOptionsSetup` and `ScalarOptionsSetup` share the `SecuritySchemeId` constant. +8. `.WithSummary(...)` strings in sentence case, no trailing period. (Current ones are Title Case.) +9. `JwtBearerOptionsSetup`: single-line guard without braces. +10. `Validators/Limits.cs` with every validation limit (playlist name 50, descriptions 256, + titles 200, user name 48, release year range). List them in the Log for the web-player + plan (P7.1). Database column length wins on disagreement. + +Coordination table (section A.4): P3.1 and P8.1 of the web-player plan are unblocked by P7; +P8.4 (bulk add) by P9; P7.1 (shared limits) by P12.10. Tick them in the web-player plan's Log too +(`web-player/docs/frontend-audit-plan.md`, also untracked). + +### P13: Worker and Gateway hosts + +1. Worker `Program.cs`: call `AddApplication(configuration)` instead of registering Application + options by hand (Album config is missing today), add `AddObservability`, remove + `ValidateOnBuild = false` and make the container validate. Register the extra POCO configs + the handlers need (stream tickets, gateway config) rather than disabling validation. + Then `AddUploadIntentConfiguration` (kept only for the Worker) can go. +2. Worker `appsettings.json`: complete and consistent with the Api for shared sections + (add `Album`; keys were renamed in P5), remove stale ones. +3. Gateway: keep option registration local and self-contained; field naming without + underscores (`TicketValidator` uses `_options`, `_rsa`, ...; the middleware field that + shadowed a constructor parameter is already gone); replace the default bucket in the + YARP transform of `appsettings.json` with a placeholder like `/buckets/CHANGE_ME/{**rest}` + (compose overrides it) or document it in `docs/streaming.md`. + Check `deploy/` when renaming config keys. + +### P14: Tests + +1. Add tests for what has none: MassTransit activities (generic lifecycle and picture-update + ones 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; sagas already use + `OnMissingInstance(Discard)`), `RecurringJobsRegistrar.EveryCron` (internal, may need + `InternalsVisibleTo`). +2. Endpoint tests for visibility and ownership if not already covered (P1, P3 added several). +3. Extract the owned-type and index configuration of `Database` into + `ModelConfiguration.Apply(ModelBuilder)` in Infrastructure and call it from `Database` and + `TestDatabase` (`Application.Tests` then references Infrastructure; note it in the Log). + +### P15: Final sweep and documentation + +1. Recompute the metrics of section A.5 (script in the plan) and fill D.1 (baseline was + 12,687 non-migration, non-test `.cs` lines; targets in A.5). Also record the MassTransit + line count (3,547 to about 2,530) and the Application line count. +2. Grep for leftovers: `Error.Unauthorized(`, `StaticPagedList`, `new ImageSize(` (only in + `PictureSizes.ToImageSizes`), `ThrowIfNull(.*nameof`, `#pragma warning disable CS8618`, + try blocks around `SaveChangesAsync` in `Application/`, `serviceDescriptors`, + `storageHandler`, `PlaylistVisibility`, log-and-rethrow in `MassTransit/Activities/Files`. +3. The analyzer rules downgraded to `suggestion` in `.editorconfig` in P0 (CA1848, CA1873, + CA1305, CA1707, CA1859, CA1716, CA1000, CA1725, CA1816, CA1822, CA1852, CA1068, CA1711, + CA1861) should be revisited: fix the cheap ones and remove their overrides. +4. Run `dotnet format Musify.slnx` and check the diff is whitespace only (some test files have a + stray blank line before the closing brace). +5. Update `docs/architecture.md`, `docs/media-processing.md`, `docs/projects.md`, + `docs/storage.md`, `docs/streaming.md` with the new names (jobs, entities, routes, config + keys such as `ParentFolder`, `Temp*`, activity/queue names, `MixKind`). +6. Add a short `backend/CLAUDE.md` with the conventions of section 3 above (this one **is** + committed). +7. Delete `ZzOpenApiDumpTests.cs`, keep the plan (section E rows should all point to a checked + part in D), and remove the `web-player/node_modules` junction only if desired (it is ignored). + +## 6. Deployment notes to keep in the Log + +Queues must be drained before deploying because these message contracts changed: +processing-slip process kinds and variables (P2), `ImageSizes` in events and arguments (P8), +new lifecycle/delete/update-picture activities and arguments (P11), `DeleteAlbumEvent` (P3). +Migrations added so far: `PlayListTrackUnique`, `DropUploadTableAndTrackAudioRetryColumns`, +`DropUserHasTrack`, `MixKind`. Old bucket objects under the `temporal/` prefix (from before the +`Temp*` config fix) may need a one-off clean-up. + +## 7. Suggested first steps for the next session + +1. `git log --oneline -14` and `git status --short` (expect only the two untracked docs and + `ZzOpenApiDumpTests.cs`). +2. Run build + tests to confirm the green baseline. +3. Start P12, commit, update the Log, then continue only if the user has not asked to stop. diff --git a/backend/docs/backend-refactor-plan.md b/backend/docs/backend-refactor-plan.md new file mode 100644 index 0000000..98b53a3 --- /dev/null +++ b/backend/docs/backend-refactor-plan.md @@ -0,0 +1,1108 @@ +# 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`, `.csproj` and `appsettings*.json` file under `backend/` +> (2026-09-24, branch `master` at `87c3f97`), plus the web-player call sites of the API. + +## How to use this document + +1. **Always read section A (Common context).** It's short and every part assumes it. +2. Pick **one part** (P0 through P15). Each part lists: objective, prerequisites, files to + read, tasks, and closing criteria. +3. One part equals one commit/PR. Don't mix parts. P0 is a pure formatting commit: never + mix formatting with behaviour changes. +4. 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. +5. When you finish a part, check its box in section D (Log) and fill in the date and notes. +6. 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 (`/.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/`: + +```bash +dotnet build Musify.slnx +``` + +```bash +dotnet test Musify.slnx +``` + +- The build must stay at **0 warnings** (after P0, warnings are errors). +- `Infrastructure.Tests` need 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:api` in + `web-player/`, needs the API on `http://localhost:5111`), fix the web-player call sites, and + run `npm run check` in `web-player/`. List the contract changes in the Log. +- A part that changes the **EF model** must add a migration from `backend/`: + +```bash +dotnet ef migrations add --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/Events` or + `Infrastructure/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) + +1. **Outbox.** The API uses the EF bus outbox (`UseBusOutbox`). `eventBus.PublishAsync` only + writes to the outbox; the message leaves when `SaveChangesAsync` commits. So "publish, then + save" is correct and atomic. Don't wrap the publish in its own try/catch. +2. **Worker DI.** `AddMediator()` registers **every** handler, including ones whose dependencies + the Worker doesn't have. Today that's hidden by `ValidateOnBuild = false`. P13 fixes it; until + then, don't add handler dependencies that the Worker can't resolve if the Worker sends that + command. +3. **Private IDs as strings.** `long` user ids are serialized as strings (JS precision). Keep that + when moving the converter (P9). +4. **Track pictures vs album/playlist pictures.** New tracks point at shared *preset* images until + processed; `DeleteTrackRoutingSlipBuilder` only deletes resized files when + `Pictures.IsProcessed`, so presets are never deleted. Keep that guard. +5. **Deploy overrides.** `deploy/compose.yml` overrides config via env vars (`Section__Key`). When + renaming a config key, grep `deploy/` too. +6. **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) + +```ini +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) + +```xml + + + + net10.0 + enable + enable + latest-recommended + true + true + true + $(NoWarn);CS1591 + + + +``` + +`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) + +1. **File layout.** `Application//.cs` holds the command/query record and its + handler. Responses live in `Application//Responses/`. No response records declared + inside handler files, no mapping methods on commands. +2. **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). **Never** `Unauthorized` for 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. +3. **Success value:** `Result.Success`. +4. **No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers.** Unexpected + exceptions go to the global `UseExceptionHandler`. Catch only what you can turn into a + domain error (e.g. unique-constraint violation → `Conflict`, see B.3.7). +5. **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. +6. **Queries never write.** If a read needs a side effect, it's a command. +7. **Return types.** A handler that can't fail returns the plain type (`ValueTask`), not + `ErrorOr`. A handler that can fail returns `ErrorOr`. Never return a lazy `IEnumerable`. +8. **Lists** are paginated with `PageRequest` (B.3.4) and have a deterministic order with `Id` as + the final tiebreaker. Public reads only return `LifeCycleStatus.Active` rows. +9. **Text input:** stored names/titles are trimmed; normalized columns use + `TextNormalizer.Normalize` (B.3.5). +10. **Entities:** no `[Required]` on value types, no `[Required]` next to `required` on + 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`, no `required` on navigations). +11. **Naming:** `PlayList` everywhere (types, variables, route parameters: `{playListId}` → + see P12 for the route style). `…Configuration` for config POCOs. Injected services are named + after their type (`storageService`, not `storageHandler`). +12. **`ArgumentNullException.ThrowIfNull(x)`** without `nameof`. +13. 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. + +```csharp +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/ + 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; } +} +``` + +- `AlbumConfiguration` and `PlayListConfiguration` become `{ PictureRoutes Routes; PictureSizes PicturesSizes; }` + implementing `IPictureOwnerConfiguration`. +- `TrackConfiguration.Routes` becomes `TrackRoutes : PictureRoutes` adding only the preset and + audio members (`PresetSmallPicture…`, `OriginalAudiosFolder`, `ProcessedAudiosFolder`, + `BuildOriginalAudioPath(userId, name)`, `BuildProcessedAudioPath(folder)`). + `BuildTempAudioPath` is deleted (it was identical to the picture one): use `BuildTempPath`. +- Config keys change: `ParentFolders` → `ParentFolder` (Album/PlayList), `ProcessedAudioFolder` → + `ProcessedAudiosFolder`, `SmallPictureWidth` → `SmallWidth` etc. Update every `appsettings*.json` + and grep `deploy/`. +- The un-prefixed `BuildOriginalPicturePath(string name)` overloads and the `…PicturesPath` + properties that nobody needs after P8 are removed. + +#### B.3.2 `ImageSizes` (`Application/Shared/ImageSize.cs`) + +```csharp +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: + +```csharp +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). + +```csharp +public record PageRequest(int PageNumber = 1, int PageSize = 20) +{ + public const int MaxPageSize = 100; +} + +public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); + +public static class PaginationExtensions +{ + public static Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken); +} +``` + +- `ToPaginatedAsync` clamps `PageNumber >= 1` and `1 <= PageSize <= MaxPageSize`, does + `CountAsync` + `Skip/Take` on the **same** filtered query. +- The query passed in must already be ordered and projected (B.3.5). +- The JSON shape of `PaginatedResponse` stays exactly as today (same property names and order). +- API side: endpoints bind `[AsParameters] PageRequest page`; `PageRequestValidator` rejects + `PageNumber < 1` and `PageSize` outside `1..100` with a 400. + +#### B.3.5 Projections and text (`Application//Responses/*Projections.cs`, `Application/Shared/TextNormalizer.cs`) + +```csharp +public static class TrackProjections +{ + public static IQueryable Active(this IQueryable query); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class AlbumProjections +{ + public const int CoverTrackCount = 4; + public static IQueryable Active(this IQueryable query); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class PlayListProjections +{ + public const int CoverTrackCount = 4; + public static IQueryable Active(this IQueryable query); + public static IQueryable VisibleTo(this IQueryable query, long? viewerId); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class TextNormalizer +{ + public static string Normalize(string value); // value.Trim().ToUpperInvariant() +} +``` + +- `SelectResponse` is a single EF-translatable `Select(...)` building the response directly + (owner name, tag list, `ListeningHistories.Count(l => l.IsCounted)`, track count, cover ids). + No `Include`, no anonymous intermediate types, no `FromEntity` in queries. +- Keep `FromEntity` only 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 call `SelectResponse()`. + +#### B.3.6 Errors and ownership (`Application/Shared/AppErrors.cs`, `Application/Shared/OwnershipExtensions.cs`) + +```csharp +public static class AppErrors +{ + public static Error NotFound(string entity, object id); // code ".NotFound" + public static Error Forbidden(string entity, object id); // code ".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> FindOwnedAsync( + this DbSet 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`) + +```csharp +Task> 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`) + +```csharp +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`. + +```csharp +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** +1. Create `backend/.editorconfig` (B.1.1) and update `Directory.Build.props` (B.1.2). +2. Remove BOMs from all `.cs` files (including the two mid-file ones in + `Application/Events/UpdatePlayListPictureEvent.cs` and `UpdateTrackPictureEvent.cs`). +3. From `backend/`: + +```bash +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** +1. **Delete `POST /users`** (anonymous, lets anyone create a user with any id). Remove + `CreateUserCommand`, `CreateUserRequest`, `CreateUserRequestValidator` and their tests. Users + are provisioned only by `SyncUserCommand` on token validation. The web-player doesn't call it. +2. **Private playlists:** + - `GET /playlists` (all users' playlists, not used by the web-player): delete it and + `GetPlayListsQuery`. + - `GET /playlists/users/{userId}`: remove the `onlyPublic` parameter; the handler gets + `ViewerId` and applies `VisibleTo(viewerId)` (owner sees everything, others only public). + Contract change: web-player `user/[id]/+page.server.ts` stops sending `onlyPublic`. + - `GET /playlists/{id}/tracks`: take `CurrentUser`, return `NotFound` when the playlist isn't + visible to the viewer (same rule as `GetPlayListById`). + - `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 become `private`. +3. **Upload intents:** `ValidateAndLoadAsync` takes the expected `UploadIntentPurpose` and returns + `Validation` if it doesn't match. `CreateTrack` rejects `PictureIntentId == AudioIntentId`. +4. **Upload request validation** (both picture validators and the track one): + - `FileType`: letters/digits only, 1–10 characters, from an allow-list (`jpg, jpeg, png, webp` for + pictures; `mp3, wav, flac, ogg, m4a, aac` for audio). Put the lists in one static class in + `Hosts/Musify.Api/Validators/Uploads.cs` so the three validators share them. + - `ContentType`: must match the allow-list (`image/*` subset, `audio/*` subset). + - `ExpectedSizeBytes`: `> 0` and `<= UploadIntentConfiguration.MaxUploadBytes`. +5. **Gateway range clamp:** when a ticket has `maxBytes`, an unparsable `Range` header or a + multi-range request must be **replaced** by `bytes=0-{maxBytes-1}` (not passed through). + Handle suffix ranges (`bytes=-N`) by rejecting them with 416 when `maxBytes` is set. +6. **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). Leave `listening-history` and `last-listened-track` public (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** +1. `ProcessingSlipFaultConsumer`: add the `AlbumPicture` case publishing + `AlbumPictureProcessingFailed`. Today album sagas stay in `Processing` forever on failure. +2. Config keys: the appsettings use `TempRootPrefix`, `TempUploadsRetentionDays`, + `TempCleanupJobIntervalSeconds`; the class expects `TemporalRootPrefix`, + `TemporalUploadsRetentionDays`, `TemporalCleanUpJobIntervalSeconds`, so they're silently + ignored. Rename the **class** properties to `TempRootPrefix`, `TempUploadsRetentionDays`, + `TempCleanupJobIntervalSeconds` (shorter, and what ops already configured), keep the value + `"temp"`. Check that no object with the `temporal/` 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. +3. 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 + `Pending` forever. Add `TrackFaults(...)` with new process kinds `TrackCreation`, + `AlbumCreation`, `PlayListCreation` that publish the entity's `*ProcessingFailed` event + directly (so the existing failed-consumers mark it as failed and remove files). +4. Duration precision: `TranscodeAudioActivity` stores `(int)Math.Round(...)`; store the `double` + `TotalSeconds`. +5. `UpdateTrackAudioActivity`: when the folder key is empty it publishes `TrackAudioProcessed` + without marking the audio as completed. Throw instead (it's a programming error) so the slip + faults and the saga marks the track failed. +6. Sagas and picture **updates**: `*PictureProcessed` / `*PictureProcessingFailed` for an update + arrive at a saga that was finalized after creation. Configure + `OnMissingInstance(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 `*_error` queues. + 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** +1. **403 for "not owner":** replace the 10 `Error.Unauthorized()` with `AppErrors.Forbidden` + (B.3.6). In `ErrorOrHttpExtensions`, map `Forbidden` to `Results.Problem(statusCode: 403)`, + and make `NotFound`/`Conflict` return ProblemDetails too (today they return a bare string). + Update `.Produces(401)` to `.Produces(403)` on owner-only endpoints. Contract change. +2. **Pagination bounds:** introduce `PageRequest` + validator (B.3.4) on every paginated endpoint + (today `pageSize=0` → 500, `pageSize=1000000` → unbounded). Bound `limit` on + `/albums/recent` to `1..50`. Remove the ad-hoc clamp in `GetTracksByUserId`. +3. `GetPlayListsByUserId`: `totalCount` is computed before the `Active` filter; move the filter + before counting (P7 will replace this with `ToPaginatedAsync` anyway). +4. **Lifecycle filter:** public reads (`GetTracks`, `GetTracksByUserId`, `GetTrackById`, + `GetAlbums*`, `GetAlbumById`, `GetAlbumTracks`, `GetPlayListTracks`, `GetLikedTracks`, + `GetGenres`, `MixItemMapper`, `GetListeningHistory`, `GetRecentlyListenedAlbums`) only return + `Active` rows. Stream only `Active` tracks. +5. **Covers before processing:** `CreateAlbum`/`UpdateAlbum`/`CreatePlayList`/`UpdatePlayList` + set `Small/Medium/LargeName = objectName`, so `/cover?size=small` looks for + `…/SmallPictures/{objectName}`, which doesn't exist until processing ends → S3 throws → 500. + Fix: resized names stay `null` until the worker sets them (`EntityPictures.Pending` in B.3.3 + sets only `OriginalName`), 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 (`HeadObjectAsync` or catch `NoSuchKey` in the storage service → `null`). +6. **Races → 500:** use `TrySaveChangesAsync` (B.3.7) in `ToggleTrackLike`, `FollowUser`, + `AddTrackToPlayList`, `AddTrackToAlbum`, mapping the unique violation to a no-op success + (like/follow) or `Conflict` (add track). Add a unique index on + `PlayListHasTrack (PlayListId, TrackId)` (migration; dedupe existing rows in the migration + with `DELETE … USING` keeping the lowest position). +7. **Track/position numbering:** compute next position with + `MaxAsync(x => (int?)x.Position) ?? -1` in 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). +8. `UpdatePlayList`: trim the name; allow clearing the description (`""` → `null`; `null` means + "unchanged"); validate `NewVisibility` with `IsInEnum()`. `CreatePlayList`: trim the name. + Same `IsInEnum()` on `CreatePlayListRequest.Visibility`. +9. `GetMixesByUserId` N+1: one query for all cover items of all mixes. +10. `GetLastTrackListenedByUserId` counts uncounted listens while the history only shows counted + ones: filter `IsCounted` for consistency, and return 204 (not 404) when there's none (check + the web-player call site in `(app)/+page.server.ts` or wherever it's read). +11. `GetListeningStats` streak: count the streak ending today **or yesterday** (a streak isn't + broken until the day is over). +12. `DeleteAlbum` removes the row directly and leaves its pictures in storage. Give it the same + shape as playlists: mark `Removing`, publish `DeleteAlbumEvent`, and a + `DeleteAlbumRoutingSlipBuilder` that 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** +1. `Infrastructure/Services/SingleFlightCache.cs` + its test + `Microsoft.Extensions.Caching.Memory` + from `Infrastructure.csproj` (the API gets `IMemoryCache` from `AddMemoryCache`). +2. `IStorageService.GetUrlAsync` and `RemoveFolderAsync` (only tests use the latter) + tests. +3. `IAudioTranscoderService.IsValidAudioFileAsync` + implementation + `BuildValidateAudioArguments`. +4. `DownloadFileFromUrlActivity`, `DownloadFileFromUrlArguments`, `DownloadFileFromUrlLog`, its + registration, `ActivityNames.DownloadThumbnail`, and `services.AddHttpClient()` in the Worker. +5. `TrackAudio.DownloadRequested`, `RetryCount`, `LastRetryAt` and their mapping (migration + `DropTrackAudioRetryColumns`). +6. `Upload` entity, `UploadState` enum, `IDatabase.Uploads`, the `Upload` writes in + `StorageService` (migration `DropUploadTable`). Update `docs/architecture.md` (mentions + `Upload`). +7. `DatabaseDesignTimeFactory`: remove the unused `optionsBuilder`. +8. `Database.cs`: remove the `AppIDatabase` alias. +9. Unused locals (`var response =` in `StorageService`), `.AsNoTracking().AsQueryable()`, + redundant `database.X.Update(entity)` on tracked entities, the always-true + `ExpiredIntentsRetentionDays >= 0`, the duplicated `if (pictureIntent != null && …)` in + `UpdateAlbum`/`UpdatePlayList`, `AddPictureService`'s unused `configuration` parameter, + `AddUploadIntentConfiguration` (already done by `AddApplication`; see P13 for the Worker). +10. `Newtonsoft.Json` from `Api.csproj` and `Worker.csproj`; package references in `Api.csproj` + and `Worker.csproj` that come transitively from Infrastructure (AWSSDK.S3, MassTransit.*, + Npgsql, EF, OpenTelemetry.*, MimeMapping, ImageSharp, X.PagedList.EF). Keep only what the host + uses directly. Rename the Worker `UserSecretsId` prefix (`dotnet-PictureWorker-…`) only if + nobody relies on it locally (ask; otherwise leave and note). +11. `appsettings.json` of the API: remove `AudioTranscoder` and `Workers` (Worker-only). + Worker: remove `AudioTranscoder.Routes.WorkingDirectory` (doesn't exist). +12. `CurrentUser.HasClaim` / `FindClaim` / `Principal` if 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** +1. Create `Pictures.cs` (B.3.1) and `ImageSizes` (B.3.2); rewrite `AlbumConfiguration`, + `PlayListConfiguration`, `TrackConfiguration` on top of them. Update every call site + (`BuildSmallPicturePath(x)` → `BuildPicturePath(PictureSize.Small, x)`). +2. Update all `appsettings*.json` with the renamed keys and grep `deploy/`. +3. **One way to register options:** `AddValidatedOptions` (move it to + `Application/Configuration/ConfigurationDependencyInjection.cs` if not there, public). Replace + `RegisterValidatedOptions` in MassTransit DI and the inline copies in `AddDatabase`, + `AddStorageService`, `AddObservability`, `AddAudioTranscoder`, `AddStreamTicketService`, + `AddAuthenticationConfiguration`. Add `public interface IConfigurationOptions { static abstract string SectionName { get; } }`, + implement it on every config class, and add the overload + `AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) where T : class, IConfigurationOptions` + so call sites stop repeating the section name. +4. Parameter name `services` everywhere (not `serviceDescriptors`). +5. Gateway: rename `StreamTicketValidationOptions` → `StreamTicketValidationConfiguration` and + inject the POCO like the rest (no `IOptions` in constructors). + +**Closing criteria:** `grep -r "AddOptionsWithValidateOnStart" backend` finds only +`AddValidatedOptions`; `new ImageSize(` appears only in `PictureSizes.ToImageSizes`. + +--- + +### P6: Domain cleanup + +**Tasks** +1. `EntityPictures` (B.3.3) replaces `AlbumPictures` and `PlayListPictures`. +2. `IHasLifeCycle` (B.3.8) and `IOwnedEntity` (B.3.6); rename `PlayList.UserId` → + `OwnerUserId` with `[Column("UserId")]`. +3. Apply B.2 rule 10 to every entity: drop redundant `[Required]`, redundant initializers, + `#pragma warning disable CS8618` (use `= null!` on navigations), `required` on `Track.Owner` + (then `CreateTrack` no longer needs to load the `User` tracked; it sets `OwnerUserId` only). +4. 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). +5. 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. +6. `UploadIntent`: move `IsExpired`'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** +1. 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. +2. Rewrite every list query with `…Active().VisibleTo(…).OrderBy(…).SelectResponse().ToPaginatedAsync(page, ct)`. + Delete every `StaticPagedList`, `ToPagedListAsync`, `Include` used only for mapping, and the + `X.PagedList*` package references. +3. Delete `TracksSearchResponse`, `TrackSearchItemResponse`, `AlbumsSearchResponse`, + `AlbumSearchItemResponse`; `GetTracks` and `GetAlbums` return `PaginatedResponse<…>`. + **Contract change:** web-player `explore/+page.svelte` uses `items.map((item) => item.track)` + and the album equivalent; update to use items directly. +4. Replace the load/exists/owner/log boilerplate in every command with `FindOwnedAsync`. +5. 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`…), `GetListeningHistory` + returns `IReadOnlyList`. +6. Move inline responses to `Responses/`: `UserProfileResponse`, `ListeningStatsResponse`, + `GenreResponse`, `AvailableGenreResponse`, the three `*CoverLocation` (which become one + `CoverLocation` in P8). +7. Use `TextNormalizer.Normalize` for every normalized column and search term. +8. Deterministic ordering with `Id` tiebreaker everywhere; `GetTracksByUserId` orders by + `CreatedAt` descending like the rest. +9. `PlayListProjections.SelectResponse` adds `OwnerUserId` (string-serialized, A.3.3), + `TrackCount` and `DurationSeconds` (sum of active tracks) to `PlayListApplicationResponse` + (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** +1. `Application/Pictures/RequestPictureUpload.cs`: one internal service + `PictureUploadIssuer.IssueAsync(userId, purpose, IPictureOwnerConfiguration, fileType, contentType, expectedSize, ct)` + returning `ErrorOr`; `RequestAlbumPictureUpload` and + `RequestPlayListPictureUpload` become thin commands calling it. `AlbumPictureUploadResponse` + and `PlayListPictureUploadResponse` merge into `PictureUploadResponse` (same shape; OpenAPI name + changes → regenerate web-player types). `RequestTrackUploadUrls` reuses 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). +2. `Application/Pictures/GetCover.cs`: one `GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size)` + (`enum CoverOwner { Track, Album, PlayList }`), one `CoverLocation`, content type from + `MimeUtility` for all three (Track currently hand-rolls it). Size is parsed in the API with + `Enum.TryParse` (default `Medium`). +3. Create/Update album and playlist: extract `PictureSourceChange.PrepareAsync(intentId, userId, IPictureOwnerConfiguration, purpose)` + returning the `EntityPictures` and the event payload; handlers shrink to entity changes + + publish. +4. Events: `CreateAlbumResourcesEvent`/`CreatePlayListResourcesEvent` and + `UpdateAlbumPictureSourceEvent`/`UpdatePlayListPictureSourceEvent` keep separate types (the + sagas correlate on them) but carry `ImageSizes Sizes` (B.3.2). Message contract change (A.2). +5. `UploadIntentValidator`: stop receiving `UploadIntentConfiguration` as 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 whose `ExpiresAt` already 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** +1. **Queries don't write:** split `GetTrackStreamQuery` into `StartListeningCommand` (creates the + `ListeningHistory`, returns stream ticket + `listenId`). `TrackStreamIssuer` only issues the + ticket and builds the URL. Same HTTP route and response (no contract change). +2. **Serialization belongs to the API:** move `LongAsStringConverter`/`NullableLongAsStringConverter` + to `Hosts/Musify.Api/Serialization/`, remove `[JsonConverter]` attributes from Application + responses, and register both converters globally in `ConfigureHttpJsonOptions`. That is safe + because the only `long` values the API returns are user ids (`ExpectedSizeBytes` is + request-only and the converter also reads numbers; `TotalItemCount` is `int`); re-check with + a grep before merging. Update the OpenAPI schema transformer to mark every `long` as string, + and check that the regenerated web-player types don't change (they already see strings). +3. **One validation place:** tag rules only in `CreateTrackRequestValidator` (remove from the + handler, keep a domain guard `GenreCompatibility.FindConflicts` call in the handler only if + commands can be sent from somewhere other than the API — they can't today, so remove); + `RecordListeningProgress` gets a FluentValidation validator instead of handler checks; genre + parsing in `GetTracks` moves to binding (`Genre? genre` with a validator). +4. **Endpoint orchestration + bulk insert:** the `AddPlayListTrack` endpoint calls one command + and stops re-querying the track. Replace it with `POST /playlists/{id}/tracks` taking + `{ "trackIds": [...] }` (1–500 ids) → 204, backed by `AddTracksToPlayListCommand` that 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 in + `web-player/src/lib/server/playlistActions.ts`. For consistency, albums get the same shape + (`POST /albums/{id}/tracks` with `trackIds`), replacing `POST /albums/{albumId}/tracks/{trackId}`. + Contract change on both; update the two web-player call sites. +5. **`UserHasTrack`:** it duplicates `Track.OwnerUserId` (written only on create). Replace its reads + (`GetTracks`, `GetTracksByUserId`, `GetGenres`, `GenerateMixesForUser`) with `Tracks` filtered + 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. +6. **Mix texts:** move the Spanish titles out of `GenerateMixesForUser` (B.2 rule 13): store a + `MixKind` enum (`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. +7. `GenerateMixesForUser`: rely on cascade delete for `MixItems` (remove the manual item query). + +--- + +### P10: Infrastructure services + +**Tasks** +1. `StorageService`: after P4 it no longer depends on `IDatabase`; register it as singleton. + Use `StorageKey.Combine` for keys; fix the `trasnsferUtility` typo and dispose it; fix + `Path.Combine(sourceDirectory, file)` (file is already a full path); prefix normalisation and + pagination helpers shared by `ListObjectsAsync`; no log-and-rethrow (B.2 rule 5); consistent + log levels (`Debug` for per-object operations). `GetFileAsync` returns `null` on `NoSuchKey` + (used by the cover endpoints, P3.5). `IStorageService`: `CancellationToken` last + (`preventOverwrite` before it), parameter names camelCase. +2. `AudioTranscoderService.cs`: rename the class to `AudioTranscoderService` (file already has + that name). Access modifiers, no log-and-rethrow, one `ExecuteFfmpegAsync(arguments, workingDirectory, Stream? input, …)` + instead of two near-identical methods. +3. `PictureService`: no log-and-rethrow. +4. `MassTransitEventBus`: expression-bodied, no `nameof`. +5. **Jobs:** one mechanism. Recommended: **Hangfire for all four** (it already runs in the Worker, + gives retries and a dashboard). Convert `UploadIntentExpirationJob` and + `TemporalUploadsCleanUpJob` (rename file/class to `TempUploadsCleanupJob`) to Hangfire recurring + jobs with the configured intervals, and move their logic behind commands in Application + (`ExpireUploadIntentsCommand`, `DeleteStaleTempUploadsCommand`) like + `DeleteStaleUncountedListensCommand`. Rename `AddDailyMixGenerationJob` to `AddJobs` and + register all jobs there. Job recurring registration moves out of `Worker/Program.cs` into an + extension in Infrastructure. +6. Observability: `AddObservability` also used by the Worker (P13). +7. `JwtBearerEventsHandler` (Api): dispose `HttpRequestMessage`/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** +1. `RoutingSlips` helper (B.3.9); rewrite every builder and failed-consumer with it. Delete + `EndpointHelper`. +2. Merge `AlbumPictureSourceRoutingSlipBuilder` and `PlayListPictureSourceRoutingSlipBuilder` + into `PictureSourceRoutingSlipBuilder` (the only difference is the publish-activity + arguments; pass a factory or a `CoverOwner`). +3. Generic lifecycle activities: `MarkLifeCycleActivity` (arguments: `Guid Id`, + `LifeCycleStatus Status`) and `DeleteEntityActivity` for `Track`, `PlayList`, `Album`. + Replaces `Mark{Track,PlayList,Album}As{Failed,Removing}Activity` and + `Delete{Track,PlayList}FromDbActivity` (+ their arguments). Keep distinct endpoint names per + entity (`mark-track-lifecycle`, …) so queues stay separate. Message contract change (A.2). +4. `Update{Track,PlayList,Album}PictureActivity`: one generic base + `UpdatePicturesActivity` with an abstract "apply/restore pictures" hook; Track + additionally sets `ProcessingStatus`. Compensation for Album/PlayList should also leave a + consistent state (today only Track marks failed). +5. `*ProcessingFailedConsumer`: one shared method building the clean-up slip from + `(entity, bucket, keys[])`. +6. 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. +7. The three Audio activities repeat "load track or throw, set status failed on error": extract + `TrackAudioStatus.MarkFailedAsync(database, trackId, ct)`. +8. `GeneratePictureWorkflowPathsActivity` isn't async: return `Task.FromResult`. +9. 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. +10. `ThrowIfNull` without `nameof`; 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** +1. `Program.cs`: `WebApplication.CreateBuilder(args)`; sorted usings; CORS constant at top. +2. Cover endpoints: one `CoverEndpoint.Stream(...)` helper used by the three `/cover` routes. +3. Routes: parameters named `{id}` for the resource itself and `{trackId}` for sub-resources; + Guid routes use `:guid`, user ids `:long`. `PlayList` routes keep the `/playlists` path. +4. Every endpoint returns `IResult` through `ToHttpResult` (no raw return types), including the + ones whose handlers can't fail (use `Results.Ok`). +5. 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. +6. Validators: one shared upload validator (P1.4 created the allow-lists); album and playlist + picture validators become one generic `PictureUploadRequestValidator`, or merge the DTOs + into `PictureUploadRequest` (preferred, since the responses merged in P8). +7. `OpenApiOptionsSetup` and `ScalarOptionsSetup`: share the `SecuritySchemeId` constant. +8. `.WithSummary(...)` strings: sentence case, no trailing period, consistently. +9. `JwtBearerOptionsSetup`: single-line guard without braces (B.1.1 `when_multiline`). +10. 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** +1. Worker `Program.cs`: call `AddApplication(configuration)` instead of registering Application + options by hand (it currently forgets `AlbumConfiguration`); add `AddObservability`; remove + `ValidateOnBuild = false` and 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. +2. Worker `appsettings.json`: complete and consistent with the API for shared sections (after P5 + the keys changed), remove stale ones. +3. Gateway: `Program.cs` uses `AddValidatedOptions`-style registration (it doesn't reference + Application; copy the 6-line helper locally, or reference nothing and keep + `AddOptions…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 the `options` constructor parameter); + `appsettings.json` default 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 in `docs/streaming.md`. + +--- + +### P14: Tests + +**Tasks** +1. 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). +2. Endpoint tests for visibility/ownership (P1, P3) if not already added there. +3. Keep `TestDatabase` in sync with `Database.OnModelCreating`: extract the owned-type and index + configuration of `Database` into `ModelConfiguration.Apply(ModelBuilder)` in Infrastructure + and call it from both, so the test model can't drift. (Requires `Application.Tests` to + reference Infrastructure, or move the test DB to `Infrastructure.Tests`; pick the former and + note it.) + +--- + +### P15: Final sweep and documentation + +**Tasks** +1. Recompute the A.5 metrics (script below) and fill D.1. +2. Grep for leftovers: `Error.Unauthorized(`, `StaticPagedList`, `new ImageSize(`, + `ThrowIfNull(.*nameof`, `#pragma warning disable CS8618`, `try` blocks around + `SaveChangesAsync` in `Application/`, `serviceDescriptors`, `storageHandler`, `PlaylistVisibility`. +3. Update `docs/architecture.md`, `docs/media-processing.md`, `docs/projects.md`, + `docs/storage.md`, `docs/streaming.md` with the new names (jobs, entities, routes). +4. Add a short `backend/CLAUDE.md` with section B.2 (conventions) so future work follows them. +5. 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): + +```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 -> (Api and Worker appsettings updated, nothing in deploy/ used them). BuildTempAudioPath/BuildTempPicturePath -> BuildTempPath; BuildSmall/Medium/LargePicturePath -> BuildPicturePath(PictureSize, name); the un-prefixed BuildOriginalPicturePath(name) overload is gone. Events still carry Small/Medium/Large ImageSize (ImageSizes in events comes in P8.4). serviceDescriptors renamed to services. Gateway: StreamTicketValidationOptions -> StreamTicketValidationConfiguration, injected as POCO. TrackRoutes keeps PresetXPicturePath props (still used). | +| 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|string; web-player schema types unchanged). Tag rules only in CreateTrackRequestValidator, RecordListeningProgressRequestValidator added, genre bound with GenreParameter (400 on unknown). POST /playlists/{id}/tracks and POST /albums/{id}/tracks take { trackIds } (1-500), skip tracks already present and answer 204 (single-track routes removed; adding a duplicate is now a silent success, no 409); web-player updated, album->playlist uses one request. UserHasTrack dropped (migration DropUserHasTrack; reads use Tracks.OwnerUserId). Mix stores MixKind instead of Spanish text (migration MixKind maps the old daily title to Daily); MixApplicationResponse now has kind, web-player renders the texts (data/mixes.ts). GenerateMixesForUser relies on cascade delete. Decision on mix texts: done. | +| 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` 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 | diff --git a/deploy/compose.yml b/deploy/compose.yml index b163a8b..b3d2b48 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -321,7 +321,6 @@ services: ASPNETCORE_URLS: http://+:5111 StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL} StreamTicket__PrivateKeyPath: /keys/stream_private.pem - Workers__Routes__TemporaryFilesDirectory: /tmp/musify # AUTH_CLIENT_ID is written to the env file by the zitadel-init service. Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration" Authentication__IssuerAddress: ${PUBLIC_AUTH_URL} @@ -347,11 +346,18 @@ services: depends_on: migrate: condition: service_completed_successfully + keys-init: + condition: service_completed_successfully environment: <<: *backend-env DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} - AudioTranscoder__Routes__WorkingDirectory: /tmp/musify + # The Application handlers need these to build their dependencies; the + # Worker never issues stream tickets, so the key is never read. + StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL} + StreamTicket__PrivateKeyPath: /keys/stream_private.pem Workers__Routes__TemporaryFilesDirectory: /tmp/musify + volumes: + - ./keys:/keys:ro restart: unless-stopped # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and diff --git a/docs/architecture.md b/docs/architecture.md index 3af19ac..6abaac8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## Principles - **Clean architecture / layers** in `backend/`: the domain and application layers don't depend on infrastructure; infrastructure implements their interfaces (`Application/Contracts`). -- **CQRS**: every operation is a `Command` or `Query` with its own handler (Mediator). Handlers return `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), which the API translates into HTTP status codes. +- **CQRS**: every operation is a `Command` or `Query` with its own handler (Mediator). Handlers that can fail return `ErrorOr` (built with `AppErrors`: NotFound, Forbidden, Conflict, Validation; `Unauthorized` only for "not signed in"), which the API translates into HTTP status codes and ProblemDetails with `ToHttpResult()`. - **Heavy bytes never touch the API**: uploads use presigned URLs (client → S3) and playback goes through a dedicated reverse proxy (gateway → S3). The API only moves metadata and issues permissions. - **Async processing**: creating a track doesn't transcode it on the spot. It publishes an event, and a worker does the heavy lifting, with compensation (MassTransit sagas/routing slips). @@ -11,11 +11,11 @@ | Component | Responsibility | Why it's separate | |---|---|---| -| **Musify.Api** | REST API (users, tracks, playlists), authorizes requests, issues presigned URLs and stream tickets | A thin entry point, it doesn't process media or serve bytes | +| **Musify.Api** | REST API (users, tracks, albums, playlists, likes, mixes, genres), authorizes requests, issues presigned URLs and stream tickets | A thin entry point, it doesn't process media or serve bytes | | **Domain/Application/Infrastructure** | Domain + use cases + shared infrastructure (EF, S3, MassTransit, ffmpeg) | Logic shared between the API and the Worker | | **StreamingGateway** | Reverse proxy (YARP) that validates the ticket and serves the audio (`.m4a`) from SeaweedFS | Scales with byte traffic independently of the API, and keeps SeaweedFS private behind it | -| **Worker** | Consumes MassTransit events: transcodes audio to `.m4a` and generates thumbnails | Keeps CPU/IO-heavy work off the HTTP request path | -| **PostgreSQL** *(shared, runs in the separate `Infrastructure` repo)* | Metadata (users, tracks, playlists, upload intents) | n/a | +| **Worker** | Consumes MassTransit events (transcodes audio to `.m4a`, generates thumbnails, deletes entities) and runs the recurring Hangfire jobs | Keeps CPU/IO-heavy work off the HTTP request path | +| **PostgreSQL** *(shared, runs in the separate `Infrastructure` repo)* | Metadata (users, tracks, albums, playlists, upload intents, listening history) and the Hangfire job store | n/a | | **RabbitMQ** *(shared, runs in Infrastructure)* | Event queue + transactional outbox | Decouples creation from processing | | **SeaweedFS** *(shared, runs in Infrastructure)* | Object storage (S3 + filer) | Stores originals and derivatives (`.m4a` audio, thumbnails) | | **Zitadel** *(shared, runs in Infrastructure)* | Identity (OIDC/OAuth2) | Login and JWT issuance | @@ -25,13 +25,13 @@ ### 1. Uploading a track 1. The client requests `POST /tracks/upload-urls`. The API creates **UploadIntents** (reservations) and returns **presigned PUT URLs** (image + audio). 2. The client uploads the files **directly to SeaweedFS** using those URLs (never through the API). -3. The client calls `POST /tracks` with the `intentId`s. The API validates the intents, creates the `Track` (+ `UserHasTrack`), and publishes `CreateTrackResourcesEvent`. +3. The client calls `POST /tracks` with the `intentId`s. The API validates the intents, creates the `Track` (owned by the user through `OwnerUserId`), and publishes `CreateTrackResourcesEvent`. Albums and playlists follow the same pattern with `POST /albums/upload-picture` / `POST /playlists/upload-picture` and `POST /albums` / `POST /playlists`. 4. The **Worker** consumes the event: moves the originals into place, generates thumbnails, transcodes the audio to `.m4a`, and updates the track's status. See [media-processing.md](media-processing.md) and [storage.md](storage.md) for details. ### 2. Playback (streaming) -1. The client requests `GET /tracks/{id}/stream` (authenticated). The API authorizes it and returns `{ manifestUrl, ticket }` (a URL to the `.m4a` plus an RS256 ticket scoped to that track's folder). +1. The client requests `GET /tracks/{id}/stream` (authenticated, or anonymous when `Playback:AllowAnonymousListening` is on). The API records the listen (`StartListeningCommand`) and issues the ticket. The API authorizes it and returns `{ manifestUrl, ticket }` (a URL to the `.m4a` plus an RS256 ticket scoped to that track's folder). 2. The player opens the audio URL with `?t=` appended (or an `X-Stream-Ticket` header) and downloads it via HTTP range requests. 3. The **StreamingGateway** validates the ticket and forwards the request to the SeaweedFS filer. The bytes flow storage → client. @@ -46,10 +46,10 @@ See [authentication.md](authentication.md). ## Data model (entities) -- **User**: `Id` is Zitadel's numeric `sub` (`long`), plus a name and `ProfilePictureUrl`. -- **Track**: original/derivative file names, `AudioFolderName` (the transcoded `.m4a`'s folder), processing state (`ProcessingStatus`), `LifeCycleStatus`. -- **PlayList**: a user's playlist (`UserId`). -- **PlayListHasTrack**: the tracks inside a playlist (with `Position`). -- **UserHasTrack**: ownership, which user owns which track. -- **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota. -- **Upload**: a record of objects uploaded or transferred to storage. +- **User**: `Id` is Zitadel's numeric `sub` (`long`), plus a name and `ProfilePictureUrl`. **UserFollow** links follower and followed. +- **Track**: owned by `OwnerUserId`; `Pictures` (`TrackPictures`) and `Audio` (`TrackAudio`, with `FolderName` = the transcoded `.m4a`'s folder) are owned types with their own processing status; `LifeCycleStatus` (Active, Removing, Failed). **TrackTag** holds its genres, **TrackLike** the likes, **ListeningHistory** the listens. +- **Album** / **AlbumHasTrack**: an album with 1-based track numbers. **PlayList** / **PlayListHasTrack**: a playlist with 0-based positions and a `Visibility` (Private or Public). Both use `EntityPictures` (resized names stay null until processed). +- **Mix** / **MixItem**: server-generated daily mixes, identified by `MixKind` (the web-player renders the title). +- **UploadIntent**: an upload reservation (key, bucket, purpose, expected size, status, expiration) used for validation and quota. + +Every entity that can be listed or removed implements `IHasLifeCycle`; public reads return only `Active` rows, and the ownership checks (`FindOwnedAsync`) treat non-active entities as not found. diff --git a/docs/authentication.md b/docs/authentication.md index c0376b8..dbffd42 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,7 +15,7 @@ Login and token issuance are handled by **Zitadel**, not the API. The API is a * ## `sub` is numeric → `User.Id` is `long` Zitadel's `sub` is a 64-bit integer encoded as a string (e.g. `371953080444977155`), **not a GUID**. Because of that: -- `User.Id` and every user-related FK (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) are **`long`**. +- `User.Id` and every user-related FK (`PlayList.OwnerUserId`, `Track.OwnerUserId`, `UploadIntent.UserId`) are **`long`**. - `User.Id` has `[DatabaseGenerated(None)]` so EF doesn't autogenerate it: it's assigned from the `sub`. - `CurrentUser.Id` is `long?` (parsed with `long.TryParse`); `RequiredId` throws if it's missing. diff --git a/docs/development.md b/docs/development.md index 9f9824b..a52ff9f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,13 +87,14 @@ dotnet test backend/Musify.slnx | Project | Covers | Docker | |---|---|---| | `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No | -| `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No | -| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Yes** | +| `Musify.Application.Tests` | The handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes...). Its `TestDatabase` builds the model with `ModelConfiguration.Apply` from Infrastructure, so it can't drift from the real `Database`. | No | +| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, plus the MassTransit layer (lifecycle and picture-update activities, routing slip builders, `ProcessingSlipFaultConsumer`, the saga state machines on the MassTransit test harness) and `RecurringJobsRegistrar`. | **Yes** | | `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** | | `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No | `Musify.Worker` has no test project: its `Program.cs` is pure DI/host wiring -(Hangfire, MassTransit, jobs). The actual logic it runs lives in +(Hangfire, MassTransit, jobs), and the container is validated on build, so a +missing registration fails at startup. The actual logic it runs lives in `Musify.Infrastructure`, already covered there. ### Application.Tests: why in-memory SQLite instead of mocks diff --git a/docs/media-processing.md b/docs/media-processing.md index 8ee9836..dd521ab 100644 --- a/docs/media-processing.md +++ b/docs/media-processing.md @@ -10,22 +10,24 @@ Heavy processing (audio transcoding, thumbnails, bucket transfers) happens **out ## Trigger -When a track or playlist is created or updated, the handler publishes an event (e.g. `CreateTrackResourcesEvent`). A **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, …) builds a **routing slip** with the sequence of activities and executes it. +When a track, album or playlist is created, updated or deleted, the handler publishes an event (e.g. `CreateTrackResourcesEvent`, `UpdateAlbumPictureSourceEvent`, `DeleteAlbumEvent`). A **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, `DeleteAlbumConsumer`, …) builds a **routing slip** with the sequence of activities and executes it. Every slip starts from `RoutingSlips.Create(correlationId)` and adds steps with `AddStep(...)`; `TrackFaults(subjectId, processKind)` makes a failed slip publish a failure event for its subject (`ProcessingSlipFaultConsumer`, one process kind per workflow: `TrackPicture`, `TrackAudio`, `PlayListPicture`, `AlbumPicture` and the three `*Creation` kinds). ## Routing slip builders - `CreateTrackRoutingSlipBuilder`: creating a track (consumes intents, moves files, kicks off processing). - `PictureWorkflowRoutingSlipBuilder`: image pipeline (download → resize → upload → update). - `AudioWorkflowRoutingSlipBuilder`: audio pipeline (download → transcode to `.m4a` → upload folder → update). -- `DeleteTrackRoutingSlipBuilder`, `DeletePlayListRoutingSlipBuilder`, `PlayListPictureSourceRoutingSlipBuilder`. +- `DeleteRoutingSlipBuilder`: marks a track, album or playlist as `Removing`, removes its files from the bucket and finally deletes the row. +- `PictureSourceRoutingSlipBuilder`: album and playlist pictures (copy the uploaded file to its final key, consume the intent, publish the event that starts the picture workflow), for both creation and update. ## Activities (steps) Grouped by area: - **Files**: `DownloadFileFromBucketActivity`, `UploadFileToBucketActivity`, `TransferFilesToBucketActivity`, `CopyFileInBucketActivity`, `RemoveFileFromBucketActivity`. - **Audio**: `GenerateAudioWorkflowPathsActivity`, `TranscodeAudioActivity` (ffmpeg → `.m4a`), `UpdateTrackAudioActivity` (saves `AudioFolderName` and marks it `Completed`). -- **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`. -- **Tracks/Playlists**: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`, `PublishTrackProcessingEventsActivity`, and their playlist equivalents. +- **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`, `UpdateAlbumPictureActivity` (a shared `UpdatePicturesActivity` base; compensation restores the previous names). +- **Life cycle**: `Mark{Track,PlayList,Album}LifeCycleActivity` and `Delete{Track,PlayList,Album}Activity` (generic over the entity; a missing row is skipped). +- **Events**: `PublishTrackProcessingEventsActivity`, `PublishPlayListPictureProcessingEventActivity`, `PublishAlbumPictureProcessingEventActivity`. - **UploadIntents**: `ConsumeUploadIntentsActivity`. `RoutingSlipCleanUpConsumer` reacts to `Completed`/`Faulted` to clean up (e.g. deleting the temporary working directory). @@ -36,11 +38,15 @@ Grouped by area: ## What the Worker needs configured -The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `appsettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`. +The Worker calls `AddApplication` (every Application handler is registered, so its container is validated on build), plus observability, the DB, storage (S3), the transcoder (ffmpeg), pictures, stream tickets, the MassTransit consumers and the Hangfire jobs. Its `appsettings.json` mirrors the API for the shared sections: `ApplicationStorage` (the same bucket as the API), `PlayList`, `Album`, `Track`, `Mix`, `UploadIntent`, `StreamGateway`, `StreamTicket` and `Playback` (the handlers need them to be constructed; the Worker never issues tickets, so the private key is never read), and adds `MassTransit`, `AudioTranscoder`, `Workers` (temp directory), `InfrastructureStorage`, `Database` and `OpenTelemetry`. ## Background jobs -- `UploadIntentExpirationJob`: marks expired intents as such (freeing quota) every `ExpirationJobIntervalSeconds`. -- `TemporalUploadsCleanUpJob`: cleans up old temporary uploads. +Recurring **Hangfire** jobs (Postgres storage), registered by `RecurringJobsRegistrar` when the Worker starts: -Both run on the Worker host (`AddUploadIntentJobs`). If they aren't registered anywhere, stale intents never get cleaned up on their own. +- `DailyMixGenerationJob` (`daily-mix-generation`): regenerates the mixes every day. +- `ListeningHistoryCleanupJob` (`listening-history-cleanup`): prunes old listens every day at 03:00. +- `UploadIntentExpirationJob` (`upload-intent-expiration`): marks expired intents as such (freeing quota) every `ExpirationJobIntervalSeconds`. +- `TempUploadsCleanupJob` (`temp-uploads-cleanup`): cleans up old temporary uploads under `TempRootPrefix` (`TempUploadsRetentionDays`) every `TempCleanupJobIntervalSeconds`. + +Hangfire cron has a one-minute resolution, so intervals in seconds are rounded up to whole minutes (`RecurringJobsRegistrar.EveryCron`). If the Worker isn't running, stale intents never get cleaned up on their own. diff --git a/docs/projects.md b/docs/projects.md index 2549e0e..1d80d08 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -7,13 +7,14 @@ The .NET projects live under `backend/` (solution `Musify.slnx`, folders The public REST API (Minimal APIs, .NET 10). The thin public face of the system. -- **Endpoints**: `UserEndpoints`, `TrackEndpoints`, `PlayListEndpoints`. +- **Endpoints**: `AlbumEndpoints`, `ConfigEndpoints`, `GenreEndpoints`, `LikeEndpoints`, `MixEndpoints`, `PlayListEndpoints`, `TrackEndpoints`, `UserEndpoints`. Every endpoint returns `IResult`; routes use `{id:guid}` (sub-resources `{trackId:guid}`) and `{id:long}` / `{userId:long}` for user ids; list endpoints bind `PageQuery` (`pageNumber`, `pageSize` 1..100). The three `/cover` routes share `CoverEndpoint.Stream`. +- **Validation**: FluentValidation validators in `Validators/`; every limit lives in `Validators/Limits.cs` (the web-player copies the same values) and the upload allow-lists in `Validators/Uploads.cs`. - **Authentication**: JWT Bearer against Zitadel (`Authentication/`), with a `JwtBearerEventsHandler` that syncs the user on every validated token. - **`CurrentUser`**: an abstraction bound as an endpoint parameter (`IBindableFromHttpContext`) that exposes the basic claims (`Id` as `long?`, `RequiredId`, name, etc.) without reading `ClaimsPrincipal` by hand. - **Issues**: presigned upload URLs (via `IStorageService`) and **stream tickets** (via `IStreamTicketService`, RS256). - **OpenAPI + Scalar** for docs/testing; OAuth2 (PKCE) set up for logging in from Scalar. - **CORS** wide open, but **only in Development** (for the local frontend). -- Translates `ErrorOr` into HTTP in `ResultHttpExtensions`. +- Translates `ErrorOr` into HTTP (ProblemDetails) in `ErrorOrHttpExtensions`. **Purpose**: keep the public surface (authorization + metadata) separate from processing and byte-serving. @@ -26,30 +27,31 @@ Entities (`Entities/`) and value objects/enums (`ValueObjects/`: `ProcessingStat ### Application Use cases (CQRS with Mediator): -- `Tracks/`, `PlayLists/`, `Users/` → `Commands`, `Queries`, `Handlers`, `Responses`. +- One folder per feature (`Albums/`, `Genres/`, `Likes/`, `Mixes/`, `Pictures/`, `PlayLists/`, `Tracks/`, `Users/`, …): each `.cs` holds the command/query record and its handler; DTOs live in `Responses/`. - `Contracts/` → interfaces that infrastructure implements (`IDatabase`, `IStorageService`, `IAudioTranscoderService`, `IPictureService`, `IEventBus`, `IStreamTicketService`). -- `Configuration/` → typed options (Track, PlayList, ApplicationStorage, UploadIntent…) bound and validated at startup. -- `Shared/` → cross-cutting helpers (`StorageKey.Combine`, `ImageSize`). -- `Pagination/` → `PaginatedResponse`. +- `Configuration/` → typed options (Track, PlayList, Album, Mix, ApplicationStorage, UploadIntent, Playback, StreamGateway…), each ending in `Configuration`, bound and validated at startup with `AddValidatedOptions`; `Pictures.cs` holds the shared picture routes and sizes. +- `Shared/` → cross-cutting helpers: `StorageKey.Combine`, `ImageSize(s)`, `AppErrors`, `PageRequest` / `PaginatedResponse`, `OwnershipExtensions.FindOwnedAsync`, `TextNormalizer`. +- `Services/` → `UploadIntentIssuer` / `UploadIntentValidator` (upload intents) and `TrackStreamIssuer` (stream tickets). +- `AddApplication(configuration)` registers all of the above; the API and the Worker both call it. **Why**: business logic doesn't know about EF, S3, or RabbitMQ, only interfaces. Shared by both the API and the Worker. ### Infrastructure Concrete implementations: -- `Persistence/` → `Database` (EF Core/Npgsql), migrations. +- `Persistence/` → `Database` (EF Core/Npgsql), migrations, and `ModelConfiguration.Apply` (the entity model, shared with the in-memory test database). - `Services/` → `StorageService` (AWSSDK.S3 against SeaweedFS), `AudioTranscoderService` (ffmpeg → `.m4a`), `PictureService` (ImageSharp), `StreamTicketService` (RS256 signing). -- `MassTransit/` → consumers, activities, and routing slips (see [media-processing.md](media-processing.md)). -- `Jobs/` → `UploadIntentExpirationJob`, `TemporalUploadsCleanUpJob`. +- `MassTransit/` → consumers, activities, routing slips and sagas (see [media-processing.md](media-processing.md)). +- `Jobs/` → the recurring Hangfire jobs (`DailyMixGenerationJob`, `ListeningHistoryCleanupJob`, `UploadIntentExpirationJob`, `TempUploadsCleanupJob`) and `RecurringJobsRegistrar`. ## Musify.StreamingGateway (`:8081`) -A reverse proxy (ASP.NET + **YARP**) that serves audio (`.m4a`) from SeaweedFS. Validates the stream ticket (RS256, public key) and, if the requested object falls under the authorized prefix, forwards it to the filer. +A reverse proxy (ASP.NET + **YARP**) that serves audio (`.m4a`) from SeaweedFS. Validates the stream ticket (RS256, public key) and, if the requested object falls under the authorized prefix, forwards it to the filer. It references neither Application nor Infrastructure and registers its own options. The bucket in the default YARP transform is the placeholder `CHANGE_ME` (compose overrides it; `appsettings.Development.json` sets `webapi-storage`). **Why a separate project**: it scales with byte traffic independently of the API, has a minimal surface (it only knows how to validate a JWT and proxy), and lets SeaweedFS stay on a private network. See [streaming.md](streaming.md). ## Musify.Worker -A background Worker Service that consumes MassTransit events and runs the heavy processing: audio transcoding to `.m4a` (ffmpeg), thumbnail generation (ImageSharp), file transfers within the bucket, and the upload-intent expiration/cleanup jobs. +A background Worker Service that consumes MassTransit events and runs the heavy processing: audio transcoding to `.m4a` (ffmpeg), thumbnail generation (ImageSharp), file transfers within the bucket, and the recurring Hangfire jobs (daily mixes, listening-history cleanup, upload-intent expiration, temporary upload cleanup). **Why separate**: CPU/IO-heavy work shouldn't happen inside an HTTP request; it scales and restarts independently. (Previously named `Musify.PictureWorker`; renamed once it started doing more than just images.) @@ -63,8 +65,9 @@ with the access token, and uploads go straight to the presigned S3 URLs. `backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects: `Musify.Domain.Tests`, `Musify.Application.Tests` (in-memory SQLite, no -Docker), `Musify.Infrastructure.Tests` and `Musify.Api.Tests` (Testcontainers: -real Postgres + SeaweedFS), and `Musify.StreamingGateway.Tests`. Details in +Docker), `Musify.Infrastructure.Tests` (Testcontainers: real Postgres + +SeaweedFS; also covers the MassTransit activities, routing slips, sagas and +jobs), `Musify.Api.Tests` (Testcontainers) and `Musify.StreamingGateway.Tests`. Details in [development.md](development.md#tests). ## Other folders diff --git a/docs/storage.md b/docs/storage.md index 7f80bbf..52c21ab 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -13,26 +13,28 @@ App bucket: **`webapi-storage`** (`ApplicationStorage:Bucket`). Important: the A ## Key layout -- `temp/{userId}/Tracks/{guid}.{ext}`: temporary uploads, the target of presigned PUTs, before the intent is consumed. -- `uploads/{userId}/Tracks/OriginalPictures|OriginalAudios/...`: originals that have already been consumed. -- `Tracks/SmallPictures|MediumPictures|LargePictures/...`: generated thumbnails. +The layout is configured per owner (the `Track`, `PlayList` and `Album` sections, `Routes`); `ParentFolder` is `Tracks`, `PlayLists` or `Albums`. + +- `{TempRootPrefix}/{userId}/{ParentFolder}/{guid}.{ext}`: temporary uploads, the target of presigned PUTs, before the intent is consumed (`UploadIntent:TempRootPrefix`, default `temp`; objects under the old `temporal/` prefix from earlier versions may need a one-off clean-up). +- `uploads/{userId}/{ParentFolder}/OriginalPictures|OriginalAudios/...`: originals that have already been consumed. +- `{ParentFolder}/SmallPictures|MediumPictures|LargePictures/...`: generated thumbnails. - `Tracks/ProcessedAudios/{AudioFolderName}/`: transcoded audio (`audio.m4a`, AAC with faststart). Keys are always built with the `StorageKey.Combine(...)` helper (in `Application/Shared`), which joins segments with `/` and trims stray spaces and slashes. It's centralized to keep the logic consistent and avoid repeating it. -> In the filer, S3 objects live under `/buckets/{bucket}/{key}`, which is why the gateway rewrites `/media/{key}` → `/buckets/webapi-storage/{key}`. +> In the filer, S3 objects live under `/buckets/{bucket}/{key}`, which is why the gateway rewrites `/media/{key}` → `/buckets/{bucket}/{key}` (the bucket comes from the compose override `S3_BUCKET`; the default in the gateway's `appsettings.json` is the placeholder `CHANGE_ME`). ## Presigned URLs To **avoid** routing large files through the API, the client talks to S3 directly using short-lived signed URLs: -- **Uploads (PUT)**: `RequestTrackUploadUrls` / `RequestPlayListPictureUpload` generate presigned PUTs (with signed `Content-Type` and `If-None-Match: *`, ~120s expiry). The client uploads straight to S3. +- **Uploads (PUT)**: `RequestTrackUploadUrls` / `RequestAlbumPictureUpload` / `RequestPlayListPictureUpload` (issued by `UploadIntentIssuer`) generate presigned PUTs (with signed `Content-Type` and `If-None-Match: *`, ~120s expiry). The client uploads straight to S3. - **Images (GET)**: thumbnails are served via presigned GETs (one object = one URL). **Why presigned instead of proxying**: routing a ~100 MB audio file through the API would burn memory and bandwidth on the application servers and wouldn't scale well. A direct presigned PUT to S3 is the standard, correct pattern here. ### Upload intents and quota -Every `upload-urls` call creates **UploadIntents** (reservations in the `Issued` state) and returns the URLs. They're **consumed** when the track/playlist is created. There's a per-user quota (`MaxActiveUploadIntentsPerUser`, byte limits…) to prevent abuse. The `UploadIntentExpirationJob` (in the Worker) marks expired intents and frees up quota. If that job isn't running, stale intents pile up and block new uploads. +Every `upload-urls` call creates **UploadIntents** (reservations in the `Issued` state) and returns the URLs. They're **consumed** when the track/playlist is created. There's a per-user quota (`MaxActiveUploadIntentsPerUser`, byte limits…) to prevent abuse. The `UploadIntentExpirationJob` (a Hangfire job on the Worker) marks expired intents and frees up quota, and `TempUploadsCleanupJob` deletes abandoned temporary objects. If the Worker isn't running, stale intents pile up and block new uploads. An intent is bound to its purpose (track picture/audio, album picture, playlist picture) and the same intent can't be used for two files. ## What's public and what's private (deployment) diff --git a/docs/streaming.md b/docs/streaming.md index 1f6ba1a..71a1fef 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -37,7 +37,7 @@ Access is authorized for **the track's folder prefix** via a short-lived *stream `TicketValidationMiddleware`, on every request to `/media/**`: 1. Reads `?t=`, validates its signature/aud/iss/exp. 2. Checks that the requested key **starts with** the ticket's `prefix`. If not → 403. -3. Strips the `?t=` and passes the request on to YARP, which rewrites `/media/{key}` → `/buckets/webapi-storage/{key}` for the filer. +3. Strips the `?t=` and passes the request on to YARP, which rewrites `/media/{key}` → `/buckets/{bucket}/{key}` for the filer (the bucket is set by the compose override `ReverseProxy__Routes__media__Transforms__0__PathPattern`; the default in `appsettings.json` is the placeholder `CHANGE_ME`, and `appsettings.Development.json` uses `webapi-storage`). - No ticket → **401**; outside the prefix → **403**; ok → **200** (and **206** for range requests, so seeking works). - Passes through `Range`/`Accept-Ranges` and exposes those headers via CORS. @@ -50,6 +50,6 @@ Yes. Each ticket is scoped to **one** folder (one track). Playing a different on ## Open items / security notes -- Today, the policy for who can request a ticket is "any authenticated user" (TODO: restrict it with `UserHasTrack`/visibility). -- The prefix check is a literal `StartsWith`; it would be worth hardening it against `..` path traversal, even though browsers and ASP.NET normalize `..`. +- Who can request a ticket is decided by `StartListeningCommand`: any authenticated user, or anonymous users when `Playback:AllowAnonymousListening` is on (the ticket then carries `maxBytes`, an anonymous preview limit). TODO: restrict it by track visibility. +- The prefix check is a literal `StartsWith` on the key, and the gateway rejects any `.`/`..` segment with 400. For tickets with `maxBytes` it clamps the `Range` header to the preview size, replaces a missing or invalid range, and answers 416 to suffix ranges or ranges that start beyond the limit. - The ticket travels in the query string (`?t=`); the gateway strips it before forwarding, so it never reaches the filer's logs. diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index f4687f6..11cc2e4 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -11,10 +11,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Albums, Optionally Filtered By Title. */ + /** Get paginated albums, optionally filtered by title */ get: operations["GetAlbums"]; put?: never; - /** Create A New Album. */ + /** Create a new album */ post: operations["CreateAlbum"]; delete?: never; options?: never; @@ -29,11 +29,13 @@ export interface paths { path?: never; cookie?: never; }; - /** Get An Album By Id. */ + /** Get an album by id */ get: operations["GetAlbumById"]; - put?: never; + /** Update an album */ + put: operations["UpdateAlbum"]; post?: never; - delete?: never; + /** Delete an album */ + delete: operations["DeleteAlbum"]; options?: never; head?: never; patch?: never; @@ -46,7 +48,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Albums For A User. */ + /** Get paginated albums for a user */ get: operations["GetAlbumsByUserId"]; put?: never; post?: never; @@ -63,7 +65,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Albums The Current User Listened To Most Recently. */ + /** Get the albums the current user listened to most recently */ get: operations["GetRecentlyListenedAlbums"]; put?: never; post?: never; @@ -73,17 +75,18 @@ export interface paths { patch?: never; trace?: never; }; - "/albums/{albumId}/tracks": { + "/albums/{id}/tracks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get The Tracks Of An Album Ordered By Track Number. */ + /** Get the tracks of an album ordered by track number */ get: operations["GetAlbumTracks"]; put?: never; - post?: never; + /** Add your uploaded tracks to an album, skipping the ones it already has */ + post: operations["AddAlbumTracks"]; delete?: never; options?: never; head?: never; @@ -97,7 +100,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get An Album Cover Image. */ + /** Get an album cover image */ get: operations["GetAlbumCover"]; put?: never; post?: never; @@ -116,7 +119,7 @@ export interface paths { }; get?: never; put?: never; - /** Request A Pre-Signed URL To Upload An Album Picture. */ + /** Request a pre-signed URL to upload an album picture */ post: operations["RequestAlbumPictureUpload"]; delete?: never; options?: never; @@ -124,25 +127,7 @@ export interface paths { patch?: never; trace?: never; }; - "/albums/{albumId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** Update An Album. */ - put: operations["UpdateAlbum"]; - post?: never; - /** Delete An Album. */ - delete: operations["DeleteAlbum"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/albums/{albumId}/tracks/{trackId}": { + "/albums/{id}/tracks/{trackId}": { parameters: { query?: never; header?: never; @@ -151,9 +136,8 @@ export interface paths { }; get?: never; put?: never; - /** Add One Of Your Uploaded Tracks To An Album. */ - post: operations["AddTrackToAlbum"]; - /** Remove A Track From An Album. */ + post?: never; + /** Remove a track from an album */ delete: operations["RemoveTrackFromAlbum"]; options?: never; head?: never; @@ -167,7 +151,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously. */ + /** Get the public playback configuration (whether anonymous listening is allowed). Works anonymously */ get: operations["GetPlaybackPublicConfig"]; put?: never; post?: never; @@ -184,7 +168,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously. */ + /** Get the genres that have at least one track, most populated first. Works anonymously */ get: operations["GetGenres"]; put?: never; post?: never; @@ -201,7 +185,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously. */ + /** Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously */ get: operations["GetAvailableGenres"]; put?: never; post?: never; @@ -218,7 +202,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Current User'S Liked Tracks. */ + /** Get the current user's liked tracks */ get: operations["GetLikedTracks"]; put?: never; post?: never; @@ -237,7 +221,7 @@ export interface paths { }; get?: never; put?: never; - /** Like Or Unlike A Track By Id. */ + /** Like or unlike a track by id */ post: operations["ToggleTrackLike"]; delete?: never; options?: never; @@ -252,7 +236,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Server-Generated Mixes Of The Current User. */ + /** Get the server-generated mixes of the current user */ get: operations["GetMixes"]; put?: never; post?: never; @@ -269,7 +253,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Mix With All Of Its Songs. */ + /** Get a mix with all of its songs */ get: operations["GetMixById"]; put?: never; post?: never; @@ -279,6 +263,77 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a playlist by id */ + get: operations["GetPlayListById"]; + /** Update an existing playlist */ + put: operations["UpdatePlayList"]; + post?: never; + /** Delete a playlist */ + delete: operations["DeletePlayList"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/playlists/users/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get paginated playlists for a user. Private playlists are only returned to their owner */ + get: operations["GetPlayListsByUserId"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/playlists/{id}/tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get paginated tracks of a playlist */ + get: operations["GetPlayListTracks"]; + put?: never; + /** Add tracks to a playlist, skipping the ones it already has */ + post: operations["AddPlayListTracks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/playlists/{id}/cover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a playlist cover image */ + get: operations["GetPlayListCover"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists": { parameters: { query?: never; @@ -286,10 +341,9 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated PlayLists. */ - get: operations["GetPlayLists"]; + get?: never; put?: never; - /** Create A New PlayList. */ + /** Create a new playlist */ post: operations["CreatePlayList"]; delete?: never; options?: never; @@ -297,110 +351,6 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get A PlayList By Id. */ - get: operations["GetPlayListById"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/playlists/users/{userId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Paginated PlayLists For A User. */ - get: operations["GetPlayListsByUserId"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/playlists/{playlistId}/albums/{albumId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It. */ - post: operations["AddAlbumToPlayList"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/playlists/{playlistId}/tracks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Paginated Tracks Of A PlayList. */ - get: operations["GetPlayListTracks"]; - put?: never; - /** Add One Of Your Tracks To A PlayList. */ - post: operations["AddPlayListTrack"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/playlists/{id}/cover": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get A PlayList Cover Image. */ - get: operations["GetPlayListCover"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/playlists/{playlistId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** Update An Existing PlayList. */ - put: operations["UpdatePlayList"]; - post?: never; - /** Delete A PlayList. */ - delete: operations["DeletePlayList"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/playlists/upload-picture": { parameters: { query?: never; @@ -410,7 +360,7 @@ export interface paths { }; get?: never; put?: never; - /** Request A Pre-Signed URL To Upload A PlayList Picture. */ + /** Request a pre-signed URL to upload a playlist picture */ post: operations["RequestPlayListPictureUpload"]; delete?: never; options?: never; @@ -418,7 +368,24 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists/{playlistId}/tracks/{trackId}": { + "/playlists/{id}/albums/{albumId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add all the tracks of an album to a playlist, skipping the ones it already has */ + post: operations["AddAlbumToPlayList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/playlists/{id}/tracks/{trackId}": { parameters: { query?: never; header?: never; @@ -428,7 +395,7 @@ export interface paths { get?: never; put?: never; post?: never; - /** Remove A Track From A PlayList. */ + /** Remove a track from a playlist */ delete: operations["RemoveTrackFromPlayList"]; options?: never; head?: never; @@ -442,10 +409,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Tracks, Optionally Filtered By Name And Genre. */ + /** Get paginated tracks, optionally filtered by name and genre */ get: operations["GetTracks"]; put?: never; - /** Create A New Track. */ + /** Create a new track */ post: operations["CreateTrack"]; delete?: never; options?: never; @@ -460,11 +427,12 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Track By Id. */ + /** Get a track by id */ get: operations["GetTrackById"]; put?: never; post?: never; - delete?: never; + /** Delete a track */ + delete: operations["DeleteTrack"]; options?: never; head?: never; patch?: never; @@ -477,7 +445,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Track Cover Image. */ + /** Get a track cover image */ get: operations["GetTrackCover"]; put?: never; post?: never; @@ -494,7 +462,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */ + /** Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it */ get: operations["GetTrackStream"]; put?: never; post?: never; @@ -512,7 +480,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint. */ + /** Report the total seconds actually played for a listen started by the stream endpoint */ put: operations["RecordListeningProgress"]; post?: never; delete?: never; @@ -528,7 +496,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Tracks For A User. */ + /** Get paginated tracks for a user */ get: operations["GetTracksByUserId"]; put?: never; post?: never; @@ -538,23 +506,6 @@ export interface paths { patch?: never; trace?: never; }; - "/tracks/{trackId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete A Track. */ - delete: operations["DeleteTrack"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/tracks/upload-urls": { parameters: { query?: never; @@ -564,7 +515,7 @@ export interface paths { }; get?: never; put?: never; - /** Request Pre-Signed URLs To Upload Track Picture And Audio. */ + /** Request pre-signed URLs to upload track picture and audio */ post: operations["RequestTrackUploadUrls"]; delete?: never; options?: never; @@ -579,11 +530,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Users. */ + /** Get paginated users */ get: operations["GetUsers"]; put?: never; - /** Create A New User. */ - post: operations["CreateUser"]; + post?: never; delete?: never; options?: never; head?: never; @@ -597,7 +547,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User By Id. */ + /** Get a user by id */ get: operations["GetUserById"]; put?: never; post?: never; @@ -614,7 +564,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User'S Listening History. */ + /** Get a user's listening history */ get: operations["GetListeningHistory"]; put?: never; post?: never; @@ -631,7 +581,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User'S Listening Stats For The Current Week And Streak. */ + /** Get a user's listening stats for the current week and streak */ get: operations["GetListeningStats"]; put?: never; post?: never; @@ -648,7 +598,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Last Track A User Listened To. */ + /** Get the last track a user listened to */ get: operations["GetLastTrackListenedByUserId"]; put?: never; post?: never; @@ -665,7 +615,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A User'S Public Profile, Including Follower Counts. */ + /** Get a user's public profile, including follower counts */ get: operations["GetUserProfile"]; put?: never; post?: never; @@ -682,7 +632,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Users Following A User. Visible To The User And To Mutual Followers. */ + /** Get the users following a user. Visible to the user and to mutual followers */ get: operations["GetUserFollowers"]; put?: never; post?: never; @@ -699,7 +649,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get The Users A User Follows. */ + /** Get the users a user follows */ get: operations["GetUserFollowing"]; put?: never; post?: never; @@ -716,7 +666,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Check Whether The Current User Follows Another User. */ + /** Check whether the current user follows another user */ get: operations["IsFollowingUser"]; put?: never; post?: never; @@ -735,9 +685,9 @@ export interface paths { }; get?: never; put?: never; - /** Follow A User. */ + /** Follow a user */ post: operations["FollowUser"]; - /** Unfollow A User. */ + /** Unfollow a user */ delete: operations["UnfollowUser"]; options?: never; head?: never; @@ -752,9 +702,8 @@ export interface components { /** Format: int32 */ addedCount: number | string; }; - AddPlayListTrackRequest: { - /** Format: uuid */ - trackId: string; + AddTracksRequest: { + trackIds: string[]; }; AlbumApplicationResponse: { /** Format: uuid */ @@ -775,33 +724,6 @@ export interface components { updatedAt: string; coverTrackIds: string[]; }; - AlbumPictureUploadResponse: { - /** Format: uuid */ - intentId: string; - bucket: string; - key: string; - pictureName: string; - contentType: string; - /** Format: int32 */ - expiresInSeconds: number | string; - uploadUrl: string; - }; - AlbumSearchItemResponse: { - album: components["schemas"]["AlbumApplicationResponse"]; - }; - AlbumsSearchResponse: { - items: components["schemas"]["AlbumSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; AvailableGenreResponse: { genre: components["schemas"]["Genre"]; incompatibleWith: components["schemas"]["Genre"][]; @@ -819,7 +741,7 @@ export interface components { description: null | string; /** Format: uuid */ pictureIntentId: null | string; - visibility?: components["schemas"]["PlaylistVisibility"]; + visibility?: components["schemas"]["PlayListVisibility"]; }; CreateTrackRequest: { title: string; @@ -831,13 +753,6 @@ export interface components { /** @default false */ isExplicit: boolean; }; - CreateUserRequest: { - /** Format: int64 */ - id: number | string; - name: string; - firstName: null | string; - secondName: null | string; - }; /** @enum {unknown} */ Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; GenreResponse: { @@ -867,8 +782,7 @@ export interface components { MixApplicationResponse: { /** Format: uuid */ id: string; - title: string; - subtitle: null | string; + kind: components["schemas"]["MixKind"]; /** Format: int32 */ itemCount: number | string; items: components["schemas"]["MixItemApplicationResponse"][]; @@ -883,6 +797,8 @@ export interface components { /** Format: int32 */ listensCount: number | string; }; + /** @enum {unknown} */ + MixKind: "Discovery" | "Daily"; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; /** Format: int32 */ @@ -935,18 +851,22 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; - PaginatedResponseOfUserApplicationResponse: { - items: components["schemas"]["UserApplicationResponse"][]; + PictureUploadRequest: { + fileType: string; + contentType: string; + /** Format: int64 */ + expectedSizeBytes?: null | number | string; + }; + PictureUploadResponse: { + /** Format: uuid */ + intentId: string; + bucket: string; + key: string; + pictureName: string; + contentType: string; /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasNextPage: boolean; - hasPreviousPage: boolean; + expiresInSeconds: number | string; + uploadUrl: string; }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; @@ -961,49 +881,26 @@ export interface components { smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; - ownerUserId: string; - visibility: components["schemas"]["PlaylistVisibility"]; - /** Format: int32 */ - trackCount: number | string; - /** Format: double */ - durationSeconds: number | string; + visibility: components["schemas"]["PlayListVisibility"]; /** Format: date-time */ createdAt: string; /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; - }; - PlayListPictureUploadResponse: { - /** Format: uuid */ - intentId: string; - bucket: string; - key: string; - pictureName: string; - contentType: string; + ownerUserId: string; /** Format: int32 */ - expiresInSeconds: number | string; - uploadUrl: string; + trackCount: number | string; + /** Format: double */ + durationSeconds: number | string; }; /** @enum {unknown} */ - PlaylistVisibility: "Private" | "Public"; + PlayListVisibility: "Private" | "Public"; /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; RecordListeningProgressRequest: { /** Format: double */ playedSeconds: number | string; }; - RequestAlbumPictureUploadRequest: { - fileType: string; - contentType: string; - /** Format: int64 */ - expectedSizeBytes?: null | number | string; - }; - RequestPlayListPictureUploadRequest: { - fileType: string; - contentType: string; - /** Format: int64 */ - expectedSizeBytes?: null | number | string; - }; RequestTrackUploadUrlsRequest: { pictureFileType: string; pictureContentType: string; @@ -1036,22 +933,6 @@ export interface components { tags: components["schemas"]["Genre"][]; isExplicit: boolean; }; - TrackSearchItemResponse: { - track: components["schemas"]["TrackApplicationResponse"]; - }; - TracksSearchResponse: { - items: components["schemas"]["TrackSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; TrackStreamResponse: { manifestUrl: string; ticket: string; @@ -1078,19 +959,19 @@ export interface components { expiresInSeconds: number | string; }; UpdateAlbumRequest: { - newTitle: string; - newDescription: null | string; + title: string; + description: null | string; /** Format: int32 */ - newReleaseYear: null | number | string; + releaseYear: null | number | string; /** Format: uuid */ - newPictureIntentId: null | string; + pictureIntentId: null | string; }; UpdatePlayListRequest: { - newName: null | string; - newDescription: null | string; + name: null | string; + description: null | string; /** Format: uuid */ - newPictureIntentId: null | string; - newVisibility?: null | components["schemas"]["PlaylistVisibility"]; + pictureIntentId: null | string; + visibility?: null | components["schemas"]["PlayListVisibility"]; }; UserApplicationResponse: { id: string; @@ -1154,7 +1035,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlbumsSearchResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"]; }; }; }; @@ -1235,6 +1116,103 @@ export interface operations { }; }; }; + UpdateAlbum: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAlbumRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DeleteAlbum: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetAlbumsByUserId: { parameters: { query?: { @@ -1243,7 +1221,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: string; }; cookie?: never; }; @@ -1304,7 +1282,7 @@ export interface operations { }; header?: never; path: { - albumId: string; + id: string; }; cookie?: never; }; @@ -1328,6 +1306,67 @@ export interface operations { }; }; }; + AddAlbumTracks: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTracksRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetAlbumCover: { parameters: { query?: { @@ -1366,7 +1405,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RequestAlbumPictureUploadRequest"]; + "application/json": components["schemas"]["PictureUploadRequest"]; }; }; responses: { @@ -1376,7 +1415,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlbumPictureUploadResponse"]; + "application/json": components["schemas"]["PictureUploadResponse"]; }; }; /** @description Bad Request */ @@ -1404,137 +1443,12 @@ export interface operations { }; }; }; - UpdateAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateAlbumRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AlbumApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - DeleteAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - AddTrackToAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - trackId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RemoveTrackFromAlbum: { parameters: { query?: never; header?: never; path: { - albumId: string; + id: string; trackId: string; }; cookie?: never; @@ -1555,6 +1469,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1755,76 +1676,6 @@ export interface operations { }; }; }; - GetPlayLists: { - parameters: { - query?: { - pageNumber?: number | string; - pageSize?: number | string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PaginatedResponseOfPlayListApplicationResponse"]; - }; - }; - }; - }; - CreatePlayList: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreatePlayListRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PlayListApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetPlayListById: { parameters: { query?: never; @@ -1854,17 +1705,113 @@ export interface operations { }; }; }; + UpdatePlayList: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePlayListRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlayListApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DeletePlayList: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetPlayListsByUserId: { parameters: { query?: { name?: string; pageNumber?: number | string; pageSize?: number | string; - onlyPublic?: boolean; }; header?: never; path: { - userId: number | string; + userId: string; }; cookie?: never; }; @@ -1889,7 +1836,7 @@ export interface operations { }; header?: never; path: { - playlistId: string; + id: string; }; cookie?: never; }; @@ -1918,7 +1865,7 @@ export interface operations { query?: never; header?: never; path: { - playlistId: string; + id: string; albumId: string; }; cookie?: never; @@ -1941,6 +1888,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1950,28 +1904,35 @@ export interface operations { }; }; }; - AddPlayListTrack: { + AddPlayListTracks: { parameters: { query?: never; header?: never; path: { - playlistId: string; + id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AddPlayListTrackRequest"]; + "application/json": components["schemas"]["AddTracksRequest"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TrackApplicationResponse"]; + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; /** @description Unauthorized */ @@ -1981,6 +1942,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2026,23 +1994,21 @@ export interface operations { }; }; }; - UpdatePlayList: { + CreatePlayList: { parameters: { query?: never; header?: never; - path: { - playlistId: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["UpdatePlayListRequest"]; + "application/json": components["schemas"]["CreatePlayListRequest"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; @@ -2075,40 +2041,6 @@ export interface operations { }; }; }; - DeletePlayList: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RequestPlayListPictureUpload: { parameters: { query?: never; @@ -2118,7 +2050,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RequestPlayListPictureUploadRequest"]; + "application/json": components["schemas"]["PictureUploadRequest"]; }; }; responses: { @@ -2128,7 +2060,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PlayListPictureUploadResponse"]; + "application/json": components["schemas"]["PictureUploadResponse"]; }; }; /** @description Bad Request */ @@ -2161,7 +2093,7 @@ export interface operations { query?: never; header?: never; path: { - playlistId: string; + id: string; trackId: string; }; cookie?: never; @@ -2182,6 +2114,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2211,7 +2150,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TracksSearchResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; /** @description Bad Request */ @@ -2308,6 +2247,54 @@ export interface operations { }; }; }; + DeleteTrack: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetTrackCover: { parameters: { query?: { @@ -2420,7 +2407,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: string; }; cookie?: never; }; @@ -2437,47 +2424,6 @@ export interface operations { }; }; }; - DeleteTrack: { - parameters: { - query?: never; - header?: never; - path: { - trackId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RequestTrackUploadUrls: { parameters: { query?: never; @@ -2556,52 +2502,12 @@ export interface operations { }; }; }; - CreateUser: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateUserRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UserApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetUserById: { parameters: { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2630,7 +2536,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2659,7 +2565,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2674,6 +2580,20 @@ export interface operations { "application/json": components["schemas"]["ListeningStatsResponse"]; }; }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; GetLastTrackListenedByUserId: { @@ -2681,7 +2601,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2696,8 +2616,8 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - /** @description Not Found */ - 404: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -2710,7 +2630,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2742,7 +2662,7 @@ export interface operations { }; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2781,7 +2701,7 @@ export interface operations { }; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2810,7 +2730,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2839,7 +2759,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; @@ -2882,7 +2802,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: string; }; cookie?: never; }; diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 0cf4150..93215d8 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -4,8 +4,8 @@ export const SEARCH_DEBOUNCE_MS = 450; export const SEARCH_MIN_LENGTH = 2; export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; export const ALBUMS_PAGE_SIZE = 50; -export const ALBUM_TRACKS_PAGE_SIZE = 200; -export const PLAYLIST_TRACKS_PAGE_SIZE = 200; +export const ALBUM_TRACKS_PAGE_SIZE = 100; +export const PLAYLIST_TRACKS_LIMIT = 500; export const LIBRARY_PICKER_PAGE_SIZE = 50; export const LIBRARY_PAGE_SIZE = 50; export const PLAYLISTS_PAGE_SIZE = 50; @@ -17,7 +17,7 @@ export const HOME_SHELF_LIMIT = 6; export const HOME_CONTINUE_LIMIT = 10; export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; export const PLAYLIST_PICKER_PAGE_SIZE = 50; -export const LIKED_TRACKS_PAGE_SIZE = 500; +export const LIKED_TRACKS_LIMIT = 500; export const EXPLORE_USERS_LIMIT = 12; export const FOLLOW_LIST_PAGE_SIZE = 30; export const MENU_WIDTH = 240; diff --git a/web-player/src/lib/server/actions/playlist.ts b/web-player/src/lib/server/actions/playlist.ts index 9337206..ccc3c59 100644 --- a/web-player/src/lib/server/actions/playlist.ts +++ b/web-player/src/lib/server/actions/playlist.ts @@ -10,15 +10,12 @@ export const addTrackAction = authedAction(async ({ api, form, params }) => { return fail(400, { message: 'Faltan datos de la canción.' }); } - const { error, response } = await api.POST('/playlists/{playlistId}/tracks', { - params: { path: { playlistId } }, - body: { trackId } + const { error } = await api.POST('/playlists/{id}/tracks', { + params: { path: { id: playlistId } }, + body: { trackIds: [trackId] } }); if (error) { - if (response?.status === 409) { - return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); - } return fail(502, { message: 'No se pudo añadir la canción.', trackId }); } @@ -33,8 +30,8 @@ export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params return fail(400, { message: 'Faltan datos del álbum.' }); } - const { data, error } = await api.POST('/playlists/{playlistId}/albums/{albumId}', { - params: { path: { playlistId, albumId } } + const { data, error } = await api.POST('/playlists/{id}/albums/{albumId}', { + params: { path: { id: playlistId, albumId } } }); if (error || !data) { diff --git a/web-player/src/lib/server/mappers.test.ts b/web-player/src/lib/server/mappers.test.ts index c8e1d4e..6605405 100644 --- a/web-player/src/lib/server/mappers.test.ts +++ b/web-player/src/lib/server/mappers.test.ts @@ -56,11 +56,11 @@ describe('toMix', () => { it('maps items into tracks', () => { const mix = toMix({ id: 'm1', - title: 'Mix', - subtitle: null, + kind: 'Daily', itemCount: 1, items: [{ trackId: 't1', title: 'Song', artist: 'A', durationSeconds: '60', listensCount: 3 }] }); + expect(mix.title).toBe('Tu mezcla diaria'); expect(mix.tracks).toEqual([ { id: 't1', diff --git a/web-player/src/lib/server/mappers.ts b/web-player/src/lib/server/mappers.ts index d962197..314c2db 100644 --- a/web-player/src/lib/server/mappers.ts +++ b/web-player/src/lib/server/mappers.ts @@ -46,11 +46,21 @@ export function toMixTrack(item: MixItemDto): Track { }; } +const MIX_TEXTS: Record = { + Discovery: { + title: 'Descubrimiento', + subtitle: 'Canciones de tu biblioteca que todavía no has escuchado.' + }, + Daily: { + title: 'Tu mezcla diaria', + subtitle: 'Lo que más escuchas, con alguna sorpresa de tu biblioteca.' + } +}; + export function toMix(dto: MixDto): Mix { return { id: dto.id, - title: dto.title, - subtitle: dto.subtitle, + ...MIX_TEXTS[dto.kind], tracks: dto.items.map(toMixTrack) }; } diff --git a/web-player/src/lib/server/pagination.ts b/web-player/src/lib/server/pagination.ts new file mode 100644 index 0000000..f8805b1 --- /dev/null +++ b/web-player/src/lib/server/pagination.ts @@ -0,0 +1,22 @@ +/** Largest page size the API accepts (`PageRequest.MaxPageSize` in the backend). */ +export const API_MAX_PAGE_SIZE = 100; + +type Page = { items: T[]; hasNextPage: boolean }; + +/** Loads consecutive pages until the list ends or `maxItems` items were collected. */ +export async function fetchAllPages( + loadPage: (pageNumber: number, pageSize: number) => Promise | undefined>, + maxItems: number +): Promise { + const items: T[] = []; + + for (let pageNumber = 1; items.length < maxItems; pageNumber++) { + const page = await loadPage(pageNumber, API_MAX_PAGE_SIZE); + if (!page) break; + + items.push(...page.items); + if (!page.hasNextPage) break; + } + + return items.slice(0, maxItems); +} diff --git a/web-player/src/routes/(app)/+layout.server.ts b/web-player/src/routes/(app)/+layout.server.ts index e8032fa..674ac16 100644 --- a/web-player/src/routes/(app)/+layout.server.ts +++ b/web-player/src/routes/(app)/+layout.server.ts @@ -4,7 +4,8 @@ import { apiFor } from '$lib/server/api'; import { loginRedirect } from '$lib/server/guards'; import { toCount, toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers'; import { authConfig } from '$lib/server/config'; -import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; +import { fetchAllPages } from '$lib/server/pagination'; +import { LIKED_TRACKS_LIMIT, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; function orFallback(load: Promise, fallback: T): Promise { return load.catch(() => fallback); @@ -50,11 +51,11 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { { items: [], total: 0 } ), orFallback( - api - .GET('/likes', { - params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } } - }) - .then(({ data }) => (data?.items ?? []).map(toLikedTrack)), + fetchAllPages( + async (pageNumber, pageSize) => + (await api.GET('/likes', { params: { query: { pageNumber, pageSize } } })).data, + LIKED_TRACKS_LIMIT + ).then((items) => items.map(toLikedTrack)), [] ), orFallback( diff --git a/web-player/src/routes/(app)/+page.server.ts b/web-player/src/routes/(app)/+page.server.ts index 3f6fca1..beadcc9 100644 --- a/web-player/src/routes/(app)/+page.server.ts +++ b/web-player/src/routes/(app)/+page.server.ts @@ -37,9 +37,9 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { const spotlightPlaylist = playlistItems[0] ?? null; const spotlightTracksPromise = spotlightPlaylist ? api - .GET('/playlists/{playlistId}/tracks', { + .GET('/playlists/{id}/tracks', { params: { - path: { playlistId: spotlightPlaylist.id }, + path: { id: spotlightPlaylist.id }, query: { pageNumber: 1, pageSize: HOME_SPOTLIGHT_TRACKS_LIMIT } } }) diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index 6dc0a2b..959dbeb 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -16,9 +16,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const [albumRes, tracksRes, libraryRes] = await Promise.all([ api.GET('/albums/{id}', { params: { path: { id: params.id } } }), - api.GET('/albums/{albumId}/tracks', { + api.GET('/albums/{id}/tracks', { params: { - path: { albumId: params.id }, + path: { id: params.id }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } }), @@ -50,8 +50,9 @@ export const actions: Actions = { const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const result = await api.POST('/albums/{albumId}/tracks/{trackId}', { - params: { path: { albumId: params.id, trackId } } + const result = await api.POST('/albums/{id}/tracks', { + params: { path: { id: params.id } }, + body: { trackIds: [trackId] } }); return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true }; }), @@ -60,8 +61,8 @@ export const actions: Actions = { const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', { - params: { path: { albumId: params.id, trackId } } + const result = await api.DELETE('/albums/{id}/tracks/{trackId}', { + params: { path: { id: params.id, trackId } } }); return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true }; }), @@ -75,21 +76,21 @@ export const actions: Actions = { return fail(502, { message: cover.failMessage, detail: cover.detail }); } - const result = await api.PUT('/albums/{albumId}', { - params: { path: { albumId: params.id } }, + const result = await api.PUT('/albums/{id}', { + params: { path: { id: params.id } }, body: { - newTitle: parsed.body.title, - newDescription: parsed.body.description, - newReleaseYear: parsed.body.releaseYear, - newPictureIntentId: cover.intentId + title: parsed.body.title, + description: parsed.body.description, + releaseYear: parsed.body.releaseYear, + pictureIntentId: cover.intentId } }); return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true }; }), delete: authedAction(async ({ api, params }) => { - const result = await api.DELETE('/albums/{albumId}', { - params: { path: { albumId: params.id } } + const result = await api.DELETE('/albums/{id}', { + params: { path: { id: params.id } } }); const failure = failOnError(result, 'No se pudo eliminar el álbum.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 12d4a0f..fd4d2ff 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -46,16 +46,14 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null; const tracksRes = genre === genreParam ? firstTracksRes : await tracksFor(genre); - const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) => - toTrack(item.track) - ); + const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), toTrack); return { query, genre, genres, tracks, - albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)), + albums: (albumsRes?.data?.items ?? []).map(toAlbum), albumsTotal: toCount(albumsRes?.data?.totalItemCount), users: usersRes?.data?.items ?? [], usersTotal: toCount(usersRes?.data?.totalItemCount), diff --git a/web-player/src/routes/(app)/library/+page.server.ts b/web-player/src/routes/(app)/library/+page.server.ts index 684dc53..11457e0 100644 --- a/web-player/src/routes/(app)/library/+page.server.ts +++ b/web-player/src/routes/(app)/library/+page.server.ts @@ -24,8 +24,8 @@ export const actions: Actions = { const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const result = await api.DELETE('/tracks/{trackId}', { - params: { path: { trackId } } + const result = await api.DELETE('/tracks/{id}', { + params: { path: { id: trackId } } }); return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true }; }) diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 8449071..37ffc2c 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -4,7 +4,8 @@ import { apiFor, failOnError, unwrapOrError } from '$lib/server/api'; import { optionalUser } from '$lib/server/guards'; import { formString } from '$lib/server/forms/fields'; import { authedAction } from '$lib/server/actions/authedAction'; -import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; +import { PLAYLIST_TRACKS_LIMIT } from '$lib/config'; +import { fetchAllPages } from '$lib/server/pagination'; import { toDatedTrack, toPlaylist } from '$lib/server/mappers'; import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; import { uploadOptionalCover } from '$lib/server/upload'; @@ -14,18 +15,21 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const user = optionalUser(locals, url, allowAnonymousListening); const api = apiFor({ fetch, locals }); - const [playlistRes, tracksRes] = await Promise.all([ + const [playlistRes, trackDtos] = await Promise.all([ api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), - api.GET('/playlists/{playlistId}/tracks', { - params: { - path: { playlistId: params.id }, - query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE } - } - }) + fetchAllPages( + async (pageNumber, pageSize) => + ( + await api.GET('/playlists/{id}/tracks', { + params: { path: { id: params.id }, query: { pageNumber, pageSize } } + }) + ).data, + PLAYLIST_TRACKS_LIMIT + ) ]); const playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404)); - const tracks = (tracksRes.data?.items ?? []).map(toDatedTrack); + const tracks = trackDtos.map(toDatedTrack); const isOwner = user !== null && String(playlist.ownerUserId) === user.sub; @@ -37,8 +41,8 @@ export const actions: Actions = { const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { - params: { path: { playlistId: params.id, trackId } } + const result = await api.DELETE('/playlists/{id}/tracks/{trackId}', { + params: { path: { id: params.id, trackId } } }); return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true }; }), @@ -53,21 +57,21 @@ export const actions: Actions = { return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); } - const result = await api.PUT('/playlists/{playlistId}', { - params: { path: { playlistId: params.id } }, + const result = await api.PUT('/playlists/{id}', { + params: { path: { id: params.id } }, body: { - newName: name, - newDescription: description, - newPictureIntentId: uploaded.intentId, - newVisibility: visibility + name, + description, + pictureIntentId: uploaded.intentId, + visibility } }); return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true }; }), delete: authedAction(async ({ api, params }) => { - const result = await api.DELETE('/playlists/{playlistId}', { - params: { path: { playlistId: params.id } } + const result = await api.DELETE('/playlists/{id}', { + params: { path: { id: params.id } } }); const failure = failOnError(result, 'No se pudo eliminar la playlist.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index 779ff28..734d790 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -15,7 +15,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent api.GET('/playlists/users/{userId}', { params: { path: { userId: params.id }, - query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true } + query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE } } }) ]); diff --git a/web-player/src/routes/api/albums/[id]/tracks/+server.ts b/web-player/src/routes/api/albums/[id]/tracks/+server.ts index 853006d..6d6393b 100644 --- a/web-player/src/routes/api/albums/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/albums/[id]/tracks/+server.ts @@ -6,9 +6,9 @@ import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { const api = apiFor({ fetch, locals }); - const result = await api.GET('/albums/{albumId}/tracks', { + const result = await api.GET('/albums/{id}/tracks', { params: { - path: { albumId: params.id }, + path: { id: params.id }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } }); diff --git a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts index 666e35f..bb3f3b0 100644 --- a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts @@ -1,17 +1,26 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { apiFor, unwrapOrError } from '$lib/server/api'; -import { toPage, toTrack } from '$lib/server/mappers'; -import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; +import { apiFor } from '$lib/server/api'; +import { toTrack } from '$lib/server/mappers'; +import { fetchAllPages } from '$lib/server/pagination'; +import { PLAYLIST_TRACKS_LIMIT } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { const api = apiFor({ fetch, locals }); - const result = await api.GET('/playlists/{playlistId}/tracks', { - params: { - path: { playlistId: params.id }, - query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE } - } - }); + const tracks = await fetchAllPages( + async (pageNumber, pageSize) => + ( + await api.GET('/playlists/{id}/tracks', { + params: { path: { id: params.id }, query: { pageNumber, pageSize } } + }) + ).data, + PLAYLIST_TRACKS_LIMIT + ); - return json(toPage(unwrapOrError(result, 'No se pudo cargar la playlist.'), toTrack)); + return json({ + items: tracks.map(toTrack), + pageNumber: 1, + hasNextPage: false, + totalItemCount: tracks.length + }); }; diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index f90eeed..a76085c 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -18,9 +18,5 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { params: { query: { name, genre, pageNumber, pageSize } } }); - return json( - toPage(unwrapOrError(result, 'No se pudieron cargar las canciones.'), (item) => - toTrack(item.track) - ) - ); + return json(toPage(unwrapOrError(result, 'No se pudieron cargar las canciones.'), toTrack)); };