From 478b90d75c2be8efe3b89d2608a5ac44204999b9 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 10 Sep 2026 15:04:36 +0200 Subject: [PATCH] Make album cover pictures mandatory, unify cover handling with playlists Albums previously had no cover concept at all: no picture upload flow, no GET /albums/{id}/cover, and AlbumApplicationResponse's image-key fields were plumbed but never populated. Description, Pictures and TrackCount already lived on the shared Album base entity for both local and external albums, so unifying the cover story only required building the missing local-album picture pipeline. Backend, cloning PlayList's existing async picture-processing architecture exactly (MassTransit routing slip + saga), but making the picture mandatory instead of optional (matching Track's pattern): - AlbumPictures moves from all-nullable to required fields. - New AlbumConfiguration (routes/sizes), deliberately without PlayList's preset-picture fallback since a picture is now required up front. - CreateAlbum now takes a mandatory PictureIntentId, validates and consumes the upload intent, and kicks off the same copy-to-final -> resize -> upload -> update-entity routing slip PlayLists use, via a new AlbumProcessingState saga that marks the album Failed on a picture-processing failure. - UpdateAlbum takes an optional NewPictureIntentId to replace the cover later. - New GET /albums/{id}/cover and POST /albums/upload-picture endpoints, mirroring the playlist ones. - New EF migration for the AlbumProcessingState saga table. Frontend: - AlbumForm gains a cover dropzone, required on create and optional on edit (via ImageDropzone's new `required` prop), uploaded through the same presigned-upload helper playlists use. - New /api/albums/{id}/cover proxy route. - PlaylistArt gains a generic coverUrl prop so AlbumHeader and the album list/detail cards render the real uploaded cover instead of only the track mosaic fallback. Delete-time picture cleanup for albums is intentionally left as a follow-up, matching the existing gap in playlist deletion. --- .../Musify.Application/Albums/CreateAlbum.cs | 74 +- .../Albums/GetAlbumCover.cs | 49 + .../Albums/RequestAlbumPictureUpload.cs | 105 ++ .../Responses/AlbumPictureUploadResponse.cs | 11 + .../Musify.Application/Albums/UpdateAlbum.cs | 86 +- .../ApplicationDependencyInjection.cs | 1 + .../Configuration/AlbumConfiguration.cs | 79 ++ .../Events/AlbumPictureProcessed.cs | 4 + .../Events/AlbumPictureProcessingFailed.cs | 4 + .../Events/AlbumProcessingFailed.cs | 7 + .../Events/CreateAlbumResourcesEvent.cs | 14 + .../Events/UpdateAlbumPictureEvent.cs | 12 + .../Events/UpdateAlbumPictureSourceEvent.cs | 14 + .../ValueObjects/AlbumPictures.cs | 14 +- .../ValueObjects/UploadIntentPurpose.cs | 1 + .../Albums/MarkAlbumAsFailedActivity.cs | 32 + ...lishAlbumPictureProcessingEventActivity.cs | 34 + .../Pictures/UpdateAlbumPictureActivity.cs | 113 ++ .../Arguments/MarkAlbumAsFailedArguments.cs | 4 + ...ishAlbumPictureProcessingEventArguments.cs | 12 + .../Arguments/UpdateAlbumPictureArguments.cs | 9 + .../AlbumProcessingFailedConsumer.cs | 36 + .../Consumers/CreateAlbumConsumer.cs | 22 + .../Consumers/UpdateAlbumPictureConsumer.cs | 22 + .../UpdateAlbumPictureSourceConsumer.cs | 22 + ...TransitDependencyInjection.Registration.cs | 9 + .../MassTransitDependencyInjection.cs | 8 + .../MassTransit/Logs/UpdateAlbumPictureLog.cs | 9 + .../MassTransit/RoutingSlip/ActivityNames.cs | 5 + .../AlbumPictureSourceRoutingSlipBuilder.cs | 85 ++ .../PictureWorkflowRoutingSlipBuilder.cs | 29 + .../RoutingSlip/RoutingSlipVariableNames.cs | 1 + .../MassTransit/Sagas/AlbumProcessingState.cs | 19 + .../Sagas/AlbumProcessingStateMachine.cs | 51 + .../Persistence/Database.cs | 5 + ...22_AddAlbumProcessingStateSaga.Designer.cs | 1240 +++++++++++++++++ ...60910125022_AddAlbumProcessingStateSaga.cs | 38 + .../Migrations/DatabaseModelSnapshot.cs | 31 + backend/Hosts/Musify.Api/AppSettings.json | 18 + .../Albums/CreateAlbumRequest.cs | 3 +- .../RequestAlbumPictureUploadRequest.cs | 6 + .../Albums/UpdateAlbumRequest.cs | 3 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 52 +- .../Albums/CreateAlbumRequestValidator.cs | 3 + ...questAlbumPictureUploadRequestValidator.cs | 20 + .../Endpoints/AlbumEndpointsTests.cs | 49 +- .../CreateAlbumRequestValidatorTests.cs | 25 +- .../Albums/CreateAlbumCommandHandlerTests.cs | 45 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 38 + ...stAlbumPictureUploadCommandHandlerTests.cs | 75 + .../Albums/UpdateAlbumCommandHandlerTests.cs | 44 +- .../TestSupport/TestConfigurations.cs | 2 + web-player/src/lib/api/schema.d.ts | 667 ++++----- .../src/lib/components/ui/AlbumForm.svelte | 56 +- .../lib/components/ui/ImageDropzone.svelte | 3 + .../src/lib/components/ui/PlaylistArt.svelte | 23 +- web-player/src/lib/server/image.ts | 2 +- .../src/routes/(app)/albums/+page.server.ts | 22 +- .../src/routes/(app)/albums/+page.svelte | 2 + .../routes/(app)/albums/[id]/+page.server.ts | 21 +- .../src/routes/(app)/albums/[id]/+page.svelte | 2 + .../albums/[id]/components/AlbumHeader.svelte | 3 + .../routes/api/albums/[id]/cover/+server.ts | 6 + 63 files changed, 3118 insertions(+), 383 deletions(-) create mode 100644 backend/Core/Musify.Application/Albums/GetAlbumCover.cs create mode 100644 backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs create mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs create mode 100644 backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs create mode 100644 backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs create mode 100644 backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs create mode 100644 backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs create mode 100644 backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs create mode 100644 backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs create mode 100644 backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs create mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs create mode 100644 web-player/src/routes/api/albums/[id]/cover/+server.ts diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 6a0d38a..81108a9 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -2,9 +2,14 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; using Musify.Application.Albums.Responses; -using Musify.Application.Contracts; +using Musify.Application.Services; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; namespace Musify.Application.Albums { @@ -12,12 +17,18 @@ namespace Musify.Application.Albums long UserId, string Title, string? Description, - int? ReleaseYear) + int? ReleaseYear, + Guid PictureIntentId) : ICommand>; public class CreateAlbumCommandHandler( + IEventBus eventBus, IDatabase database, - ILogger logger) + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) @@ -31,6 +42,12 @@ namespace Musify.Application.Albums return Error.NotFound(description: $"User {request.UserId} not found"); } + var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); + if (pictureIntent.IsError) + return pictureIntent.Errors; + + var intent = pictureIntent.Value; var title = request.Title.Trim(); var album = new UserAlbum @@ -39,11 +56,23 @@ namespace Musify.Application.Albums Title = title, NormalizedTitle = title.ToUpperInvariant(), Description = request.Description, - ReleaseYear = request.ReleaseYear + ReleaseYear = request.ReleaseYear, + Pictures = new AlbumPictures + { + OriginalName = intent.ObjectName, + SmallName = intent.ObjectName, + MediumName = intent.ObjectName, + LargeName = intent.ObjectName + } }; await database.UserAlbums.AddAsync(album, cancellationToken); + var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); + var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + try { await database.SaveChangesAsync(cancellationToken); @@ -58,5 +87,42 @@ namespace Musify.Application.Albums return AlbumApplicationResponse.FromEntity(album, trackCount: 0); } + + private async Task> PublishCreateAlbumEventAsync( + Guid albumId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + albumId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + albumConfiguration.Routes.SmallPicturesPath, + albumConfiguration.PicturesSizes.SmallPictureWidth, + albumConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + albumConfiguration.Routes.MediumPicturesPath, + albumConfiguration.PicturesSizes.MediumPictureWidth, + albumConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + albumConfiguration.Routes.LargePicturesPath, + albumConfiguration.PicturesSizes.LargePictureWidth, + albumConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId); + return Error.Failure(description: $"Failed to publish create album event for album {albumId}"); + } + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs new file mode 100644 index 0000000..a78fbb5 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record GetAlbumCoverQuery(Guid AlbumId, string Size) + : IQuery>; + + public record AlbumCoverLocation(string Bucket, string Key, string ContentType); + + public class GetAlbumCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) + { + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == request.AlbumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + if (pictures is null) + return Error.NotFound(); + + var routes = albumConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch + { + "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), + "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), + _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + }; + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return Error.NotFound(); + + var contentType = MimeUtility.GetMimeMapping(name); + return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); + } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); + } +} diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs new file mode 100644 index 0000000..d59b86f --- /dev/null +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -0,0 +1,105 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Albums.Responses; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record RequestAlbumPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + + public class RequestAlbumPictureUploadCommandHandler( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var effectiveSizeBytes = request.ExpectedSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + + var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }"; + + var tempKey = albumConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + + try + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempKey, + request.ContentType, + TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + cancellationToken); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectiveSizeBytes, 1, cancellationToken); + if (quotaCheck.IsError) + { + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck.Errors; + } + + var intent = new UploadIntent + { + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempKey, + ObjectName = objectName, + ContentType = request.ContentType, + ExpectedSizeBytes = effectiveSizeBytes, + Purpose = UploadIntentPurpose.AlbumPicture, + ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey); + + return new AlbumPictureUploadResponse( + intent.Id, + storageConfiguration.Bucket, + tempKey, + objectName, + request.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + uploadUrl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId); + return Error.Failure(description: "Failed to generate upload URL"); + } + } + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs new file mode 100644 index 0000000..ab3dcd3 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs @@ -0,0 +1,11 @@ +namespace Musify.Application.Albums.Responses +{ + public record AlbumPictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); +} diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index fa0181b..458d7fb 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -2,7 +2,13 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; using Musify.Application.Albums.Responses; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Musify.Application.Contracts; namespace Musify.Application.Albums @@ -12,12 +18,18 @@ namespace Musify.Application.Albums Guid AlbumId, string Title, string? Description, - int? ReleaseYear) + int? ReleaseYear, + Guid? NewPictureIntentId) : ICommand>; public class UpdateAlbumCommandHandler( + IEventBus eventBus, IDatabase database, - ILogger logger) + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) @@ -43,6 +55,38 @@ namespace Musify.Application.Albums album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; + UploadIntent? pictureIntent = null; + string? finalPictureKey = null; + + if (request.NewPictureIntentId.HasValue) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.NewPictureIntentId.Value, request.UserId, cancellationToken); + if (validation.IsError) + return validation.Errors; + + pictureIntent = validation.Value; + album.Pictures = new AlbumPictures + { + OriginalName = pictureIntent.ObjectName, + SmallName = pictureIntent.ObjectName, + MediumName = pictureIntent.ObjectName, + LargeName = pictureIntent.ObjectName + }; + finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + } + + database.UserAlbums.Update(album); + + if (pictureIntent != null && finalPictureKey != null) + { + var publishResult = await PublishUpdateAlbumPictureSourceEventAsync( + album.Id, pictureIntent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + } + try { await database.SaveChangesAsync(cancellationToken); @@ -64,7 +108,45 @@ namespace Musify.Application.Albums .Select(albumTrack => albumTrack.TrackId) .ToListAsync(cancellationToken); + logger.LogInformation("Updated album {AlbumId}", album.Id); return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds); } + + private async Task> PublishUpdateAlbumPictureSourceEventAsync( + Guid albumId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new UpdateAlbumPictureSourceEvent( + albumId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + albumConfiguration.Routes.SmallPicturesPath, + albumConfiguration.PicturesSizes.SmallPictureWidth, + albumConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + albumConfiguration.Routes.MediumPicturesPath, + albumConfiguration.PicturesSizes.MediumPictureWidth, + albumConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + albumConfiguration.Routes.LargePicturesPath, + albumConfiguration.PicturesSizes.LargePictureWidth, + albumConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId); + return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}"); + } + } } } diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index d143bc7..296432c 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -16,6 +16,7 @@ public static class ApplicationDependencyInjection services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); + services.AddValidatedOptions(configuration, AlbumConfiguration.SectionName); services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddValidatedOptions(configuration, StreamGatewayConfiguration.SectionName); diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs new file mode 100644 index 0000000..1802775 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Application.Shared; + +namespace Musify.Application.Configuration +{ + public class AlbumConfiguration + { + public const string SectionName = "Album"; + + [Required] + public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); + + [Required] + public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); + } + + public class AlbumRoutes + { + [Required] + public string UploadsFolder { get; set; } = "uploads"; + + [Required] + public string ParentFolders { get; set; } = "Albums"; + + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); + + public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); + + public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); + + public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); + + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); + + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); + + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); + + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); + + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); + } + + public class AlbumPicturesSizes + { + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; + } +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs new file mode 100644 index 0000000..73db1d3 --- /dev/null +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Events +{ + public record AlbumPictureProcessed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs new file mode 100644 index 0000000..24f4cc5 --- /dev/null +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Events +{ + public record AlbumPictureProcessingFailed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs new file mode 100644 index 0000000..5ee8895 --- /dev/null +++ b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Events +{ + public record AlbumProcessingFailed( + Guid AlbumId, + string? Bucket, + string? PictureKey); +} diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs new file mode 100644 index 0000000..a6bbe38 --- /dev/null +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record CreateAlbumResourcesEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs new file mode 100644 index 0000000..338dc6c --- /dev/null +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureEvent( + Guid AlbumId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs new file mode 100644 index 0000000..a6de018 --- /dev/null +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureSourceEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs index c2eb99b..26e39c6 100644 --- a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs @@ -1,13 +1,19 @@ +using System.ComponentModel.DataAnnotations; + namespace Musify.Domain.ValueObjects { public class AlbumPictures { - public string? OriginalName { get; set; } + [Required] + public required string OriginalName { get; set; } - public string? SmallName { get; set; } + [Required] + public required string SmallName { get; set; } - public string? MediumName { get; set; } + [Required] + public required string MediumName { get; set; } - public string? LargeName { get; set; } + [Required] + public required string LargeName { get; set; } } } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs index dc0339b..9629594 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs @@ -5,5 +5,6 @@ namespace Musify.Domain.ValueObjects PlayListPicture, TrackPicture, TrackAudio, + AlbumPicture, } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs new file mode 100644 index 0000000..61b765c --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs @@ -0,0 +1,32 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums +{ + internal class MarkAlbumAsFailedActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "mark-album-as-failed"; + + public async Task Execute(ExecuteContext executeContext) + { + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); + + if (album is not null) + { + album.LifeCycleStatus = LifeCycleStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId); + } + + return executeContext.Completed(); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs new file mode 100644 index 0000000..0db2cab --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums +{ + internal class PublishAlbumPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "publish-album-picture-processing-event"; + + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + + await publishEndpoint.Publish( + new UpdateAlbumPictureEvent( + arguments.AlbumId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); + + logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); + + return executeContext.Completed(); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs new file mode 100644 index 0000000..a8e1dec --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs @@ -0,0 +1,113 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Pictures +{ + internal class UpdateAlbumPictureActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "update-album-picture"; + + public async Task Execute(ExecuteContext executeContext) + { + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + + try + { + var album = await database.Albums.FindAsync( + [executeContext.Arguments.AlbumId], + cancellationToken: executeContext.CancellationToken); + + if (album is null) + { + logger.LogWarning("Album {AlbumId} not found", + executeContext.Arguments.AlbumId); + throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found"); + } + + if (album.Pictures is null) + { + logger.LogWarning("Album {AlbumId} has no pending picture upload", + executeContext.Arguments.AlbumId); + throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload"); + } + + var log = new UpdateAlbumPictureLog( + album.Id, + album.Pictures.OriginalName, + album.Pictures.SmallName, + album.Pictures.MediumName, + album.Pictures.LargeName); + + album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + album.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + album.Pictures.LargeName = Path.GetFileName(largeResizedVariable); + + database.Albums.Update(album); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated album {AlbumId} pictures", + executeContext.Arguments.AlbumId); + + await publishEndpoint.Publish( + new AlbumPictureProcessed(executeContext.Arguments.AlbumId), + executeContext.CancellationToken); + + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update album {AlbumId} pictures", + executeContext.Arguments.AlbumId); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var album = await database.Albums.FindAsync( + [compensateContext.Log.AlbumId], + cancellationToken: compensateContext.CancellationToken); + + if (album is null) + { + return compensateContext.Compensated(); + } + + album.Pictures = new AlbumPictures + { + OriginalName = compensateContext.Log.PreviousOriginalPictureKey, + SmallName = compensateContext.Log.PreviousSmallPictureKey, + MediumName = compensateContext.Log.PreviousMediumPictureKey, + LargeName = compensateContext.Log.PreviousLargePictureKey + }; + + database.Albums.Update(album); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId); + return compensateContext.Failed(exception); + } + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs new file mode 100644 index 0000000..1c9cf0e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record MarkAlbumAsFailedArguments(Guid AlbumId); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs new file mode 100644 index 0000000..f484264 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishAlbumPictureProcessingEventArguments( + Guid AlbumId, + string Bucket, + string FinalPictureKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs new file mode 100644 index 0000000..6f006f0 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdateAlbumPictureArguments( + Guid AlbumId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs new file mode 100644 index 0000000..db2c554 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -0,0 +1,36 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer + { + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) + builder.AddActivity( + ActivityNames.RemoveAlbumOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); + + builder.AddActivity( + ActivityNames.MarkAlbumAsFailed, + EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName), + new MarkAlbumAsFailedArguments(message.AlbumId)); + + await bus.Execute(builder.Build()); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs new file mode 100644 index 0000000..0c022f8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class CreateAlbumConsumer( + IBus bus, + AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs new file mode 100644 index 0000000..0a31f11 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdateAlbumPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs new file mode 100644 index 0000000..9ce0dcd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdateAlbumPictureSourceConsumer( + IBus bus, + AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index c54e033..3ec84ad 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -1,6 +1,7 @@ using MassTransit; using Microsoft.Extensions.DependencyInjection; using Musify.Infrastructure.MassTransit.Activities.Pictures; +using Musify.Infrastructure.MassTransit.Activities.Albums; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.PlayLists; @@ -23,6 +24,7 @@ namespace Musify.Infrastructure.MassTransit services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } @@ -41,6 +43,10 @@ namespace Musify.Infrastructure.MassTransit options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); options.AddExecuteActivity(); options.AddExecuteActivity(); @@ -53,9 +59,12 @@ namespace Musify.Infrastructure.MassTransit options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddActivity(); options.AddActivity(); + options.AddActivity(); options.AddActivity(); options.AddActivity(); options.AddActivity(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index ae9df96..5790c2c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -83,6 +83,14 @@ namespace Musify.Infrastructure.MassTransit repository.UsePostgres(); } ); + options.AddSagaStateMachine() + .EntityFrameworkRepository(repository => + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + } ); + options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => { UseStandardRetry(endpointConfigurator); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs new file mode 100644 index 0000000..7563dbf --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateAlbumPictureLog( + Guid AlbumId, + string PreviousOriginalPictureKey, + string PreviousSmallPictureKey, + string PreviousMediumPictureKey, + string PreviousLargePictureKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 0cf2953..58ec272 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -8,6 +8,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; + internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; @@ -30,6 +31,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string UpdateTrackPicture = "UpdateTrackPicture"; internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; + internal const string UpdateAlbumPicture = "UpdateAlbumPicture"; internal const string UpdateTrackAudio = "UpdateTrackAudio"; internal const string CopyPictureToFinal = "CopyPictureToFinal"; @@ -39,6 +41,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; + internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent"; internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; @@ -51,5 +54,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; + + internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs new file mode 100644 index 0000000..f19686f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs @@ -0,0 +1,85 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class AlbumPictureSourceRoutingSlipBuilder + { + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) + => BuildSlip( + message.AlbumId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) + => BuildSlip( + message.AlbumId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + static RoutingSlipBuilder BuildSlip( + Guid albumId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + ImageSize small, + ImageSize medium, + ImageSize large, + Guid? correlationId) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + routingSlipBuilder.AddActivity( + ActivityNames.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + bucket, + pictureSourceKey, + bucket, + pictureDestinationKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments([pictureIntentId])); + + routingSlipBuilder.AddActivity( + ActivityNames.PublishAlbumPictureProcessingEvent, + EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName), + new PublishAlbumPictureProcessingEventArguments( + albumId, + bucket, + pictureDestinationKey, + small, + medium, + large)); + + return routingSlipBuilder; + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index b411ac1..34bf4e4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -70,6 +70,35 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders return routingSlipBuilder; } + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) + { + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdateAlbumPicture, + EndpointHelper.BuildExecuteActivityUri(UpdateAlbumPictureActivity.ExecuteEndpointName), + new UpdateAlbumPictureArguments( + message.AlbumId, + message.SourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.AlbumId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + + return routingSlipBuilder; + } + RoutingSlipBuilder BuildPictureWorkflow( string bucket, string sourceKey, diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index 2e351cf..ed2b181 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -15,6 +15,7 @@ namespace Musify.Infrastructure.MassTransit internal const string TrackPicture = "TrackPicture"; internal const string TrackAudio = "TrackAudio"; internal const string PlayListPicture = "PlayListPicture"; + internal const string AlbumPicture = "AlbumPicture"; } internal static class Audio diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs new file mode 100644 index 0000000..8229784 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs @@ -0,0 +1,19 @@ +using MassTransit; + +namespace Musify.Infrastructure.MassTransit.Sagas +{ + public class AlbumProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } + + public string CurrentState { get; set; } = null!; + + public string? Bucket { get; set; } + + public string? PictureKey { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs new file mode 100644 index 0000000..332c90b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Musify.Application.Events; + +namespace Musify.Infrastructure.MassTransit.Sagas +{ + public sealed class AlbumProcessingStateMachine : MassTransitStateMachine + { + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; + + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + + public AlbumProcessingStateMachine() + { + InstanceState(state => state.CurrentState); + + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId)); + + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + } ) + .TransitionTo(Processing)); + + During(Processing, + When(PictureProcessed).Finalize(), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new AlbumProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey))); + + During(Failed, + Ignore(PictureProcessed), + Ignore(PictureFailed)); + + SetCompletedWhenFinalized(); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index f28bfa0..91b3241 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -163,6 +163,11 @@ namespace Musify.Infrastructure.Persistence playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); + + var albumProcessing = modelBuilder.Entity(); + albumProcessing.HasKey(state => state.CorrelationId); + albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); } public DbSet Users => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs new file mode 100644 index 0000000..1e406b9 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs @@ -0,0 +1,1240 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260910125022_AddAlbumProcessingStateSaga")] + partial class AddAlbumProcessingStateSaga + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("UserAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs new file mode 100644 index 0000000..5776fc4 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAlbumProcessingStateSaga : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AlbumProcessingState", + columns: table => new + { + CorrelationId = table.Column(type: "uuid", nullable: false), + CurrentState = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Bucket = table.Column(type: "text", nullable: true), + PictureKey = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlbumProcessingState", x => x.CorrelationId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlbumProcessingState"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 41d2ab4..74000bb 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -657,6 +657,33 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("UserHasTrack"); }); + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => { b.Property("CorrelationId") @@ -804,21 +831,25 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnType("uuid"); b1.Property("LargeName") + .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("LargePictureName"); b1.Property("MediumName") + .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("MediumPictureName"); b1.Property("OriginalName") + .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("OriginalPictureName"); b1.Property("SmallName") + .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("SmallPictureName"); diff --git a/backend/Hosts/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/AppSettings.json index 4378189..b26e236 100644 --- a/backend/Hosts/Musify.Api/AppSettings.json +++ b/backend/Hosts/Musify.Api/AppSettings.json @@ -87,6 +87,24 @@ "LargePictureHeight": 512 } }, + "Album": { + "Routes": { + "UploadsFolder": "uploads", + "ParentFolders": "Albums", + "OriginalPicturesFolder": "OriginalPictures", + "SmallPicturesFolder": "SmallPictures", + "MediumPicturesFolder": "MediumPictures", + "LargePicturesFolder": "LargePictures" + }, + "PicturesSizes": { + "SmallPictureWidth": 128, + "SmallPictureHeight": 128, + "MediumPictureWidth": 256, + "MediumPictureHeight": 256, + "LargePictureWidth": 512, + "LargePictureHeight": 512 + } + }, "Track": { "Routes": { "UploadsFolder": "uploads", diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs index 6698c9c..fa3292e 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs @@ -3,4 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums; public record CreateAlbumRequest( string Title, string? Description, - int? ReleaseYear); + int? ReleaseYear, + Guid PictureIntentId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs new file mode 100644 index 0000000..a1fb913 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs @@ -0,0 +1,6 @@ +namespace Musify.Api.DataTransferObjects.Albums; + +public record RequestAlbumPictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index a27e8e7..d01812e 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -3,4 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums; public record UpdateAlbumRequest( string NewTitle, string? NewDescription, - int? NewReleaseYear); + int? NewReleaseYear, + Guid? NewPictureIntentId); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 6fe8e1e..e4d6385 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -5,6 +5,7 @@ using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Albums; using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Application.YouTube; @@ -55,6 +56,22 @@ public static class AlbumEndpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/cover", GetAlbumCover) + .WithName("GetAlbumCover") + .WithSummary("Get An Album Cover Image.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/upload-picture", RequestAlbumPictureUpload) + .WithName("RequestAlbumPictureUpload") + .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreateAlbum) .WithName("CreateAlbum") .WithSummary("Create A New Album.") @@ -169,6 +186,37 @@ public static class AlbumEndpoints return result.ToHttpResult(); } + private static async Task GetAlbumCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + var result = await mediator.Send(new GetAlbumCoverQuery(id, size), cancellationToken); + if (result.IsError) + return Results.NotFound(); + + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } + + private static async Task RequestAlbumPictureUpload( + IMediator mediator, + CurrentUser currentUser, + RequestAlbumPictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); + + return result.ToHttpResult(); + } + private static async Task CreateAlbum( IMediator mediator, CurrentUser currentUser, @@ -176,7 +224,7 @@ public static class AlbumEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear), + new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), cancellationToken); return result.ToCreatedResult(album => $"/albums/{album.Id}"); @@ -190,7 +238,7 @@ public static class AlbumEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear), + new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear, request.NewPictureIntentId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs index c2d2cd8..10550eb 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -17,5 +17,8 @@ public sealed class CreateAlbumRequestValidator : AbstractValidator x.ReleaseYear) .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) .When(x => x.ReleaseYear.HasValue); + + RuleFor(x => x.PictureIntentId) + .NotEmpty(); } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs new file mode 100644 index 0000000..d747056 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Albums; + +namespace Musify.Api.Validators.Albums; + +public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator +{ + public RequestAlbumPictureUploadRequestValidator() + { + RuleFor(x => x.FileType) + .NotEmpty(); + + RuleFor(x => x.ContentType) + .NotEmpty(); + + RuleFor(x => x.ExpectedSizeBytes) + .GreaterThan(0) + .When(x => x.ExpectedSizeBytes.HasValue); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index f5485cc..9230f5b 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -4,6 +4,8 @@ using Musify.Api.DataTransferObjects.Albums; using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using NSubstitute; using Xunit; namespace Musify.Api.Tests.Endpoints @@ -20,11 +22,30 @@ namespace Musify.Api.Tests.Endpoints return userId; } + private async Task CreatePictureIntentAsync(HttpClient client) + { + fixture.StorageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + fixture.StorageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var response = await client.PostAsJsonAsync( + "/albums/upload-picture", + new RequestAlbumPictureUploadRequest("webp", "image/webp"), + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return body!.IntentId; + } + [Fact] public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() { var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken); + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -34,8 +55,9 @@ namespace Musify.Api.Tests.Endpoints { var userId = await CreateUserAsync(); var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); @@ -50,7 +72,18 @@ namespace Musify.Api.Tests.Endpoints var userId = await CreateUserAsync(); var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -60,7 +93,8 @@ namespace Musify.Api.Tests.Endpoints { var userId = await CreateUserAsync(); var client = fixture.CreateAuthenticatedClient(userId); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken)) + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); @@ -73,7 +107,9 @@ namespace Musify.Api.Tests.Endpoints { var owner = await CreateUserAsync(); var stranger = await CreateUserAsync(); - var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken)) + var ownerClient = fixture.CreateAuthenticatedClient(owner); + var pictureIntentId = await CreatePictureIntentAsync(ownerClient); + var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); @@ -86,7 +122,8 @@ namespace Musify.Api.Tests.Endpoints { var userId = await CreateUserAsync(); var client = fixture.CreateAuthenticatedClient(userId); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken)) + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs index c7008ca..c2d3ee1 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -11,7 +11,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ValidRequest_Passes() { - var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024)); + var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -19,7 +19,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ValidRequestWithoutOptionalFields_Passes() { - var result = validator.Validate(new CreateAlbumRequest("My Album", null, null)); + var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -27,7 +27,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_BlankTitle_Fails() { - var result = validator.Validate(new CreateAlbumRequest("", null, null)); + var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); Assert.False(result.IsValid); Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); @@ -36,7 +36,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_TitleOverMaxLength_Fails() { - var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null)); + var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid())); Assert.False(result.IsValid); } @@ -44,7 +44,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1)); + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid())); Assert.False(result.IsValid); } @@ -52,7 +52,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest)); + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -60,7 +60,7 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearAtTheLatestBoundary_Passes() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest)); + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid())); Assert.True(result.IsValid); } @@ -68,9 +68,18 @@ namespace Musify.Api.Tests.Validators [Fact] public void Validate_ReleaseYearPastTheLatestBoundary_Fails() { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1)); + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid())); Assert.False(result.IsValid); } + + [Fact] + public void Validate_EmptyPictureIntentId_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 5984dcc..91b3664 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -1,21 +1,39 @@ using ErrorOr; using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Services; using Musify.Application.Tests.TestSupport; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase { - private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + TestConfigurations.UploadIntent()); [Fact] - public async Task Handle_UserExists_CreatesAlbumAndReturnsIt() + public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() { - var owner = TestEntities.User(); - await SeedAsync(owner); + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024); + var owner = TestEntities.User(); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); + await SeedAsync(owner, intent); + + var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -29,16 +47,31 @@ namespace Musify.Application.Tests.Albums var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("MY ALBUM", stored.NormalizedTitle); + Assert.Equal("cover.webp", stored.Pictures.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_UserDoesNotExist_ReturnsNotFound() { - var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null); + var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PictureIntentNotFound_ReturnsError() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs new file mode 100644 index 0000000..f5eff01 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -0,0 +1,38 @@ +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.UserAlbum(owner.Id); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs new file mode 100644 index 0000000..4c0a83c --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -0,0 +1,75 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase + { + private readonly IStorageService storageService = Substitute.For(); + + private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + + return new RequestAlbumPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } + + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; + + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index 0975a71..c82a160 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -1,13 +1,26 @@ using ErrorOr; using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Services; using Musify.Application.Tests.TestSupport; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase { - private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + TestConfigurations.UploadIntent()); [Fact] public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() @@ -16,7 +29,7 @@ namespace Musify.Application.Tests.Albums var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); await SeedAsync(owner, album); - var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025); + var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -30,10 +43,33 @@ namespace Musify.Application.Tests.Albums Assert.Equal("NEW TITLE", stored.NormalizedTitle); } + [Fact] + public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var owner = TestEntities.User(); + var album = TestEntities.UserAlbum(owner.Id, title: "Old Title"); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, album, intent); + + var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task Handle_AlbumMissing_ReturnsNotFound() { - var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null); + var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -48,7 +84,7 @@ namespace Musify.Application.Tests.Albums var album = TestEntities.UserAlbum(owner.Id); await SeedAsync(owner, stranger, album); - var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null); + var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs index 8365163..2d45ebe 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -11,6 +11,8 @@ namespace Musify.Application.Tests.TestSupport public static PlayListConfiguration PlayList() => new(); + public static AlbumConfiguration Album() => new(); + public static MixConfiguration Mix() => new(); public static UploadIntentConfiguration UploadIntent() => new(); diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index d695371..9f8e582 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -11,7 +11,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Albums. */ + /** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */ get: operations["GetAlbums"]; put?: never; /** Create A New Album. */ @@ -39,6 +39,23 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/external/{source}/{externalId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist. */ + get: operations["GetExternalAlbum"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/users/{userId}": { parameters: { query?: never; @@ -90,6 +107,40 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/{id}/cover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get An Album Cover Image. */ + get: operations["GetAlbumCover"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/albums/upload-picture": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request A Pre-Signed URL To Upload An Album Picture. */ + post: operations["RequestAlbumPictureUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/{albumId}": { parameters: { query?: never; @@ -222,7 +273,8 @@ export interface paths { /** Get Paginated Tracks Of A PlayList. */ get: operations["GetPlayListTracks"]; put?: never; - post?: never; + /** Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed. */ + post: operations["AddPlayListTrack"]; delete?: never; options?: never; head?: never; @@ -290,8 +342,7 @@ export interface paths { }; get?: never; put?: never; - /** Add A Track To A PlayList. */ - post: operations["AddTrackToPlayList"]; + post?: never; /** Remove A Track From A PlayList. */ delete: operations["RemoveTrackFromPlayList"]; options?: never; @@ -299,23 +350,6 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists/{playlistId}/youtube-tracks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Add A YouTube Track To A PlayList, Downloading It In The Background If Needed. */ - post: operations["AddYouTubeTrackToPlayList"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/tracks": { parameters: { query?: never; @@ -375,7 +409,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get A Streaming Manifest URL And Ticket For A Track. */ + /** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */ get: operations["GetTrackStream"]; put?: never; post?: never; @@ -385,6 +419,23 @@ export interface paths { patch?: never; trace?: never; }; + "/tracks/external/{source}/{externalId}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It. */ + get: operations["GetExternalTrackStream"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tracks/users/{userId}": { parameters: { query?: never; @@ -488,63 +539,15 @@ export interface paths { patch?: never; trace?: never; }; - "/youtube/albums": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Search Albums On YouTube Music. */ - get: operations["SearchYouTubeAlbums"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/youtube/albums/{albumId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get A YouTube Music Album With Its Tracklist. */ - get: operations["GetYouTubeAlbum"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/youtube/tracks/{videoId}/stream": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. */ - get: operations["GetYouTubeTrackStream"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { schemas: { - AddYouTubeTrackRequest: { - videoId: string; + AddPlayListTrackRequest: { + /** Format: uuid */ + trackId: null | string; + source: null | components["schemas"]["TrackSource"]; + externalId: null | string; }; AlbumApplicationResponse: { /** Format: uuid */ @@ -565,14 +568,48 @@ export interface components { /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; - youTubeAlbumId: null | string; - thumbnailUrl: null | string; + youTubeAlbumId?: null | string; + thumbnailUrl?: null | string; + }; + AlbumPictureUploadResponse: { + /** Format: uuid */ + intentId: string; + bucket: string; + key: string; + pictureName: string; + contentType: string; + /** Format: int32 */ + expiresInSeconds: number | string; + uploadUrl: string; + }; + AlbumSearchItemResponse: { + source: components["schemas"]["TrackSource"]; + album: null | components["schemas"]["AlbumApplicationResponse"]; + youTubeAlbum: null | components["schemas"]["YouTubeAlbumResult"]; + }; + AlbumsSearchResponse: { + items: components["schemas"]["AlbumSearchItemResponse"][]; + /** Format: int32 */ + pageNumber: number | string; + /** Format: int32 */ + pageSize: number | string; + /** Format: int32 */ + pageCount: number | string; + /** Format: int32 */ + totalItemCount: number | string; + hasPreviousPage: boolean; + hasNextLocalPage: boolean; + nextYoutubeContinuationToken: null | string; + youtubeUnavailable: boolean; + hasNextPage: boolean; }; CreateAlbumRequest: { title: string; description: null | string; /** Format: int32 */ releaseYear: null | number | string; + /** Format: uuid */ + pictureIntentId: string; }; CreatePlayListRequest: { name: string; @@ -622,7 +659,7 @@ export interface components { title: string; artist: null | string; thumbnailUrl: null | string; - /** Format: int32 */ + /** Format: double */ durationSeconds: number | string; isExplicit: boolean; }; @@ -707,6 +744,12 @@ export interface components { }; /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; + RequestAlbumPictureUploadRequest: { + fileType: string; + contentType: string; + /** Format: int64 */ + expectedSizeBytes?: null | number | string; + }; RequestPlayListPictureUploadRequest: { fileType: string; contentType: string; @@ -731,7 +774,7 @@ export interface components { source: components["schemas"]["TrackSource"]; externalId: null | string; audioStatus: components["schemas"]["ProcessingStatus"]; - /** Format: int32 */ + /** Format: double */ duration: number | string; /** Format: int32 */ listensCount: number | string; @@ -744,11 +787,27 @@ export interface components { }; TrackSearchItemResponse: { source: components["schemas"]["TrackSource"]; - track: components["schemas"]["TrackApplicationResponse"] | null; - youTubeSong: components["schemas"]["YouTubeSongResult"] | null; + track: null | components["schemas"]["TrackApplicationResponse"]; + youTubeSong: null | components["schemas"]["YouTubeSongResult"]; }; /** @enum {unknown} */ TrackSource: "Local" | "YouTube"; + TracksSearchResponse: { + items: components["schemas"]["TrackSearchItemResponse"][]; + /** Format: int32 */ + pageNumber: number | string; + /** Format: int32 */ + pageSize: number | string; + /** Format: int32 */ + pageCount: number | string; + /** Format: int32 */ + totalItemCount: number | string; + hasPreviousPage: boolean; + hasNextLocalPage: boolean; + nextYoutubeContinuationToken: null | string; + youtubeUnavailable: boolean; + hasNextPage: boolean; + }; TrackStreamResponse: { manifestUrl: string; ticket: string; @@ -772,27 +831,13 @@ export interface components { /** Format: int32 */ expiresInSeconds: number | string; }; - TracksSearchResponse: { - items: components["schemas"]["TrackSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextLocalPage: boolean; - nextYoutubeContinuationToken: null | string; - youtubeUnavailable: boolean; - hasNextPage: boolean; - }; UpdateAlbumRequest: { newTitle: string; newDescription: null | string; /** Format: int32 */ newReleaseYear: null | number | string; + /** Format: uuid */ + newPictureIntentId: null | string; }; UpdatePlayListRequest: { newName: null | string; @@ -830,10 +875,6 @@ export interface components { isEp: boolean; artists: components["schemas"]["YouTubeArtistRef"][]; }; - YouTubeAlbumSearchResult: { - items: components["schemas"]["YouTubeAlbumResult"][]; - continuationToken: string; - }; YouTubeAlbumTrack: { videoId: string; title: string; @@ -861,13 +902,13 @@ export interface components { /** @enum {unknown} */ YouTubeStreamMode: "Server" | "YouTube"; YouTubeStreamResponse: { + /** Format: uuid */ + trackId: null | string; mode: components["schemas"]["YouTubeStreamMode"]; streamUrl: string; - ticket: string; + ticket: null | string; /** Format: int32 */ expiresInSeconds: number | string; - /** Format: uuid */ - trackId: string; }; }; responses: never; @@ -884,6 +925,7 @@ export interface operations { title?: string; pageNumber?: number | string; pageSize?: number | string; + youtubeContinuationToken?: string; }; header?: never; path?: never; @@ -897,7 +939,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"]; + "application/json": components["schemas"]["AlbumsSearchResponse"]; }; }; }; @@ -978,6 +1020,36 @@ export interface operations { }; }; }; + GetExternalAlbum: { + parameters: { + query?: never; + header?: never; + path: { + source: string; + externalId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["YouTubeAlbumDetail"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetAlbumsByUserId: { parameters: { query?: { @@ -1071,6 +1143,82 @@ export interface operations { }; }; }; + GetAlbumCover: { + parameters: { + query?: { + size?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RequestAlbumPictureUpload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestAlbumPictureUploadRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumPictureUploadResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; UpdateAlbum: { parameters: { query?: never; @@ -1451,6 +1599,62 @@ export interface operations { }; }; }; + AddPlayListTrack: { + parameters: { + query?: never; + header?: never; + path: { + playlistId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddPlayListTrackRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetPlayListCover: { parameters: { query?: { @@ -1610,48 +1814,6 @@ export interface operations { }; }; }; - AddTrackToPlayList: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - trackId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RemoveTrackFromPlayList: { parameters: { query?: never; @@ -1687,62 +1849,6 @@ export interface operations { }; }; }; - AddYouTubeTrackToPlayList: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddYouTubeTrackRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TrackApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetTracks: { parameters: { query?: { @@ -1916,6 +2022,50 @@ export interface operations { }; }; }; + GetExternalTrackStream: { + parameters: { + query?: never; + header?: never; + path: { + source: string; + externalId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["YouTubeStreamResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetTracksByUserId: { parameters: { query?: { @@ -2159,115 +2309,4 @@ export interface operations { }; }; }; - SearchYouTubeAlbums: { - parameters: { - query: { - query: string; - continuation?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["YouTubeAlbumSearchResult"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - GetYouTubeAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["YouTubeAlbumDetail"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - GetYouTubeTrackStream: { - parameters: { - query?: never; - header?: never; - path: { - videoId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["YouTubeStreamResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; } diff --git a/web-player/src/lib/components/ui/AlbumForm.svelte b/web-player/src/lib/components/ui/AlbumForm.svelte index f577607..37d92b1 100644 --- a/web-player/src/lib/components/ui/AlbumForm.svelte +++ b/web-player/src/lib/components/ui/AlbumForm.svelte @@ -1,6 +1,7 @@ -{#if playlistId && !coverFailed} +{#if src && !coverFailed}
(coverFailed = true)} diff --git a/web-player/src/lib/server/image.ts b/web-player/src/lib/server/image.ts index c584ade..1f09eca 100644 --- a/web-player/src/lib/server/image.ts +++ b/web-player/src/lib/server/image.ts @@ -4,7 +4,7 @@ const ALLOWED_SIZES = new Set(['small', 'medium', 'large']); export async function proxyCoverImage( fetch: typeof globalThis.fetch, - kind: 'playlists' | 'tracks', + kind: 'playlists' | 'tracks' | 'albums', id: string, size: string | null ): Promise { diff --git a/web-player/src/routes/(app)/albums/+page.server.ts b/web-player/src/routes/(app)/albums/+page.server.ts index 9464a87..8fd98e3 100644 --- a/web-player/src/routes/(app)/albums/+page.server.ts +++ b/web-player/src/routes/(app)/albums/+page.server.ts @@ -8,6 +8,7 @@ import { } from '$lib/server/api'; import { ALBUMS_PAGE_SIZE } from '$lib/config'; import { parseAlbumForm } from '$lib/server/albumForm'; +import { uploadPresignedImage } from '$lib/server/upload'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { const user = requireUser(locals, url); @@ -27,11 +28,28 @@ export const actions: Actions = { const accessToken = requireAccessTokenAction(locals); if (typeof accessToken !== 'string') return accessToken; - const parsed = parseAlbumForm(await request.formData()); + const form = await request.formData(); + const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); + const cover = form.get('cover'); + if (!(cover instanceof File) || cover.size === 0) { + return fail(400, { message: 'La portada es obligatoria.' }); + } + const api = createApiClient({ fetch, accessToken }); - const { data, error: err } = await api.POST('/albums', { body: parsed.body }); + + const uploaded = await uploadPresignedImage( + (args) => api.POST('/albums/upload-picture', { body: args }), + cover + ); + if ('failMessage' in uploaded) { + return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); + } + + const { data, error: err } = await api.POST('/albums', { + body: { ...parsed.body, pictureIntentId: uploaded.intentId } + }); if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' }); diff --git a/web-player/src/routes/(app)/albums/+page.svelte b/web-player/src/routes/(app)/albums/+page.svelte index 4c204f4..459ae55 100644 --- a/web-player/src/routes/(app)/albums/+page.svelte +++ b/web-player/src/routes/(app)/albums/+page.svelte @@ -43,6 +43,7 @@ releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} trackCount={Number(album.trackCount)} trackIds={album.coverTrackIds} + coverSrc="/api/albums/{album.id}/cover?size=large" index={i} /> {/each} @@ -67,6 +68,7 @@ { const user = requireUser(locals, url); @@ -80,16 +81,32 @@ export const actions: Actions = { const accessToken = requireAccessTokenAction(locals); if (typeof accessToken !== 'string') return accessToken; - const parsed = parseAlbumForm(await request.formData()); + const form = await request.formData(); + const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); const api = createApiClient({ fetch, accessToken }); + + let newPictureIntentId: string | null = null; + const cover = form.get('cover'); + if (cover instanceof File && cover.size > 0) { + const uploaded = await uploadPresignedImage( + (args) => api.POST('/albums/upload-picture', { body: args }), + cover + ); + if ('failMessage' in uploaded) { + return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); + } + newPictureIntentId = uploaded.intentId; + } + const result = await api.PUT('/albums/{albumId}', { params: { path: { albumId: params.id } }, body: { newTitle: parsed.body.title, newDescription: parsed.body.description, - newReleaseYear: parsed.body.releaseYear + newReleaseYear: parsed.body.releaseYear, + newPictureIntentId } }); const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.'); diff --git a/web-player/src/routes/(app)/albums/[id]/+page.svelte b/web-player/src/routes/(app)/albums/[id]/+page.svelte index 3502785..f7d33ce 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.svelte +++ b/web-player/src/routes/(app)/albums/[id]/+page.svelte @@ -41,6 +41,7 @@ description={album.description ?? undefined} releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} trackIds={tracks.map((track) => track.id)} + coverUrl="/api/albums/{album.id}/cover?size=large" playing={isCurrentQueue && player.playing} {isOwner} onPlayAll={playAll} @@ -67,6 +68,7 @@ initialTitle={album.title} initialDescription={album.description ?? ''} initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} + coverFallbackUrl="/api/albums/{album.id}/cover?size=medium" formMessage={form?.message} submitLabel="Guardar" submittingLabel="Guardando…" diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte index 93cec8e..103194b 100644 --- a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte +++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte @@ -12,6 +12,7 @@ description?: string; releaseYear?: number; trackIds: (string | number)[]; + coverUrl?: string; playing: boolean; isOwner: boolean; onPlayAll: () => void; @@ -24,6 +25,7 @@ description, releaseYear, trackIds, + coverUrl, playing, isOwner, onPlayAll, @@ -49,6 +51,7 @@
diff --git a/web-player/src/routes/api/albums/[id]/cover/+server.ts b/web-player/src/routes/api/albums/[id]/cover/+server.ts new file mode 100644 index 0000000..b65493f --- /dev/null +++ b/web-player/src/routes/api/albums/[id]/cover/+server.ts @@ -0,0 +1,6 @@ +import type { RequestHandler } from './$types'; +import { proxyCoverImage } from '$lib/server/image'; + +export const GET: RequestHandler = async ({ params, url, fetch }) => { + return proxyCoverImage(fetch, 'albums', params.id, url.searchParams.get('size')); +};