A lot of changes

This commit is contained in:
2026-05-14 14:32:19 +02:00
parent d2b83524cf
commit 7215eebb5a
43 changed files with 2941 additions and 444 deletions
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Abstractions.Application;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Events;
using Musify.Application.PlayLists.Commands;
@@ -15,7 +14,6 @@ namespace Musify.Application.PlayLists.Handlers
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
IStorageService storageService,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
@@ -32,79 +30,43 @@ namespace Musify.Application.PlayLists.Handlers
return Result.NotFound($"User {request.UserId} not found");
}
var playList = CreatePlayListCommand.ToEntity(
request,
playListConfiguration.Routes.PresetOriginalPicture,
playListConfiguration.Routes.PresetSmallPicture,
playListConfiguration.Routes.PresetMediumPicture,
playListConfiguration.Routes.PresetLargePicture);
var originalPictureName = request.OriginalPictureName ?? playListConfiguration.Routes.PresetOriginalPicture;
if (request.Picture is not null)
var playList = new PlayList
{
var uploadResult = await UploadPictureAsync(playList, request.Picture, cancellationToken);
if (!uploadResult.IsSuccess)
return uploadResult;
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = request.Description,
OriginalPictureName = originalPictureName,
SmallPictureName = playListConfiguration.Routes.PresetSmallPicture,
MediumPictureName = playListConfiguration.Routes.PresetMediumPicture,
LargePictureName = playListConfiguration.Routes.PresetLargePicture
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (request.OriginalPictureName is not null)
{
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
if (!publishResult.IsSuccess)
return publishResult;
}
try
{
await database.PlayLists.AddAsync(playList, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
await RollbackAllPicturesAsync(playList, cancellationToken);
return Result.Error($"Failed to create playlist for user {request.UserId}");
}
if (request.Picture is not null)
{
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
if (!publishResult.IsSuccess)
{
await TryRollbackOnPublishFailureAsync(playList, cancellationToken);
return publishResult;
}
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return Result.Created(PlayListApplicationResponse.FromEntity(playList));
}
async Task TryRollbackOnPublishFailureAsync(PlayList playList, CancellationToken cancellationToken)
{
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback playlist creation after publish failure for playlist {PlayListId}", playList.Id);
}
await RollbackAllPicturesAsync(playList, cancellationToken);
}
async Task<Result> UploadPictureAsync(PlayList playList, IFileData picture, CancellationToken cancellationToken)
{
var pictureName = Guid.NewGuid() + picture.FileType;
var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName);
try
{
await storageService.UploadFileAsync(picture.FileStream, picture.ContentType, storageConfiguration.Bucket, pictureKey, cancellationToken);
playList.OriginalPictureName = pictureName;
return Result.Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload picture for playlist {PlayListId}", playList.Id);
return Result.Error($"Failed to upload picture for playlist {playList.Id}");
}
}
async Task<Result> PublishPictureUpdateEventAsync(PlayList playList, CancellationToken cancellationToken)
{
try
@@ -112,7 +74,7 @@ namespace Musify.Application.PlayLists.Handlers
var updateEvent = new UpdatePlayListPictureEvent(
playList.Id,
storageConfiguration.Bucket,
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName),
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
@@ -135,25 +97,5 @@ namespace Musify.Application.PlayLists.Handlers
return Result.Error($"Failed to publish picture update event for playlist {playList.Id}");
}
}
async Task RollbackAllPicturesAsync(PlayList playList, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(playList.OriginalPictureName))
return;
if (playList.OriginalPictureName == playListConfiguration.Routes.PresetOriginalPicture)
return;
var originalPicturePath = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName);
try
{
await storageService.RemoveFileAsync(storageConfiguration.Bucket, originalPicturePath, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback picture upload for playlist {PlayListId}", playList.Id);
}
}
}
}
@@ -33,23 +33,12 @@ namespace Musify.Application.PlayLists.Handlers
var mediumPictureName = playList.MediumPictureName;
var largePictureName = playList.LargePictureName;
var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName);
var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName);
var smallPictureKey = playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName);
var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName);
var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName);
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId);
return Result.Error($"Failed to delete playlist {request.PlayListId}");
}
await PublishRemoveFileEventsBestEffortAsync(
var publishResult = await PublishRemoveFileEventsAsync(
playList.Id,
originalPictureName,
smallPictureName,
@@ -62,10 +51,24 @@ namespace Musify.Application.PlayLists.Handlers
playListConfiguration.Routes,
cancellationToken);
if (!publishResult.IsSuccess)
return publishResult;
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId);
return Result.Error($"Failed to delete playlist {request.PlayListId}");
}
return Result.NoContent();
}
async Task PublishRemoveFileEventsBestEffortAsync(
async Task<Result> PublishRemoveFileEventsAsync(
Guid playListId,
string originalPictureName,
string smallPictureName,
@@ -79,20 +82,31 @@ namespace Musify.Application.PlayLists.Handlers
CancellationToken cancellationToken)
{
if (originalPictureName != playListRoutes.PresetOriginalPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, originalPictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, originalPictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
if (smallPictureName != playListRoutes.PresetSmallPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, smallPictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, smallPictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
if (mediumPictureName != playListRoutes.PresetMediumPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, mediumPictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, mediumPictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
if (largePictureName != playListRoutes.PresetLargePicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, largePictureKey, cancellationToken);
}
{
var result = await PublishRemoveFileEventAsync(playListId, largePictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
async Task PublishRemoveFileEventIgnoringErrorsAsync(Guid playListId, string key, CancellationToken cancellationToken)
{
var result = await PublishRemoveFileEventAsync(playListId, key, cancellationToken);
if (!result.IsSuccess)
logger.LogWarning("Remove file event publish failed for playlist {PlayListId} with key {Key}", playListId, key);
return Result.Success();
}
async Task<Result> PublishRemoveFileEventAsync(Guid playListId, string key, CancellationToken cancellationToken)
@@ -0,0 +1,88 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Configuration;
using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses;
namespace Musify.Application.PlayLists.Handlers
{
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
{
static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"];
const int ExpiresInSeconds = 600;
public async ValueTask<Result<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Result.NotFound($"User {request.UserId} not found");
}
var extensionResult = ValidateExtension(request.FileType);
if (!extensionResult.IsSuccess)
return Result.Invalid(extensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
if (string.IsNullOrWhiteSpace(request.ContentType))
return Result.Invalid(new ValidationError("ContentType is required"));
var pictureName = Guid.NewGuid() + extensionResult.Value;
var key = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
key,
request.ContentType,
TimeSpan.FromSeconds(ExpiresInSeconds),
cancellationToken);
return Result.Success(new PlayListPictureUploadResponse(
storageConfiguration.Bucket,
key,
pictureName,
request.ContentType,
ExpiresInSeconds,
uploadUrl));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Result.Error("Failed to generate upload URL");
}
}
static Result<string> ValidateExtension(string fileType)
{
if (string.IsNullOrWhiteSpace(fileType))
return Result.Error("FileType is required");
var extension = fileType.Trim();
if (!extension.StartsWith('.'))
extension = "." + extension;
extension = extension.ToLowerInvariant();
if (extension.Contains('/') || extension.Contains('\\'))
return Result.Error("Invalid FileType");
if (!AllowedExtensions.Contains(extension))
return Result.Error($"Unsupported FileType '{extension}'");
return Result.Success(extension);
}
}
}
@@ -3,23 +3,21 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Abstractions.Application;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Events;
using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Handlers
{
public class UpdatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageService storageHandler,
ILogger<UpdatePlayListCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration,
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
{
record UpdatePictureOutcome(Result Result, bool PictureUpdated, string? UploadedPictureKey);
record PreviousPlayListState(string Name, string NormalizedName, string Description, string OriginalPictureName);
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
@@ -35,14 +33,25 @@ namespace Musify.Application.PlayLists.Handlers
return Result.Unauthorized();
}
var previousState = CapturePreviousState(playListEntity);
if (request.NewName is not null)
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
UpdateName(playListEntity, request.NewName);
UpdateDescription(playListEntity, request.NewDescription);
if (request.NewDescription is not null)
{
playListEntity.Description = request.NewDescription;
}
var pictureOutcome = await UpdatePicture(playListEntity, request.NewPicture, cancellationToken);
if (!pictureOutcome.Result.IsSuccess)
return pictureOutcome.Result;
if (request.NewOriginalPictureName is not null)
{
playListEntity.OriginalPictureName = request.NewOriginalPictureName;
var publishResult = await PublishPlayListPictureEvent(playListEntity.UserId, playListEntity.Id, playListEntity.OriginalPictureName, cancellationToken);
if (!publishResult.IsSuccess)
return publishResult;
}
try
{
@@ -51,86 +60,22 @@ namespace Musify.Application.PlayLists.Handlers
}
catch (Exception exception)
{
if (pictureOutcome.PictureUpdated && pictureOutcome.UploadedPictureKey is not null)
await Rollback(pictureOutcome.UploadedPictureKey, cancellationToken);
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
}
if (pictureOutcome.PictureUpdated)
{
var publishResult = await PublishPlayListPictureEvent(playListEntity, cancellationToken);
if (!publishResult.IsSuccess)
{
await TryRollbackAfterPublishFailureAsync(
playListEntity,
previousState,
pictureOutcome.UploadedPictureKey,
cancellationToken);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
}
}
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity));
}
void UpdateName(PlayList playList, string? newName)
{
if (newName is null)
return;
playList.Name = newName;
playList.NormalizedName = newName.Trim().ToUpperInvariant();
}
void UpdateDescription(PlayList playList, string? newDescription)
{
if (newDescription is null)
return;
playList.Description = newDescription;
}
static PreviousPlayListState CapturePreviousState(PlayList playList)
=> new(playList.Name, playList.NormalizedName, playList.Description, playList.OriginalPictureName);
async Task<UpdatePictureOutcome> UpdatePicture(
PlayList playList,
IFileData? newPicture,
CancellationToken cancellationToken)
{
if (newPicture is null)
return new UpdatePictureOutcome(Result.Success(), false, null);
var pictureName = Guid.NewGuid() + newPicture.FileType;
var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName);
try
{
await storageHandler.UploadFileAsync(
newPicture.FileStream,
newPicture.ContentType,
storageConfiguration.Bucket,
pictureKey,
cancellationToken);
}
catch
{
return new UpdatePictureOutcome(Result.Error("Failed to upload picture to storage"), false, null);
}
playList.OriginalPictureName = pictureName;
return new UpdatePictureOutcome(Result.Success(), true, pictureKey);
}
async Task<Result> PublishPlayListPictureEvent(PlayList playList, CancellationToken cancellationToken)
async Task<Result> PublishPlayListPictureEvent(Guid userId, Guid playListId, string originalPictureName, CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(new UpdatePlayListPictureEvent(
playList.Id,
playListId,
storageConfiguration.Bucket,
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
playListConfiguration.Routes.BuildOriginalPicturePath(userId, originalPictureName),
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
@@ -148,50 +93,8 @@ namespace Musify.Application.PlayLists.Handlers
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish playlist picture update event for playlist {PlayListId}", playList.Id);
return Result.Error($"Failed to publish playlist {playList.Id} picture update event");
}
}
async Task Rollback(string pictureKey, CancellationToken cancellationToken)
{
await storageHandler.RemoveFileAsync(
storageConfiguration.Bucket,
pictureKey,
cancellationToken);
logger.LogInformation("Rolled back uploaded picture {PictureKey} from storage", pictureKey);
}
async Task TryRollbackAfterPublishFailureAsync(
PlayList playList,
PreviousPlayListState previousState,
string? uploadedPictureKey,
CancellationToken cancellationToken)
{
playList.Name = previousState.Name;
playList.NormalizedName = previousState.NormalizedName;
playList.Description = previousState.Description;
playList.OriginalPictureName = previousState.OriginalPictureName;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback playlist state after publish failure for playlist {PlayListId}", playList.Id);
}
if (uploadedPictureKey is null)
return;
try
{
await Rollback(uploadedPictureKey, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback uploaded picture after publish failure for playlist {PlayListId}", playList.Id);
logger.LogError(exception, "Failed to publish playlist picture update event for playlist {PlayListId}", playListId);
return Result.Error($"Failed to publish playlist {playListId} picture update event");
}
}
}