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')); +};