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
@@ -11,9 +11,13 @@ using Musify.Domain.Entities;
namespace Musify.Application.Tracks.Handler
{
public class CreateTrackCommandHandler(IDatabase database, IEventBus eventBus, IStorageService storageHandler,
ILogger<CreateTrackCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration)
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse> >
public class CreateTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
{
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
{
@@ -25,102 +29,50 @@ namespace Musify.Application.Tracks.Handler
return Result.NotFound($"User {request.UserId} not found");
}
var trackEntity = CreateTrackCommand.ToEntity(
request,
trackConfiguration.Routes.PresetSmallPicture,
trackConfiguration.Routes.PresetMediumPicture,
trackConfiguration.Routes.PresetLargePicture);
var trackEntity = new Track
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
OriginalPictureName = request.OriginalPictureName,
OriginalAudioName = request.OriginalAudioName,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
PicturesProcessingStatus = ProcessingStatus.Pending,
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
};
var fullPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(trackEntity.OriginalPictureName);
var fullAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(trackEntity.OriginalAudioName);
await database.Tracks.AddAsync(trackEntity, cancellationToken);
var uploadResult = await UploadFilesAsync(request, fullPictureKey, fullAudioKey, cancellationToken);
if (!uploadResult.IsSuccess)
return uploadResult;
var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken);
if (!publishPictureResult.IsSuccess)
return Result.Error($"Failed to create track for {request.Title}");
var publishTranscodeResult = await PublishTranscodeEvent(request.UserId, trackEntity, cancellationToken);
if (!publishTranscodeResult.IsSuccess)
return Result.Error($"Failed to create track for {request.Title}");
try
{
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save track {Title} after uploading files", request.Title);
await RollbackFilesAsync(fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
var publishPictureResult = await PublishUpdateEvent(trackEntity, cancellationToken);
if (!publishPictureResult.IsSuccess)
{
await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
var publishTranscodeResult = await PublishTranscodeEvent(trackEntity, cancellationToken);
if (!publishTranscodeResult.IsSuccess)
{
await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken);
logger.LogError(exception, "Failed to save track {Title}", request.Title);
return Result.Error($"Failed to create track for {request.Title}");
}
return Result.Created(TrackApplicationResponse.FromEntity(trackEntity));
}
async Task<Result> UploadFilesAsync(
CreateTrackCommand request,
string pictureKey,
string audioKey,
CancellationToken cancellationToken)
{
try
{
await storageHandler.UploadFileAsync(
request.Picture.FileStream,
request.Picture.ContentType,
storageConfiguration.Bucket,
pictureKey,
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload picture for track {Title}", request.Title);
return Result.Error($"Failed to upload picture for {request.Title}");
}
try
{
await storageHandler.UploadFileAsync(
request.Audio.FileStream,
request.Audio.ContentType,
storageConfiguration.Bucket,
audioKey,
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload audio for track {Title}", request.Title);
await RollbackFilesAsync(pictureKey, audioKey, cancellationToken);
return Result.Error($"Failed to upload audio for {request.Title}");
}
return Result.Success();
}
async Task RollbackFilesAsync(string pictureKey, string audioKey, CancellationToken cancellationToken)
{
await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, pictureKey, cancellationToken);
await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, audioKey, cancellationToken);
logger.LogInformation("Rolled back uploaded files {PictureKey}, {AudioKey}", pictureKey, audioKey);
}
async Task<Result> PublishUpdateEvent(Track track, CancellationToken cancellationToken)
async Task<Result> PublishUpdateEvent(Guid userId, Track track, CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(new UpdateTrackPictureEvent(
track.Id,
storageConfiguration.Bucket,
trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName),
trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName),
new ImageSize(
trackConfiguration.Routes.SmallPicturesPath,
trackConfiguration.PicturesSizes.SmallPictureWidth,
@@ -142,7 +94,7 @@ namespace Musify.Application.Tracks.Handler
}
}
async Task<Result> PublishTranscodeEvent(Track track, CancellationToken cancellationToken)
async Task<Result> PublishTranscodeEvent(Guid userId, Track track, CancellationToken cancellationToken)
{
var destinationFolderAudio = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
@@ -151,7 +103,7 @@ namespace Musify.Application.Tracks.Handler
await eventBus.PublishAsync(new UpdateTrackAudioEvent(
track.Id,
storageConfiguration.Bucket,
trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName),
trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName),
storageConfiguration.Bucket,
destinationFolderAudio), cancellationToken);
return Result.Success();
@@ -163,30 +115,5 @@ namespace Musify.Application.Tracks.Handler
}
}
async Task RollbackTrackAfterPublishFailureAsync(
Track track,
string pictureKey,
string audioKey,
CancellationToken cancellationToken)
{
try
{
database.Tracks.Remove(track);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback track {TrackId} persistence after publish failure", track.Id);
}
try
{
await RollbackFilesAsync(pictureKey, audioKey, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback uploaded files after publish failure for track {TrackId}", track.Id);
}
}
}
}
@@ -40,12 +40,21 @@ namespace Musify.Application.Tracks.Handler
return Result.Conflict("Track is currently being processed and cannot be deleted");
}
var previousPicturesStatus = track.PicturesProcessingStatus;
var previousAudioStatus = track.AudioTranscodeProcessingStatus;
track.PicturesProcessingStatus = ProcessingStatus.Processing;
track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing;
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
return Result.Error($"Failed to delete track {request.TrackId}");
}
try
{
await database.SaveChangesAsync(cancellationToken);
@@ -56,31 +65,6 @@ namespace Musify.Application.Tracks.Handler
return Result.Error($"Failed to delete track {request.TrackId}");
}
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
track.PicturesProcessingStatus = previousPicturesStatus;
track.AudioTranscodeProcessingStatus = previousAudioStatus;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception rollbackException)
{
logger.LogError(rollbackException, "Failed to rollback track {TrackId} processing flags after publish failure", request.TrackId);
}
return Result.Error($"Failed to delete track {request.TrackId}");
}
return Result.NoContent();
}
}
@@ -0,0 +1,110 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Configuration;
using Musify.Application.Tracks.Commands;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks.Handler
{
public class RequestTrackUploadUrlsCommandHandler(
IDatabase database,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
{
static readonly string[] AllowedPictureExtensions = [".webp", ".png", ".jpg", ".jpeg"];
static readonly string[] AllowedAudioExtensions = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".opus"];
const int ExpiresInSeconds = 600;
public async ValueTask<Result<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand 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 pictureExtensionResult = ValidateExtension(request.PictureFileType, AllowedPictureExtensions);
if (!pictureExtensionResult.IsSuccess)
return Result.Invalid(pictureExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
var audioExtensionResult = ValidateExtension(request.AudioFileType, AllowedAudioExtensions);
if (!audioExtensionResult.IsSuccess)
return Result.Invalid(audioExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
if (string.IsNullOrWhiteSpace(request.PictureContentType))
return Result.Invalid(new ValidationError("PictureContentType is required"));
if (string.IsNullOrWhiteSpace(request.AudioContentType))
return Result.Invalid(new ValidationError("AudioContentType is required"));
var pictureName = Guid.NewGuid() + pictureExtensionResult.Value;
var audioName = Guid.NewGuid() + audioExtensionResult.Value;
var pictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
var audioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioName);
try
{
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
pictureKey,
request.PictureContentType,
TimeSpan.FromSeconds(ExpiresInSeconds),
cancellationToken);
var audioUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
audioKey,
request.AudioContentType,
TimeSpan.FromSeconds(ExpiresInSeconds),
cancellationToken);
return Result.Success(new TrackUploadUrlsResponse(
storageConfiguration.Bucket,
pictureKey,
pictureName,
request.PictureContentType,
pictureUploadUrl,
audioKey,
audioName,
request.AudioContentType,
audioUploadUrl,
ExpiresInSeconds));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId);
return Result.Error("Failed to generate upload URLs");
}
}
static Result<string> ValidateExtension(string fileType, string[] allowedExtensions)
{
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);
}
}
}