From 75d0c0817e43b68f7876cd28f47b7094f8996dd4 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:04:54 +0200 Subject: [PATCH] Deduplicate picture flows: shared upload issuer, cover query and picture source change --- .../Musify.Application/Albums/CreateAlbum.cs | 71 ++------- .../Albums/GetAlbumCover.cs | 57 -------- .../Albums/RequestAlbumPictureUpload.cs | 96 +----------- .../Musify.Application/Albums/UpdateAlbum.cs | 104 +++---------- .../ApplicationDependencyInjection.cs | 1 + .../Events/CreateAlbumResourcesEvent.cs | 4 +- .../Events/CreatePlayListResourcesEvent.cs | 4 +- .../Events/CreateTrackResourcesEvent.cs | 4 +- .../Events/UpdateAlbumPictureEvent.cs | 4 +- .../Events/UpdateAlbumPictureSourceEvent.cs | 4 +- .../Events/UpdatePlayListPictureEvent.cs | 4 +- .../UpdatePlayListPictureSourceEvent.cs | 4 +- .../Events/UpdateTrackPictureEvent.cs | 4 +- .../Musify.Application/Pictures/GetCover.cs | 75 ++++++++++ .../Pictures/PictureSourceChange.cs | 35 +++++ .../Responses/PictureUploadResponse.cs} | 4 +- .../PlayLists/CreatePlayList.cs | 103 ++++--------- .../PlayLists/GetPlayListCover.cs | 57 -------- .../PlayLists/RequestPlayListPictureUpload.cs | 96 +----------- .../PlayListPictureUploadResponse.cs | 10 -- .../PlayLists/UpdatePlayList.cs | 118 ++++----------- .../Services/UploadIntentIssuer.cs | 133 +++++++++++++++++ .../Services/UploadIntentValidator.cs | 31 ++-- .../Musify.Application/Tracks/CreateTrack.cs | 54 ++----- .../Tracks/GetTrackCover.cs | 50 ------- .../Tracks/RequestTrackUploadUrls.cs | 137 ++++-------------- ...lishAlbumPictureProcessingEventActivity.cs | 4 +- ...hPlayListPictureProcessingEventActivity.cs | 4 +- .../PublishTrackProcessingEventsActivity.cs | 4 +- ...ishAlbumPictureProcessingEventArguments.cs | 4 +- ...PlayListPictureProcessingEventArguments.cs | 4 +- .../PublishTrackProcessingEventsArguments.cs | 4 +- .../AlbumPictureSourceRoutingSlipBuilder.cs | 16 +- .../Builders/CreateTrackRoutingSlipBuilder.cs | 4 +- .../PictureWorkflowRoutingSlipBuilder.cs | 22 +-- ...PlayListPictureSourceRoutingSlipBuilder.cs | 16 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 18 +-- .../Musify.Api/Endpoints/CoverEndpoint.cs | 31 ++++ .../Musify.Api/Endpoints/PlayListEndpoints.cs | 18 +-- .../Musify.Api/Endpoints/TrackEndpoints.cs | 15 +- .../Endpoints/AlbumEndpointsTests.cs | 3 +- .../Albums/CreateAlbumCommandHandlerTests.cs | 6 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 52 ------- ...stAlbumPictureUploadCommandHandlerTests.cs | 17 ++- .../Albums/UpdateAlbumCommandHandlerTests.cs | 6 +- .../Pictures/GetCoverQueryHandlerTests.cs | 104 +++++++++++++ .../CreatePlayListCommandHandlerTests.cs | 6 +- .../GetPlayListCoverQueryHandlerTests.cs | 50 ------- ...layListPictureUploadCommandHandlerTests.cs | 17 ++- .../UpdatePlayListCommandHandlerTests.cs | 6 +- .../Services/UploadIntentValidatorTests.cs | 26 ++-- .../Tracks/CreateTrackCommandHandlerTests.cs | 6 +- .../Tracks/GetTrackCoverQueryHandlerTests.cs | 51 ------- ...questTrackUploadUrlsCommandHandlerTests.cs | 15 +- web-player/src/lib/api/schema.d.ts | 37 ++--- 55 files changed, 667 insertions(+), 1163 deletions(-) delete mode 100644 backend/Core/Musify.Application/Albums/GetAlbumCover.cs create mode 100644 backend/Core/Musify.Application/Pictures/GetCover.cs create mode 100644 backend/Core/Musify.Application/Pictures/PictureSourceChange.cs rename backend/Core/Musify.Application/{Albums/Responses/AlbumPictureUploadResponse.cs => Pictures/Responses/PictureUploadResponse.cs} (63%) delete mode 100644 backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs delete mode 100644 backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs create mode 100644 backend/Core/Musify.Application/Services/UploadIntentIssuer.cs delete mode 100644 backend/Core/Musify.Application/Tracks/GetTrackCover.cs create mode 100644 backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 5c37244..de98485 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -5,9 +5,10 @@ using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -26,9 +27,7 @@ public class CreateAlbumCommandHandler( IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + AlbumConfiguration albumConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) @@ -37,17 +36,14 @@ public class CreateAlbumCommandHandler( .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, UploadIntentPurpose.AlbumPicture, 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 @@ -57,57 +53,20 @@ public class CreateAlbumCommandHandler( NormalizedTitle = TextNormalizer.Normalize(title), Description = request.Description, ReleaseYear = request.ReleaseYear, - Pictures = EntityPictures.Pending(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 - { - var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes); - await eventBus.PublishAsync( - new CreateAlbumResourcesEvent( - albumId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), - cancellationToken); - return Result.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/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs deleted file mode 100644 index b9ca892..0000000 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ /dev/null @@ -1,57 +0,0 @@ -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.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 owned = await database.Albums.AsNoTracking() - .Where(e => e.Id == request.AlbumId && e.LifeCycleStatus == LifeCycleStatus.Active) - .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) - .SingleOrDefaultAsync(cancellationToken); - - if (owned?.Pictures == null) - return AppErrors.NotFound("AlbumCover", request.AlbumId); - - var pictures = owned.Pictures; - var routes = albumConfiguration.Routes; - var size = PictureSizeParser.Parse(request.Size); - var name = size switch - { - PictureSize.Small => pictures.SmallName, - PictureSize.Large => pictures.LargeName, - _ => pictures.MediumName - }; - var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); - - // The resized files only exist once processing has finished; until then serve the original. - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - { - name = pictures.OriginalName; - key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); - } - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return AppErrors.NotFound("AlbumCover", request.AlbumId); - - var contentType = MimeUtility.GetMimeMapping(name); - return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); - } -} diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index b689b71..14fdd71 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -1,12 +1,8 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Albums.Responses; using Musify.Application.Configuration; -using Musify.Application.Contracts; +using Musify.Application.Pictures.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -16,89 +12,13 @@ public record RequestAlbumPictureUploadCommand( 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.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, 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/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 4d58142..b4d798d 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -6,9 +6,9 @@ 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; namespace Musify.Application.Albums; @@ -27,27 +27,16 @@ public class UpdateAlbumCommandHandler( 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 AppErrors.Forbidden("Album", request.AlbumId); - } + 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; @@ -55,75 +44,30 @@ public class UpdateAlbumCommandHandler( album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; - if (request.NewPictureIntentId.HasValue) + if (request.NewPictureIntentId is { } intentId) { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, 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; - var pictureIntent = validation.Value; - album.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); - var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + var picture = prepared.Value; + album.Pictures = picture.Pictures; - 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 - { - var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes); await eventBus.PublishAsync( new UpdateAlbumPictureSourceEvent( - albumId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return Result.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/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index b1fb216..8e22f82 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -11,6 +11,7 @@ public static class ApplicationDependencyInjection { services.AddMediator(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddValidatedOptions(configuration); diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs index 18290e1..01dada3 100644 --- a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -8,6 +8,4 @@ public record CreateAlbumResourcesEvent( 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 b26409c..9ad6955 100644 --- a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs @@ -8,6 +8,4 @@ public record CreatePlayListResourcesEvent( 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 da92b47..baa9315 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -12,6 +12,4 @@ public record CreateTrackResourcesEvent( string AudioSourceKey, string AudioDestinationKey, string AudioProcessedFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs index 35324bb..6c8f43e 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -6,6 +6,4 @@ public record UpdateAlbumPictureEvent( 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 72a7c52..8fa33b8 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -8,6 +8,4 @@ public record UpdateAlbumPictureSourceEvent( 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 93eb1fd..1b2d646 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs @@ -6,6 +6,4 @@ public record UpdatePlayListPictureEvent( Guid PlayListId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs index b3c0f61..bba5c34 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -8,6 +8,4 @@ public record UpdatePlayListPictureSourceEvent( string Bucket, string PictureSourceKey, string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs index bab9787..0c83c9e 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs @@ -6,6 +6,4 @@ public record UpdateTrackPictureEvent( Guid TrackId, string Bucket, string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Application/Pictures/GetCover.cs b/backend/Core/Musify.Application/Pictures/GetCover.cs new file mode 100644 index 0000000..6e91e2d --- /dev/null +++ b/backend/Core/Musify.Application/Pictures/GetCover.cs @@ -0,0 +1,75 @@ +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..ea8070b --- /dev/null +++ b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs @@ -0,0 +1,35 @@ +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 63% rename from backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs rename to backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs index bc39921..60cafb6 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/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 76b8e2b..2b37a88 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -4,10 +4,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Shared; 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; @@ -26,106 +27,52 @@ public class CreatePlayListCommandHandler( IDatabase database, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + PlayListConfiguration playListConfiguration) : ICommandHandler> { - private sealed record ResolvedPlayListPictures(EntityPictures? 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 { OwnerUserId = request.UserId, - Name = request.Name.Trim(), - NormalizedName = TextNormalizer.Normalize(request.Name), + 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) + if (picture != 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, UploadIntentPurpose.PlayListPicture, cancellationToken); - - if (validation.IsError) - return validation.Errors; - - return new ResolvedPlayListPictures(EntityPictures.Pending(validation.Value.ObjectName), validation.Value); - } - - private async Task> PublishCreatePlayListEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) - { - try - { - var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes); await eventBus.PublishAsync( new CreatePlayListResourcesEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return Result.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/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs deleted file mode 100644 index fab4d48..0000000 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ /dev/null @@ -1,57 +0,0 @@ -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.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 owned = await database.PlayLists.AsNoTracking() - .Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active) - .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) - .SingleOrDefaultAsync(cancellationToken); - - if (owned?.Pictures == null) - return AppErrors.NotFound("PlayListCover", request.PlayListId); - - var pictures = owned.Pictures; - var routes = playListConfiguration.Routes; - var size = PictureSizeParser.Parse(request.Size); - var name = size switch - { - PictureSize.Small => pictures.SmallName, - PictureSize.Large => pictures.LargeName, - _ => pictures.MediumName - }; - var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); - - // The resized files only exist once processing has finished; until then serve the original. - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - { - name = pictures.OriginalName; - key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); - } - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return AppErrors.NotFound("PlayListCover", request.PlayListId); - - var contentType = MimeUtility.GetMimeMapping(name); - return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); - } -} diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 6603940..44d9e68 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -1,12 +1,8 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.PlayLists.Responses; +using Musify.Application.Pictures.Responses; using Musify.Application.Services; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -16,89 +12,13 @@ public record RequestPlayListPictureUploadCommand( 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.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, 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/PlayListPictureUploadResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs deleted file mode 100644 index 6937944..0000000 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs +++ /dev/null @@ -1,10 +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/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 53a2dd1..1d29ec1 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Pictures; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; using Musify.Application.Shared; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -27,108 +27,54 @@ public class UpdatePlayListCommandHandler( 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.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return AppErrors.Forbidden("PlayList", request.PlayListId); - } + var playList = found.Value; if (!string.IsNullOrWhiteSpace(request.NewName)) { - playListEntity.Name = request.NewName.Trim(); - playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName); + playList.Name = request.NewName.Trim(); + playList.NormalizedName = TextNormalizer.Normalize(request.NewName); } + // null means "unchanged"; a blank description clears it. if (request.NewDescription != null) + playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); + + if (request.NewVisibility is { } visibility) + playList.Visibility = visibility; + + if (request.NewPictureIntentId is { } intentId) { - playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); - } + 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; - if (request.NewPictureIntentId.HasValue) - { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken); - if (validation.IsError) - return validation.Errors; - - var pictureIntent = validation.Value; - playListEntity.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - - 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); - return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); - } - - private async Task> PublishUpdatePlayListPictureSourceEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) - { - try - { - var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes); await eventBus.PublishAsync( new UpdatePlayListPictureSourceEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - sizes.Small, - sizes.Medium, - sizes.Large), + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), cancellationToken); - return Result.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/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs new file mode 100644 index 0000000..18660f5 --- /dev/null +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -0,0 +1,133 @@ +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); + + 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 039e57e..4888ea7 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -2,40 +2,49 @@ 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.Shared; namespace Musify.Application.Services; public sealed class UploadIntentValidator( IDatabase database, - IStorageService storageService) + IStorageService storageService, + UploadIntentConfiguration config) { public async Task> CheckQuotaAsync( - UploadIntentConfiguration config, long userId, long requiredBytes, int requiredIntentCount, CancellationToken cancellationToken) { - var activeIntents = await database.UploadIntents - .AsNoTracking() - .Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued) - .ToListAsync(cancellationToken); + var now = DateTime.UtcNow; + var pictureDefault = config.DefaultExpectedPictureSizeBytes; + var audioDefault = config.DefaultExpectedAudioSizeBytes; - if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) + 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); + + if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); - var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes); - if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser) + if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); return Result.Success; } public async Task> ValidateAndLoadAsync( - UploadIntentConfiguration config, Guid intentId, long userId, UploadIntentPurpose purpose, diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index cd4ed2a..ec28219 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -27,9 +27,7 @@ public class CreateTrackCommandHandler( IEventBus eventBus, UploadIntentValidator uploadIntentValidator, ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) + TrackConfiguration trackConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) @@ -63,13 +61,11 @@ public class CreateTrackCommandHandler( return Error.Validation(description: "Picture and audio must use different upload intents."); var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); if (pictureValidation.IsError) return pictureValidation.Errors; var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); if (audioValidation.IsError) return audioValidation.Errors; @@ -115,40 +111,22 @@ public class CreateTrackCommandHandler( TrackId = trackEntity.Id }, cancellationToken); - try - { - var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); - await eventBus.PublishAsync( - new CreateTrackResourcesEvent( - trackEntity.Id, - pictureIntent.Id, - audioIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - audioIntent.Key, - finalAudioKey, - audioProcessedFolderKey, - sizes.Small, - sizes.Medium, - sizes.Large), - 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/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs deleted file mode 100644 index 51a963c..0000000 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ /dev/null @@ -1,50 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.ValueObjects; -using MimeMapping; - -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 && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) - .SingleOrDefaultAsync(cancellationToken); - - if (track == null) - return AppErrors.NotFound("TrackCover", request.TrackId); - - var routes = trackConfiguration.Routes; - var size = PictureSizeParser.Parse(request.Size); - var name = size switch - { - PictureSize.Small => track.SmallName, - PictureSize.Large => track.LargeName, - _ => track.MediumName - }; - var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); - - if (string.IsNullOrEmpty(name) || key == null) - return AppErrors.NotFound("TrackCover", request.TrackId); - - var contentType = MimeUtility.GetMimeMapping(name); - - return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); - } -} diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 143dce0..5c51124 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -1,12 +1,8 @@ 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; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -22,10 +18,7 @@ public record RequestTrackUploadUrlsCommand( : ICommand>; public class RequestTrackUploadUrlsCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, + UploadIntentIssuer issuer, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration, UploadIntentConfiguration uploadIntentConfiguration) @@ -33,110 +26,32 @@ public class RequestTrackUploadUrlsCommandHandler( { 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.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName); - var tempAudioKey = trackConfiguration.Routes.BuildTempPath( - uploadIntentConfiguration.TempRootPrefix, 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.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs index c5c15c1..ae54c09 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 @@ internal class PublishAlbumPictureProcessingEventActivity( 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/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs index 530774d..c1b104c 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 @@ internal class PublishPlayListPictureProcessingEventActivity( 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/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs index 8e2f03a..8cf6b1f 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 @@ internal class PublishTrackProcessingEventsActivity( 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/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs index 6600b1f..10cfa7a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -6,6 +6,4 @@ public record PublishAlbumPictureProcessingEventArguments( 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 31000ae..b4831c0 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -6,6 +6,4 @@ public record PublishPlayListPictureProcessingEventArguments( 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 f451b98..6bc151b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -8,6 +8,4 @@ public record PublishTrackProcessingEventsArguments( string FinalPictureKey, string FinalAudioKey, string AudioDestinationFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); + ImageSizes Sizes); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs index 866dbca..5beae29 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs @@ -19,9 +19,7 @@ public class AlbumPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation); @@ -32,9 +30,7 @@ public class AlbumPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); @@ -44,9 +40,7 @@ public class AlbumPictureSourceRoutingSlipBuilder string bucket, string pictureSourceKey, string pictureDestinationKey, - ImageSize small, - ImageSize medium, - ImageSize large, + ImageSizes sizes, Guid? correlationId, string processKind) { @@ -78,9 +72,7 @@ public class AlbumPictureSourceRoutingSlipBuilder albumId, bucket, pictureDestinationKey, - small, - medium, - large)); + sizes)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, albumId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index 426f544..f50a844 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -53,9 +53,7 @@ public class CreateTrackRoutingSlipBuilder message.PictureDestinationKey, message.AudioDestinationKey, message.AudioProcessedFolderKey, - message.Small, - message.Medium, - message.Large)); + message.Sizes)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackCreation); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index b827afb..ba6fa48 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -17,9 +17,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu var routingSlipBuilder = BuildPictureWorkflow( message.Bucket, message.SourceKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId); routingSlipBuilder.AddActivity( @@ -46,9 +44,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu var routingSlipBuilder = BuildPictureWorkflow( message.Bucket, message.SourceKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId); routingSlipBuilder.AddActivity( @@ -75,9 +71,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu var routingSlipBuilder = BuildPictureWorkflow( message.Bucket, message.SourceKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId); routingSlipBuilder.AddActivity( @@ -102,9 +96,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu private RoutingSlipBuilder BuildPictureWorkflow( string bucket, string sourceKey, - ImageSize small, - ImageSize medium, - ImageSize large, + ImageSizes sizes, Guid? correlationId) { var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); @@ -135,7 +127,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, - small); + sizes.Small); AddResizeActivity( routingSlipBuilder, @@ -143,7 +135,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, - medium); + sizes.Medium); AddResizeActivity( routingSlipBuilder, @@ -151,7 +143,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, - large); + sizes.Large); return routingSlipBuilder; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs index 6e79a3b..6744749 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs @@ -19,9 +19,7 @@ public class PlayListPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation); @@ -32,9 +30,7 @@ public class PlayListPictureSourceRoutingSlipBuilder message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, + message.Sizes, correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture); @@ -44,9 +40,7 @@ public class PlayListPictureSourceRoutingSlipBuilder string bucket, string pictureSourceKey, string pictureDestinationKey, - ImageSize small, - ImageSize medium, - ImageSize large, + ImageSizes sizes, Guid? correlationId, string processKind) { @@ -78,9 +72,7 @@ public class PlayListPictureSourceRoutingSlipBuilder playListId, bucket, pictureDestinationKey, - small, - medium, - large)); + sizes)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, playListId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index d75a5b5..e4ad0e9 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -6,6 +6,8 @@ using Musify.Api.Filters; using Musify.Application.Albums; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Pictures.Responses; using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; @@ -65,7 +67,7 @@ public static class AlbumEndpoints .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") .AddEndpointFilter>() .RequireAuthorization() - .Produces() + .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -179,7 +181,7 @@ public static class AlbumEndpoints return result.ToHttpResult(); } - private static async Task GetAlbumCover( + private static Task GetAlbumCover( IMediator mediator, IStorageService storageService, HttpResponse response, @@ -187,17 +189,7 @@ public static class AlbumEndpoints CancellationToken cancellationToken, string size = "medium") { - var result = await mediator.Send(new GetAlbumCoverQuery(id, 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); + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); } private static async Task RequestAlbumPictureUpload( diff --git a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs new file mode 100644 index 0000000..7bf3570 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs @@ -0,0 +1,31 @@ +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/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index d3daa30..70ca6ce 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -4,6 +4,8 @@ using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Contracts; +using Musify.Application.Pictures; +using Musify.Application.Pictures.Responses; using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; using Musify.Api.Models; @@ -80,7 +82,7 @@ public static class PlayListEndpoints .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") .AddEndpointFilter>() .RequireAuthorization() - .Produces() + .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -140,7 +142,7 @@ public static class PlayListEndpoints return result.ToHttpResult(); } - private static async Task GetPlayListCover( + private static Task GetPlayListCover( IMediator mediator, IStorageService storageService, HttpResponse response, @@ -148,17 +150,7 @@ public static class PlayListEndpoints CancellationToken cancellationToken, string size = "medium") { - var result = await mediator.Send(new GetPlayListCoverQuery(id, 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); + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); } private static async Task CreatePlayList( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index a718e6d..8b6e765 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Contracts; +using Musify.Application.Pictures; using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; @@ -128,7 +129,7 @@ public static class TrackEndpoints return result.ToHttpResult(); } - private static async Task GetTrackCover( + private static Task GetTrackCover( IMediator mediator, IStorageService storageService, HttpResponse response, @@ -136,17 +137,7 @@ public static class TrackEndpoints CancellationToken cancellationToken, string size = "medium") { - var result = await mediator.Send(new GetTrackCoverQuery(id, 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); + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); } private static async Task GetTrackStream( diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 68745e8..793eca0 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; +using Musify.Application.Pictures.Responses; using Musify.Application.Contracts; using NSubstitute; using Xunit; @@ -29,7 +30,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) 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; } diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 42b56d7..5e5972c 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase 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/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs deleted file mode 100644 index 00c82be..0000000 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ /dev/null @@ -1,52 +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); - } - - [Fact] - public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.EntityPictures.Pending("original.png")); - await SeedAsync(owner, album); - - var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "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); - } -} diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 1a17d19..05d4a2e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -20,14 +20,17 @@ public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBa .GetUploadUrlAsync(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 b1c6695..41916a3 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase 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() 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..63a3c8f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs @@ -0,0 +1,104 @@ +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/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index a724a5a..53ffc79 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase 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() 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 6a7e02f..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs +++ /dev/null @@ -1,50 +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/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 78eab3f..d08a8ae 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -20,14 +20,17 @@ public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTes .GetUploadUrlAsync(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 848f58c..c151868 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -16,11 +16,9 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase 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() diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index ec75365..95967e3 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 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase 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,7 +27,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase 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, UploadIntentPurpose.TrackPicture, 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); @@ -37,7 +39,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -45,7 +47,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase [Fact] public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -57,7 +59,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, stranger, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -68,7 +70,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -79,7 +81,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase 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, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -94,7 +96,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -112,7 +114,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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); } @@ -122,7 +124,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase { 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); } @@ -134,7 +136,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase 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); } @@ -149,7 +151,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase 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/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index dda3226..26a32e6 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -24,11 +24,9 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase 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) => 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 a4a8615..0000000 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ /dev/null @@ -1,51 +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/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index eab3b41..6eb2e52 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -21,14 +21,19 @@ public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase .GetUploadUrlAsync(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/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 6e889eb..1fb9389 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -752,17 +752,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; - }; AvailableGenreResponse: { genre: components["schemas"]["Genre"]; incompatibleWith: components["schemas"]["Genre"][]; @@ -889,6 +878,17 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; + PictureUploadResponse: { + /** Format: uuid */ + intentId: string; + bucket: string; + key: string; + pictureName: string; + contentType: string; + /** Format: int32 */ + expiresInSeconds: number | string; + uploadUrl: string; + }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; /** Format: int32 */ @@ -914,17 +914,6 @@ export interface components { /** Format: double */ durationSeconds: number | string; }; - PlayListPictureUploadResponse: { - /** Format: uuid */ - intentId: string; - bucket: string; - key: string; - pictureName: string; - contentType: string; - /** Format: int32 */ - expiresInSeconds: number | string; - uploadUrl: string; - }; /** @enum {unknown} */ PlayListVisibility: "Private" | "Public"; /** @enum {unknown} */ @@ -1301,7 +1290,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlbumPictureUploadResponse"]; + "application/json": components["schemas"]["PictureUploadResponse"]; }; }; /** @description Bad Request */ @@ -2041,7 +2030,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PlayListPictureUploadResponse"]; + "application/json": components["schemas"]["PictureUploadResponse"]; }; }; /** @description Bad Request */