A lot of changes
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
{
|
||||
public interface IEventBus
|
||||
{
|
||||
Task PublishAsync<T>(T message, CancellationToken cancellationToken);
|
||||
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
||||
|
||||
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace Musify.Application.Configuration
|
||||
|
||||
public class PlayListRoutes
|
||||
{
|
||||
[Required]
|
||||
public string UploadsFolder { get; set; } = "uploads";
|
||||
|
||||
[Required]
|
||||
public string ParentFolders { get; set; } = "PlayLists";
|
||||
|
||||
@@ -42,27 +45,36 @@ namespace Musify.Application.Configuration
|
||||
[Required]
|
||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||
|
||||
public string SmallPicturesPath => Path.Combine(ParentFolders, SmallPicturesFolder);
|
||||
public string SmallPicturesPath => CombineKey(ParentFolders, SmallPicturesFolder);
|
||||
|
||||
public string MediumPicturesPath => Path.Combine(ParentFolders, MediumPicturesFolder);
|
||||
public string MediumPicturesPath => CombineKey(ParentFolders, MediumPicturesFolder);
|
||||
|
||||
public string LargePicturesPath => Path.Combine(ParentFolders, LargePicturesFolder);
|
||||
public string LargePicturesPath => CombineKey(ParentFolders, LargePicturesFolder);
|
||||
|
||||
public string PresetSmallPicturePath => Path.Combine(ParentFolders, PresetSmallPicture);
|
||||
public string PresetSmallPicturePath => CombineKey(ParentFolders, PresetSmallPicture);
|
||||
|
||||
public string PresetMediumPicturePath => Path.Combine(ParentFolders, PresetMediumPicture);
|
||||
public string PresetMediumPicturePath => CombineKey(ParentFolders, PresetMediumPicture);
|
||||
|
||||
public string PresetLargePicturePath => Path.Combine(ParentFolders, PresetLargePicture);
|
||||
public string PresetLargePicturePath => CombineKey(ParentFolders, PresetLargePicture);
|
||||
|
||||
public string OriginalPicturesPath => Path.Combine(ParentFolders, OriginalPicturesFolder);
|
||||
public string OriginalPicturesPath => CombineKey(ParentFolders, OriginalPicturesFolder);
|
||||
|
||||
public string BuildOriginalPicturePath(string pictureName) => Path.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
||||
public string BuildOriginalPicturePath(string pictureName) => CombineKey(ParentFolders, OriginalPicturesFolder, pictureName);
|
||||
|
||||
public string BuildSmallPicturePath(string pictureName) => Path.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
||||
public string BuildOriginalPicturePath(Guid userId, string pictureName) => CombineKey(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
||||
|
||||
public string BuildMediumPicturePath(string pictureName) => Path.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
||||
public string BuildSmallPicturePath(string pictureName) => CombineKey(ParentFolders, SmallPicturesFolder, pictureName);
|
||||
|
||||
public string BuildLargePicturePath(string pictureName) => Path.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
||||
public string BuildMediumPicturePath(string pictureName) => CombineKey(ParentFolders, MediumPicturesFolder, pictureName);
|
||||
|
||||
public string BuildLargePicturePath(string pictureName) => CombineKey(ParentFolders, LargePicturesFolder, pictureName);
|
||||
|
||||
static string CombineKey(params string[] segments)
|
||||
{
|
||||
return string.Join('/', segments
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim().Trim('/', '\\')));
|
||||
}
|
||||
}
|
||||
|
||||
public class PlayListPicturesSizes
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace Musify.Application.Configuration
|
||||
|
||||
public class TrackRoutes
|
||||
{
|
||||
[Required]
|
||||
public string UploadsFolder { get; set; } = "uploads";
|
||||
|
||||
[Required]
|
||||
public string ParentFolder { get; set; } = "Tracks";
|
||||
|
||||
@@ -48,35 +51,46 @@ namespace Musify.Application.Configuration
|
||||
[Required]
|
||||
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
|
||||
|
||||
public string SmallPicturesPath => Path.Combine(ParentFolder, SmallPicturesFolder);
|
||||
public string SmallPicturesPath => CombineKey(ParentFolder, SmallPicturesFolder);
|
||||
|
||||
public string MediumPicturesPath => Path.Combine(ParentFolder, MediumPicturesFolder);
|
||||
public string MediumPicturesPath => CombineKey(ParentFolder, MediumPicturesFolder);
|
||||
|
||||
public string LargePicturesPath => Path.Combine(ParentFolder, LargePicturesFolder);
|
||||
public string LargePicturesPath => CombineKey(ParentFolder, LargePicturesFolder);
|
||||
|
||||
public string PresetSmallPicturePath => Path.Combine(ParentFolder, PresetSmallPicture);
|
||||
public string PresetSmallPicturePath => CombineKey(ParentFolder, PresetSmallPicture);
|
||||
|
||||
public string PresetMediumPicturePath => Path.Combine(ParentFolder, PresetMediumPicture);
|
||||
public string PresetMediumPicturePath => CombineKey(ParentFolder, PresetMediumPicture);
|
||||
|
||||
public string PresetLargePicturePath => Path.Combine(ParentFolder, PresetLargePicture);
|
||||
public string PresetLargePicturePath => CombineKey(ParentFolder, PresetLargePicture);
|
||||
|
||||
public string OriginalPicturesPath => Path.Combine(ParentFolder, OriginalPicturesFolder);
|
||||
public string OriginalPicturesPath => CombineKey(ParentFolder, OriginalPicturesFolder);
|
||||
|
||||
public string OriginalAudiosPath => Path.Combine(ParentFolder, OriginalAudiosFolder);
|
||||
public string OriginalAudiosPath => CombineKey(ParentFolder, OriginalAudiosFolder);
|
||||
|
||||
public string ProcessedAudiosPath => Path.Combine(ParentFolder, ProcessedAudioFolder);
|
||||
public string ProcessedAudiosPath => CombineKey(ParentFolder, ProcessedAudioFolder);
|
||||
|
||||
public string BuildOriginalPicturePath(string pictureName) => Path.Combine(OriginalPicturesPath, pictureName);
|
||||
public string BuildOriginalPicturePath(string pictureName) => CombineKey(OriginalPicturesPath, pictureName);
|
||||
|
||||
public string BuildSmallPicturePath(string pictureName) => Path.Combine(SmallPicturesPath, pictureName);
|
||||
public string BuildOriginalPicturePath(Guid userId, string pictureName) => CombineKey(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
|
||||
|
||||
public string BuildMediumPicturePath(string pictureName) => Path.Combine(MediumPicturesPath, pictureName);
|
||||
public string BuildSmallPicturePath(string pictureName) => CombineKey(SmallPicturesPath, pictureName);
|
||||
|
||||
public string BuildLargePicturePath(string pictureName) => Path.Combine(LargePicturesPath, pictureName);
|
||||
public string BuildMediumPicturePath(string pictureName) => CombineKey(MediumPicturesPath, pictureName);
|
||||
|
||||
public string BuildOriginalAudioPath(string audioName) => Path.Combine(OriginalAudiosPath, audioName);
|
||||
public string BuildLargePicturePath(string pictureName) => CombineKey(LargePicturesPath, pictureName);
|
||||
|
||||
public string BuildProcessedAudioPath(string audioName) => Path.Combine(ProcessedAudiosPath, audioName);
|
||||
public string BuildOriginalAudioPath(string audioName) => CombineKey(OriginalAudiosPath, audioName);
|
||||
|
||||
public string BuildOriginalAudioPath(Guid userId, string audioName) => CombineKey(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
|
||||
|
||||
public string BuildProcessedAudioPath(string audioName) => CombineKey(ProcessedAudiosPath, audioName);
|
||||
|
||||
static string CombineKey(params string[] segments)
|
||||
{
|
||||
return string.Join('/', segments
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim().Trim('/', '\\')));
|
||||
}
|
||||
}
|
||||
|
||||
public class TrackPicturesSizes
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Abstractions.Application;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
@@ -10,22 +8,6 @@ namespace Musify.Application.PlayLists.Commands
|
||||
Guid UserId,
|
||||
string Name,
|
||||
string Description,
|
||||
IFileData? Picture)
|
||||
: ICommand<Result<PlayListApplicationResponse>>
|
||||
{
|
||||
public static PlayList ToEntity(CreatePlayListCommand command, string originalKey, string smallKey, string mediumKey, string largeKey)
|
||||
{
|
||||
return new PlayList
|
||||
{
|
||||
UserId = command.UserId,
|
||||
Name = command.Name,
|
||||
NormalizedName = command.Name.Trim().ToUpperInvariant(),
|
||||
Description = command.Description,
|
||||
OriginalPictureName = originalKey,
|
||||
SmallPictureName = smallKey,
|
||||
MediumPictureName = mediumKey,
|
||||
LargePictureName = largeKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
string? OriginalPictureName)
|
||||
: ICommand<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record RequestPlayListPictureUploadCommand(
|
||||
Guid UserId,
|
||||
string FileType,
|
||||
string ContentType)
|
||||
: ICommand<Result<PlayListPictureUploadResponse>>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Abstractions.Application;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
@@ -10,6 +9,6 @@ namespace Musify.Application.PlayLists.Commands
|
||||
Guid PlayListId,
|
||||
string? NewName,
|
||||
string? NewDescription,
|
||||
IFileData? NewPicture)
|
||||
string? NewOriginalPictureName)
|
||||
: ICommand<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public record PlayListPictureUploadResponse(
|
||||
string Bucket,
|
||||
string Key,
|
||||
string PictureName,
|
||||
string ContentType,
|
||||
int ExpiresInSeconds,
|
||||
string UploadUrl);
|
||||
}
|
||||
@@ -1,30 +1,13 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Abstractions.Application;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks.Commands
|
||||
{
|
||||
public record CreateTrackCommand(
|
||||
Guid UserId,
|
||||
string Title,
|
||||
IFileData Picture,
|
||||
IFileData Audio)
|
||||
: ICommand<Result<TrackApplicationResponse> >
|
||||
{
|
||||
public static Track ToEntity(CreateTrackCommand command, string smallPictureName, string mediumPictureName, string largePictureName)
|
||||
{
|
||||
return new Track
|
||||
{
|
||||
Title = command.Title,
|
||||
NormalizedTitle = command.Title.ToUpperInvariant(),
|
||||
OriginalPictureName = Guid.NewGuid() + command.Picture.FileType,
|
||||
OriginalAudioName = Guid.NewGuid() + command.Audio.FileType,
|
||||
SmallPictureName = smallPictureName,
|
||||
MediumPictureName = mediumPictureName,
|
||||
LargePictureName = largePictureName
|
||||
};
|
||||
}
|
||||
}
|
||||
string OriginalPictureName,
|
||||
string OriginalAudioName)
|
||||
: ICommand<Result<TrackApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Commands
|
||||
{
|
||||
public record RequestTrackUploadUrlsCommand(
|
||||
Guid UserId,
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType)
|
||||
: ICommand<Result<TrackUploadUrlsResponse>>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackUploadUrlsResponse(
|
||||
string Bucket,
|
||||
string PictureKey,
|
||||
string PictureName,
|
||||
string PictureContentType,
|
||||
string PictureUploadUrl,
|
||||
string AudioKey,
|
||||
string AudioName,
|
||||
string AudioContentType,
|
||||
string AudioUploadUrl,
|
||||
int ExpiresInSeconds);
|
||||
}
|
||||
Reference in New Issue
Block a user