From cd8b8cbd0bbfca963f1adf3353975cd2f6e704b3 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 18 Mar 2026 02:19:36 +0100 Subject: [PATCH] Uploads --- Application/Application.csproj | 2 +- .../Configuration/PlayListConfiguration.cs | 30 ++++++ .../Configuration/StorageConfiguration.cs | 14 +++ .../Contracts/Infrastructure/IDatabase.cs | 2 + .../Contracts/Infrastructure/IEventBus.cs | 7 ++ .../Infrastructure/IStorageHandler.cs | 10 +- .../Commands/ResizeImage/ResizeCommand.cs | 13 +++ .../ResizeImage/ResizeCommandHandler.cs | 55 +++++++++++ .../Pictures/Events/ImageResizeEvent.cs | 8 ++ Infrastructure/Infrastructure.csproj | 1 + .../Consumers/ImageResizedConsumer.cs | 99 +++++++++++++++++++ .../Messaging/MassTransitEventBus.cs | 16 +++ Infrastructure/Persistence/Database.cs | 2 + Infrastructure/Pictures/PictureHandler.cs | 2 +- Infrastructure/Storage/StorageHandler.cs | 10 +- Musify.Backend/Entities/Upload.cs | 27 +++++ Musify.Backend/Entities/UploadState.cs | 10 ++ 17 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 Application/Configuration/PlayListConfiguration.cs create mode 100644 Application/Configuration/StorageConfiguration.cs create mode 100644 Application/Contracts/Infrastructure/IEventBus.cs create mode 100644 Application/Pictures/Commands/ResizeImage/ResizeCommand.cs create mode 100644 Application/Pictures/Commands/ResizeImage/ResizeCommandHandler.cs create mode 100644 Application/Pictures/Events/ImageResizeEvent.cs create mode 100644 Infrastructure/Messaging/Consumers/ImageResizedConsumer.cs create mode 100644 Infrastructure/Messaging/MassTransitEventBus.cs create mode 100644 Musify.Backend/Entities/Upload.cs create mode 100644 Musify.Backend/Entities/UploadState.cs diff --git a/Application/Application.csproj b/Application/Application.csproj index d063a70..1764b90 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -10,6 +10,7 @@ + @@ -20,7 +21,6 @@ - diff --git a/Application/Configuration/PlayListConfiguration.cs b/Application/Configuration/PlayListConfiguration.cs new file mode 100644 index 0000000..ea14d99 --- /dev/null +++ b/Application/Configuration/PlayListConfiguration.cs @@ -0,0 +1,30 @@ +namespace Musify.Application.Configuration +{ + public class PlayListConfiguration + { + public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); + + public int SmallPictureWidth { get; set; } = 128; + + public int SmallPictureHeight { get; set; } = 128; + + public int MediumPictureWidth { get; private set; } = 256; + + public int MediumPictureHeight { get; private set; } = 256; + + public int LargePictureWidth { get; private set; } = 512; + + public float LargePictureHeight { get; private set; } = 512; + } + + public class PlayListRoutes + { + public string ParentFolder { get; set; } = "PlayLists"; + + public string SmallPictures { get; set; } = "SmallPictures"; + + public string MediumPictures { get; set; } = "MediumPictures"; + + public string LargePictures { get; set; } = "LargePictures"; + } +} \ No newline at end of file diff --git a/Application/Configuration/StorageConfiguration.cs b/Application/Configuration/StorageConfiguration.cs new file mode 100644 index 0000000..8cf6c49 --- /dev/null +++ b/Application/Configuration/StorageConfiguration.cs @@ -0,0 +1,14 @@ +namespace Musify.Application.Configuration +{ + public class StorageConfiguration + { + public string BucketName { get; set; } = "musify-S3"; + + public StorageRoutes Routes { get; set; } = new StorageRoutes(); + } + + public class StorageRoutes + { + public string Uploads { get; set; } = "Uploads"; + } +} \ No newline at end of file diff --git a/Application/Contracts/Infrastructure/IDatabase.cs b/Application/Contracts/Infrastructure/IDatabase.cs index 0d3a871..172bfd8 100644 --- a/Application/Contracts/Infrastructure/IDatabase.cs +++ b/Application/Contracts/Infrastructure/IDatabase.cs @@ -13,6 +13,8 @@ namespace Musify.Application.Contracts.Infrastructure DbSet PlayListHasTracks { get; } + DbSet Uploads { get; } + Task SaveChangesAsync(CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Application/Contracts/Infrastructure/IEventBus.cs b/Application/Contracts/Infrastructure/IEventBus.cs new file mode 100644 index 0000000..0091321 --- /dev/null +++ b/Application/Contracts/Infrastructure/IEventBus.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Contracts.Infrastructure +{ + public interface IEventBus + { + Task PublishAsync(T message, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Application/Contracts/Infrastructure/IStorageHandler.cs b/Application/Contracts/Infrastructure/IStorageHandler.cs index e3af5f1..613b524 100644 --- a/Application/Contracts/Infrastructure/IStorageHandler.cs +++ b/Application/Contracts/Infrastructure/IStorageHandler.cs @@ -4,14 +4,14 @@ namespace Musify.Application.Contracts.Infrastructure { public interface IStorageHandler { - Task> GetFile(string bucketName, string keyName, CancellationToken cancellationToken); + Task> GetFileAsync(string bucketName, string keyName, CancellationToken cancellationToken); - Task GetUrl(string bucketName, string keyName, TimeSpan ExpirationTime, CancellationToken cancellationToken); + Task GetUrlAsync(string bucketName, string keyName, TimeSpan ExpirationTime, CancellationToken cancellationToken); - Task> UploadFile(Stream sourceStream, string contentType, string bucketName, string keyName, CancellationToken cancellationToken); + Task> UploadFileAsync(Stream sourceStream, string contentType, string bucketName, string keyName, CancellationToken cancellationToken); - Task TransferFiles(string sourceDirectory, string bucketName, string route, CancellationToken cancellationToken); + Task TransferFilesAsync(string sourceDirectory, string bucketName, string route, CancellationToken cancellationToken); - Task RemoveFile(string bucketName, string keyName, CancellationToken cancellationToken); + Task RemoveFileAsync(string bucketName, string keyName, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Application/Pictures/Commands/ResizeImage/ResizeCommand.cs b/Application/Pictures/Commands/ResizeImage/ResizeCommand.cs new file mode 100644 index 0000000..c79b5e5 --- /dev/null +++ b/Application/Pictures/Commands/ResizeImage/ResizeCommand.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using DispatchR.Abstractions.Send; + +namespace Musify.Application.Pictures.Commands.ResizeImage +{ + public record ResizeCommand( + Stream PictureStream, + string ContentType, + int Width, + int Height, + string SaveRoute) + : IRequest>; +} \ No newline at end of file diff --git a/Application/Pictures/Commands/ResizeImage/ResizeCommandHandler.cs b/Application/Pictures/Commands/ResizeImage/ResizeCommandHandler.cs new file mode 100644 index 0000000..037992b --- /dev/null +++ b/Application/Pictures/Commands/ResizeImage/ResizeCommandHandler.cs @@ -0,0 +1,55 @@ +using Ardalis.Result; +using DispatchR.Abstractions.Send; +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts.Infrastructure; +using Musify.Application.Pictures.Events; +using Musify.Domain.Entities; + +namespace Musify.Application.Pictures.Commands.ResizeImage +{ + public class ResizeCommandHandler(IStorageHandler storageHandler, IDatabase database, IPublishEndpoint publishEndpoint, + ILogger logger, StorageConfiguration storageConfiguration) + : IRequestHandler >> + { + public async Task> Handle(ResizeCommand request, CancellationToken cancellationToken) + { + var pictureId = Guid.NewGuid(); + + var upload = new Upload + { + Id = pictureId, + KeyName = $"{storageConfiguration.Routes.Uploads}/{pictureId}", + ContentType = request.ContentType, + BucketName = storageConfiguration.BucketName, + }; + + await database.Uploads.AddAsync(upload, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + var uploadResult = await storageHandler.UploadFileAsync( + request.PictureStream, + request.ContentType, + storageConfiguration.BucketName, + pictureId.ToString(), + cancellationToken); + + if (!uploadResult.IsSuccess) + { + logger.LogError("Failed to upload the picture with ID {PictureId} to bucket {BucketName}.", pictureId, storageConfiguration.BucketName); + return Result.Error("Failed to upload the picture."); + } + + await publishEndpoint.Publish(new ImageResizeEvent( + pictureId, + request.Width, + request.Height, + request.SaveRoute), + cancellationToken); + + return Result.Success(); + } + } +} \ No newline at end of file diff --git a/Application/Pictures/Events/ImageResizeEvent.cs b/Application/Pictures/Events/ImageResizeEvent.cs new file mode 100644 index 0000000..c08aec6 --- /dev/null +++ b/Application/Pictures/Events/ImageResizeEvent.cs @@ -0,0 +1,8 @@ +namespace Musify.Application.Pictures.Events +{ + public record ImageResizeEvent( + Guid UploadId, + int Width, + int Height, + string SaveRoute); +} \ No newline at end of file diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index 7564525..4a82fe5 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -10,6 +10,7 @@ + diff --git a/Infrastructure/Messaging/Consumers/ImageResizedConsumer.cs b/Infrastructure/Messaging/Consumers/ImageResizedConsumer.cs new file mode 100644 index 0000000..6e386fa --- /dev/null +++ b/Infrastructure/Messaging/Consumers/ImageResizedConsumer.cs @@ -0,0 +1,99 @@ +using Ardalis.Result; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts.Infrastructure; +using Musify.Application.Pictures.Events; +using Musify.Domain.Entities; + +namespace Musify.Infrastructure.Messaging.Consumers +{ + public class ImageResizedConsumer(IStorageHandler storageHandler, IDatabase database, IPictureHandler pictureHandler, ILogger logger) + : IConsumer + { + public async Task Consume(ConsumeContext consumeContext) + { + try + { + var upload = await database.Uploads.AsNoTracking().SingleOrDefaultAsync(u => u.Id == consumeContext.Message.UploadId); + + if (upload is null) + { + logger.LogWarning("Upload not found in database: UploadId={UploadId}", consumeContext.Message.UploadId); + return; + } + + if (upload.State != UploadState.Pending) + { + logger.LogInformation("Upload is not in a failed state, skipping processing: UploadId={UploadId}, State={State}", consumeContext.Message.UploadId, upload.State); + return; + } + + await UpdateState(database, upload, UploadState.Processing, consumeContext.CancellationToken); + + var file = await storageHandler.GetFileAsync( + upload.BucketName, + upload.KeyName, + consumeContext.CancellationToken); + + if (file.IsNotFound()) + { + logger.LogWarning("File not found in storage: BucketName={BucketName}, KeyName={KeyName}", upload.BucketName, upload.KeyName); + + await UpdateState(database, upload, UploadState.Failed, consumeContext.CancellationToken); + + return; + } + + var pictureResult = await pictureHandler.ResizePictureAsync( + file.Value, + consumeContext.Message.Width, + consumeContext.Message.Height, + consumeContext.CancellationToken); + + if (!pictureResult.IsSuccess) + { + string errors = string.Join("; ", pictureResult.Errors); + logger.LogError("Failed to resize picture: {ErrorMessage}", errors); + + await UpdateState(database, upload, UploadState.Failed, consumeContext.CancellationToken); + + return; + } + + var saveResult = await storageHandler.UploadFileAsync( + pictureResult.Value, + "image/webp", + upload.BucketName, + $"{consumeContext.Message.SaveRoute}/{upload.Id}.webp", + consumeContext.CancellationToken); + + if (!saveResult.IsSuccess) + { + string errors = string.Join("; ", saveResult.Errors); + logger.LogError("Failed to upload resized picture: {ErrorMessage}", errors); + + await UpdateState(database, upload, UploadState.Failed, consumeContext.CancellationToken); + + return; + } + + await UpdateState(database, upload, UploadState.Completed, consumeContext.CancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "An error occurred while consuming the ImageResizedConsumer message."); + throw; + } + } + + async Task UpdateState(IDatabase database, Upload upload, UploadState uploadState, CancellationToken cancellationToken) + { + upload.State = uploadState; + + database.Uploads.Update(upload); + + await database.SaveChangesAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Messaging/MassTransitEventBus.cs b/Infrastructure/Messaging/MassTransitEventBus.cs new file mode 100644 index 0000000..0e15411 --- /dev/null +++ b/Infrastructure/Messaging/MassTransitEventBus.cs @@ -0,0 +1,16 @@ +using MassTransit; +using Musify.Application.Contracts.Infrastructure; + +namespace Musify.Infrastructure.Messaging +{ + public class MassTransitEventBus(IPublishEndpoint publishEndpoint) + : IEventBus + { + public async Task PublishAsync(T message, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(message, nameof(message)); + + await publishEndpoint.Publish(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Persistence/Database.cs b/Infrastructure/Persistence/Database.cs index 3ee112b..cf4b52d 100644 --- a/Infrastructure/Persistence/Database.cs +++ b/Infrastructure/Persistence/Database.cs @@ -14,5 +14,7 @@ namespace Musify.Infrastructure.Persistence public DbSet PlayLists => Set(); public DbSet PlayListHasTracks => Set(); + + public DbSet Uploads => Set(); } } \ No newline at end of file diff --git a/Infrastructure/Pictures/PictureHandler.cs b/Infrastructure/Pictures/PictureHandler.cs index 6afa11b..1e5db72 100644 --- a/Infrastructure/Pictures/PictureHandler.cs +++ b/Infrastructure/Pictures/PictureHandler.cs @@ -11,7 +11,7 @@ namespace Musify.Infrastructure.Pictures { public async Task> ResizePictureAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - try + try { if (pictureStream.CanSeek && pictureStream.Position > 0) pictureStream.Position = 0; diff --git a/Infrastructure/Storage/StorageHandler.cs b/Infrastructure/Storage/StorageHandler.cs index 7ad9d4c..b95f616 100644 --- a/Infrastructure/Storage/StorageHandler.cs +++ b/Infrastructure/Storage/StorageHandler.cs @@ -10,7 +10,7 @@ namespace Musify.Infrastructure.Storage public class StorageHandler(IAmazonS3 amazonS3, ILogger logger) : IStorageHandler { - public async Task> GetFile(string bucketName, string keyName, CancellationToken cancellationToken) + public async Task> GetFileAsync(string bucketName, string keyName, CancellationToken cancellationToken) { var request = new GetObjectRequest { @@ -34,7 +34,7 @@ namespace Musify.Infrastructure.Storage } } - public async Task GetUrl(string bucketName, string keyName, TimeSpan expirationTime, CancellationToken cancellationToken) + public async Task GetUrlAsync(string bucketName, string keyName, TimeSpan expirationTime, CancellationToken cancellationToken) { var request = new GetPreSignedUrlRequest { @@ -50,7 +50,7 @@ namespace Musify.Infrastructure.Storage return url; } - public async Task RemoveFile(string bucketName, string keyName, CancellationToken cancellationToken) + public async Task RemoveFileAsync(string bucketName, string keyName, CancellationToken cancellationToken) { var request = new DeleteObjectRequest { @@ -64,7 +64,7 @@ namespace Musify.Infrastructure.Storage return Result.NoContent(); } - public async Task TransferFiles(string sourceDirectory, string bucketName, string route, CancellationToken cancellationToken) + public async Task TransferFilesAsync(string sourceDirectory, string bucketName, string route, CancellationToken cancellationToken) { var trasnsferUtility = new TransferUtility(amazonS3); @@ -89,7 +89,7 @@ namespace Musify.Infrastructure.Storage return Result.Success(); } - public async Task> UploadFile(Stream sourceStream, string contentType, string bucketName, string keyName, CancellationToken cancellationToken) + public async Task> UploadFileAsync(Stream sourceStream, string contentType, string bucketName, string keyName, CancellationToken cancellationToken) { var request = new PutObjectRequest { diff --git a/Musify.Backend/Entities/Upload.cs b/Musify.Backend/Entities/Upload.cs new file mode 100644 index 0000000..0bb727c --- /dev/null +++ b/Musify.Backend/Entities/Upload.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ + [Table("Upload")] + public class Upload + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required string KeyName { get; set; } + + [Required] + public required string BucketName { get; set; } + + [Required] + public required string ContentType { get; set; } + + [Required] + public UploadState State { get; set; } = UploadState.Pending; + + [Required] + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Musify.Backend/Entities/UploadState.cs b/Musify.Backend/Entities/UploadState.cs new file mode 100644 index 0000000..738b5d4 --- /dev/null +++ b/Musify.Backend/Entities/UploadState.cs @@ -0,0 +1,10 @@ +namespace Musify.Domain.Entities +{ + public enum UploadState + { + Pending, + Processing, + Completed, + Failed + } +} \ No newline at end of file