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