From d0e6eaaee8db1c0563b9f5cc97b7e37d99893324 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 13 May 2026 02:20:22 +0200 Subject: [PATCH] Reworks --- .../Infrastructure/IStorageService.cs | 2 + Application/Events/DeleteTrackEvent.cs | 6 + .../Handler/DeleteTrackCommandHandler.cs | 62 +++------- Domain/Entities/ProcessingStatus.cs | 10 ++ Domain/Entities/Track.cs | 34 ++++-- .../GenerateAudioWorkflowPathsActivity.cs | 53 ++++---- .../Audio/TranscodeDashAudioActivity.cs | 62 +++++----- .../Audio/UpdateTrackAudioActivity.cs | 101 ++++++++------- .../Files/DownloadFileFromBucketActivity.cs | 4 +- .../Files/RemoveFileFromBucketActivity.cs | 25 +--- .../Files/RemoveFolderFromBucketActivity.cs | 42 +++++++ .../Pictures/UpdateTrackPictureActivity.cs | 19 +-- .../Activities/Tracks/DeleteTrackActivity.cs | 115 ++++++++++++++++++ .../Activities/Tracks/DeleteTrackArguments.cs | 6 + .../GenerateAudioWorkflowPathsArguments.cs | 1 + .../RemoveFolderFromBucketArguments.cs | 6 + .../Arguments/TranscodeDashAudioArguments.cs | 1 + .../Arguments/UpdateTrackAudioArguments.cs | 2 +- .../Consumers/DeleteTrackConsumer.cs | 23 ++++ .../Consumers/RemoveFileConsumer.cs | 23 ---- .../MassTransitDependencyInjection.cs | 12 +- .../MassTransit/Logs/UpdateTrackAudioLog.cs | 2 +- .../MassTransit/RoutingSlip/ActivityNames.cs | 1 + .../AudioWorkflowRoutingSlipBuilder.cs | 5 +- ...er.cs => DeleteTrackRoutingSlipBuilder.cs} | 16 +-- .../Variables/RoutingSlipVariableNames.cs | 1 + Infrastructure/Services/StorageService.cs | 38 ++++++ 27 files changed, 445 insertions(+), 227 deletions(-) create mode 100644 Application/Events/DeleteTrackEvent.cs create mode 100644 Domain/Entities/ProcessingStatus.cs create mode 100644 Infrastructure/MassTransit/Activities/Files/RemoveFolderFromBucketActivity.cs create mode 100644 Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs create mode 100644 Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs create mode 100644 Infrastructure/MassTransit/Arguments/RemoveFolderFromBucketArguments.cs create mode 100644 Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs delete mode 100644 Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs rename Infrastructure/MassTransit/RoutingSlip/Builders/{RemoveFileFromBucketRoutingSlipBuilder.cs => DeleteTrackRoutingSlipBuilder.cs} (55%) diff --git a/Application/Contracts/Infrastructure/IStorageService.cs b/Application/Contracts/Infrastructure/IStorageService.cs index beb5c85..3127c35 100644 --- a/Application/Contracts/Infrastructure/IStorageService.cs +++ b/Application/Contracts/Infrastructure/IStorageService.cs @@ -16,5 +16,7 @@ Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken); + + Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Application/Events/DeleteTrackEvent.cs b/Application/Events/DeleteTrackEvent.cs new file mode 100644 index 0000000..2f85a6a --- /dev/null +++ b/Application/Events/DeleteTrackEvent.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Events +{ + public record DeleteTrackEvent( + Guid TrackId, + Guid UserId); +} diff --git a/Application/Tracks/Handler/DeleteTrackCommandHandler.cs b/Application/Tracks/Handler/DeleteTrackCommandHandler.cs index 5737708..fff04b9 100644 --- a/Application/Tracks/Handler/DeleteTrackCommandHandler.cs +++ b/Application/Tracks/Handler/DeleteTrackCommandHandler.cs @@ -2,15 +2,17 @@ using Ardalis.Result; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; using Musify.Application.Contracts.Infrastructure; using Musify.Application.Tracks.Commands; using Musify.Domain.Entities; +using Musify.Application.Events; namespace Musify.Application.Tracks.Handler { - public class DeleteTrackCommandHandler(IDatabase database, IStorageService storageHandler, ILogger logger, - TrackConfiguration trackConfiguration, ApplicationStorageConfiguration storageConfiguration) + public class DeleteTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) : ICommandHandler { public async ValueTask Handle(DeleteTrackCommand request, CancellationToken cancellationToken) @@ -28,48 +30,22 @@ namespace Musify.Application.Tracks.Handler return Result.Unauthorized(); } - await RemovePictures(track, cancellationToken); - await RemoveAudios(track, cancellationToken); + if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Processing + || track.AudioTranscodeProcessingStatus == ProcessingStatus.Pending) + { + logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); + return Result.Conflict("Track is currently being processed and cannot be deleted"); + } + + track.PicturesProcessingStatus = ProcessingStatus.Processing; + track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing; + await database.SaveChangesAsync(cancellationToken); + + await eventBus.PublishAsync( + new DeleteTrackEvent(track.Id, request.UserId), + cancellationToken); return Result.NoContent(); } - - async Task RemovePictures(Track track, CancellationToken cancellationToken) - { - await RemoveFile(trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName), - cancellationToken); - - if (track.SmallPictureName != trackConfiguration.Routes.PresetSmallPicture) - await RemoveFile(trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName), - cancellationToken); - if (track.MediumPictureName != trackConfiguration.Routes.PresetMediumPicture) - await RemoveFile(trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName), - cancellationToken); - if (track.LargePictureName != trackConfiguration.Routes.PresetLargePicture) - await RemoveFile(trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName), - cancellationToken); - - } - - async Task RemoveAudios(Track track, CancellationToken cancellationToken) - { - await RemoveFile(trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName), - cancellationToken); - await RemoveFile(trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName), - cancellationToken); - } - - async Task RemoveFile(string path, CancellationToken cancellationToken) - { - try - { - await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, path, cancellationToken); - logger.LogDebug("Removed file {Path} from storage", path); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to remove file {Path} from storage", path); - } - } } } \ No newline at end of file diff --git a/Domain/Entities/ProcessingStatus.cs b/Domain/Entities/ProcessingStatus.cs new file mode 100644 index 0000000..583ac67 --- /dev/null +++ b/Domain/Entities/ProcessingStatus.cs @@ -0,0 +1,10 @@ +namespace Musify.Domain.Entities +{ + public enum ProcessingStatus + { + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3 + } +} \ No newline at end of file diff --git a/Domain/Entities/Track.cs b/Domain/Entities/Track.cs index b1f9592..82fa295 100644 --- a/Domain/Entities/Track.cs +++ b/Domain/Entities/Track.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; namespace Musify.Domain.Entities { @@ -20,24 +22,32 @@ namespace Musify.Domain.Entities [MaxLength(64)] public required string OriginalPictureName { get; set; } - [Required] [MaxLength(64)] - public required string SmallPictureName { get; set; } + public string? SmallPictureName { get; set; } - [Required] [MaxLength(64)] - public required string MediumPictureName { get; set; } + public string? MediumPictureName { get; set; } - [Required] [MaxLength(64)] - public required string LargePictureName { get; set; } + public string? LargePictureName { get; set; } [Required] [MaxLength(64)] public required string OriginalAudioName { get; set; } [MaxLength(64)] - public string AudioFolderName { get; set; } = string.Empty; + public string? AudioFolderName { get; set; } + + [Required] + public ProcessingStatus PicturesProcessingStatus { get; set; } = ProcessingStatus.Pending; + + [Required] + public ProcessingStatus AudioTranscodeProcessingStatus { get; set; } = ProcessingStatus.Pending; + + [Required] + public int RetryCount { get; set; } = 0; + + public DateTime? LastRetryAt { get; set; } [Required] public DateTime CreatedDate { get; set; } = DateTime.UtcNow; @@ -48,5 +58,15 @@ namespace Musify.Domain.Entities public ICollection UserTracks { get; set; } = new List(); public ICollection PlayListTracks { get; set; } = new List(); + + [NotMapped] + [MemberNotNullWhen(true, nameof(SmallPictureName)) ] + [MemberNotNullWhen(true, nameof(MediumPictureName)) ] + [MemberNotNullWhen(true, nameof(LargePictureName)) ] + public bool IsPicturesProcessed => PicturesProcessingStatus == ProcessingStatus.Completed; + + [NotMapped] + [MemberNotNullWhen(true, nameof(AudioFolderName)) ] + public bool IsAudioProcessed => AudioTranscodeProcessingStatus == ProcessingStatus.Completed; } } \ No newline at end of file diff --git a/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs b/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs index 6b0a597..73f4dcc 100644 --- a/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -1,4 +1,5 @@ using MassTransit; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts.Infrastructure; using Musify.Domain.Entities; @@ -6,42 +7,41 @@ using Musify.Infrastructure.MassTransit.Activities.Arguments; namespace Musify.Infrastructure.MassTransit.Activities { - public class GenerateAudioWorkflowPathsActivity( - ILogger logger, - IProcessTrackingStore processTrackingStore) + internal class GenerateAudioWorkflowPathsActivity( + IDatabase database, + ILogger logger) : IExecuteActivity { public const string ExecuteEndpointName = "generate-audio-workflow-paths"; public async Task Execute(ExecuteContext executeContext) { - var processId = await processTrackingStore.GetOrCreateProcessAsync( - "RoutingSlip", - executeContext.CorrelationId ?? executeContext.TrackingNumber, - executeContext.ConversationId, - executeContext.MessageId, - executeContext.CancellationToken); - - var stepId = await processTrackingStore.StartStepAsync( - processId, - nameof(GenerateAudioWorkflowPathsActivity), - ProcessStepComponentType.Activity, - 0, - executeContext.CancellationToken); + Track track; + try + { + var getTrack = await database.Tracks.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found."); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track for TrackId {TrackId}", + executeContext.Arguments.TrackId); + throw; + } try { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing; var folderName = Guid.NewGuid().ToString(); var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey); var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName); var sourceFilePath = Path.Combine(workingDirectory, sourceFileName); Directory.CreateDirectory(workingDirectory); + await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", - workingDirectory); + logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); - await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken); return executeContext.CompletedWithVariables(new Dictionary { [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, @@ -54,9 +54,20 @@ namespace Musify.Infrastructure.MassTransit.Activities { logger.LogError(exception, "Failed to generate audio workflow paths for {SourceKey}", executeContext.Arguments.SourceKey); - await processTrackingStore.FailStepAsync(processId, stepId, exception.Message, executeContext.CancellationToken); + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); + } + throw; } } } -} +} \ No newline at end of file diff --git a/Infrastructure/MassTransit/Activities/Audio/TranscodeDashAudioActivity.cs b/Infrastructure/MassTransit/Activities/Audio/TranscodeDashAudioActivity.cs index c14fe92..4766ae5 100644 --- a/Infrastructure/MassTransit/Activities/Audio/TranscodeDashAudioActivity.cs +++ b/Infrastructure/MassTransit/Activities/Audio/TranscodeDashAudioActivity.cs @@ -1,4 +1,5 @@ using MassTransit; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts.Infrastructure; using Musify.Domain.Entities; @@ -7,54 +8,59 @@ using Musify.Infrastructure.MassTransit.Activities.Logs; namespace Musify.Infrastructure.MassTransit.Activities { - public class TranscodeDashAudioActivity( + internal class TranscodeDashAudioActivity( + IDatabase database, IAudioTranscoderService audioTranscoder, - ILogger logger, - IProcessTrackingStore processTrackingStore) + ILogger logger) : IActivity { public const string ExecuteEndpointName = "transcode-dash-audio"; public async Task Execute(ExecuteContext executeContext) { - var processId = await processTrackingStore.GetOrCreateProcessAsync( - "RoutingSlip", - executeContext.CorrelationId ?? executeContext.TrackingNumber, - executeContext.ConversationId, - executeContext.MessageId, - executeContext.CancellationToken); - - var stepId = await processTrackingStore.StartStepAsync( - processId, - nameof(TranscodeDashAudioActivity), - ProcessStepComponentType.Activity, - 0, - executeContext.CancellationToken); - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); var workingDirectory = executeContext.GetVariable(executeContext.Arguments.WorkingDirectoryVariable); ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory)); + Track track; try { - await using (var fileStream = File.OpenRead(sourceFilePath)) - { - await audioTranscoder.TranscodeToDashAsync( - fileStream, - workingDirectory, - executeContext.CancellationToken); - } + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); + throw; + } + + try + { + await using var fileStream = File.OpenRead(sourceFilePath); + await audioTranscoder.TranscodeToDashAsync( + fileStream, + workingDirectory, + executeContext.CancellationToken); File.Delete(sourceFilePath); - await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken); return executeContext.Completed(); } catch (Exception exception) { - logger.LogError(exception, "Failed to transcode {SourceFilePath}", - sourceFilePath); - await processTrackingStore.FailStepAsync(processId, stepId, exception.Message, executeContext.CancellationToken); + logger.LogError(exception, "Failed to transcode {SourceFilePath}", sourceFilePath); + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); + } + throw; } } diff --git a/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index 7521aef..cf32e81 100644 --- a/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -1,4 +1,5 @@ using MassTransit; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts.Infrastructure; using Musify.Domain.Entities; @@ -7,63 +8,76 @@ using Musify.Infrastructure.MassTransit.Logs; namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public class UpdateTrackAudioActivity( + internal class UpdateTrackAudioActivity( IDatabase database, - ILogger logger, - IProcessTrackingStore processTrackingStore) + ILogger logger) : IActivity { public const string ExecuteEndpointName = "update-track-audio"; public async Task Execute(ExecuteContext executeContext) { - var processId = await processTrackingStore.GetOrCreateProcessAsync( - "RoutingSlip", - executeContext.CorrelationId ?? executeContext.TrackingNumber, - executeContext.ConversationId, - executeContext.MessageId, - executeContext.CancellationToken); + Track track; + try + { + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track {TrackId}", + executeContext.Arguments.TrackId); + throw; + } - var stepId = await processTrackingStore.StartStepAsync( - processId, - nameof(UpdateTrackAudioActivity), - ProcessStepComponentType.Activity, - 0, - executeContext.CancellationToken); + string? audioFolderKey; + try + { + audioFolderKey = executeContext.GetVariable(executeContext.Arguments.AudioFolderKeyVariable); + logger.LogDebug("Resolved audio folder key: {AudioFolderKey}", audioFolderKey); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Audio folder key variable could not be resolved for track {TrackId}", track.Id); + return executeContext.Completed(); + } + + if (string.IsNullOrWhiteSpace(audioFolderKey)) + { + logger.LogWarning("Audio folder key is empty for track {TrackId}; skipping update", track.Id); + return executeContext.Completed(); + } try { - var track = await database.Tracks.FindAsync( - [executeContext.Arguments.TrackId], - cancellationToken: executeContext.CancellationToken); - - if (track is null) - { - logger.LogWarning("Track {TrackId} not found", - executeContext.Arguments.TrackId); - throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); - } - var log = new UpdateTrackAudioLog( track.Id, track.AudioFolderName); - track.AudioFolderName = Path.GetFileName(executeContext.Arguments.AudioFolderKey); - - database.Tracks.Update(track); + track.AudioFolderName = Path.GetFileName(audioFolderKey); + track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Updated track {TrackId} audio", - executeContext.Arguments.TrackId); + logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); - await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken); - return executeContext.Completed(); + return executeContext.Completed(log); } catch (Exception exception) { logger.LogError(exception, "Failed to update track {TrackId} audio", executeContext.Arguments.TrackId); - await processTrackingStore.FailStepAsync(processId, stepId, exception.Message, executeContext.CancellationToken); + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); + } + throw; } } @@ -72,25 +86,22 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio { try { - var track = await database.Tracks.FindAsync( - [compensateContext.Log.TrackId], - cancellationToken: compensateContext.CancellationToken); - + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken); if (track is null) - { - return compensateContext.Compensated(); - } + throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found"); - track.AudioFolderName = compensateContext.Log.PreviousFolderAudioKey; - - database.Tracks.Update(track); + track.AudioFolderName = compensateContext.Log.PreviousAudioFolderName; + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; await database.SaveChangesAsync(compensateContext.CancellationToken); + logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); + return compensateContext.Compensated(); } catch (Exception exception) { - logger.LogError(exception, "Failed to compensate track {TrackId} audio", compensateContext.Log.TrackId); + logger.LogError(exception, "Failed to compensate track {TrackId} audio", + compensateContext.Log.TrackId); return compensateContext.Failed(exception); } } diff --git a/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index df6bd52..9bf4475 100644 --- a/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -5,9 +5,9 @@ using Musify.Domain.Entities; using Musify.Infrastructure.MassTransit.Activities.Arguments; using Musify.Infrastructure.MassTransit.Activities.Logs; -namespace Musify.Infrastructure.MassTransit.Activities +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public class DownloadFileFromBucketActivity( + internal class DownloadFileFromBucketActivity( IStorageService storageHandler, ILogger logger, IProcessTrackingStore processTrackingStore) diff --git a/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index e1ddbf8..9f8b29f 100644 --- a/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -1,43 +1,25 @@ using MassTransit; using Microsoft.Extensions.Logging; using Musify.Application.Contracts.Infrastructure; -using Musify.Domain.Entities; using Musify.Infrastructure.MassTransit.Activities.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public class RemoveFileFromBucketActivity( + internal class RemoveFileFromBucketActivity( IStorageService storageHandler, - ILogger logger, - IProcessTrackingStore processTrackingStore) + ILogger logger) : IExecuteActivity { public const string ExecuteEndpointName = "remove-file-from-bucket"; public async Task Execute(ExecuteContext executeContext) { - var processId = await processTrackingStore.GetOrCreateProcessAsync( - "RoutingSlip", - executeContext.CorrelationId ?? executeContext.TrackingNumber, - executeContext.ConversationId, - executeContext.MessageId, - executeContext.CancellationToken); - - var stepId = await processTrackingStore.StartStepAsync( - processId, - nameof(RemoveFileFromBucketActivity), - ProcessStepComponentType.Activity, - 0, - executeContext.CancellationToken); - try { await storageHandler.RemoveFileAsync( executeContext.Arguments.Bucket, executeContext.Arguments.Key, executeContext.CancellationToken); - - await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken); return executeContext.Completed(); } catch (Exception exception) @@ -45,7 +27,6 @@ namespace Musify.Infrastructure.MassTransit.Activities logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}", executeContext.Arguments.Key, executeContext.Arguments.Bucket); - await processTrackingStore.FailStepAsync(processId, stepId, exception.Message, executeContext.CancellationToken); throw; } } diff --git a/Infrastructure/MassTransit/Activities/Files/RemoveFolderFromBucketActivity.cs b/Infrastructure/MassTransit/Activities/Files/RemoveFolderFromBucketActivity.cs new file mode 100644 index 0000000..98f1289 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Files/RemoveFolderFromBucketActivity.cs @@ -0,0 +1,42 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts.Infrastructure; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Files +{ + internal class RemoveFolderFromBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "remove-folder-from-bucket"; + + public async Task Execute(ExecuteContext executeContext) + { + var folderKey = executeContext.GetVariable(executeContext.Arguments.FolderKeyVariable); + ArgumentException.ThrowIfNullOrEmpty(folderKey, nameof(folderKey)); + + try + { + await storageHandler.RemoveFolderAsync( + executeContext.Arguments.Bucket, + folderKey, + executeContext.CancellationToken); + + logger.LogInformation("Removed folder {FolderKey} from bucket {Bucket}", + folderKey, + executeContext.Arguments.Bucket); + + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to remove folder {FolderKey} from bucket {Bucket}", + folderKey, + executeContext.Arguments.Bucket); + throw; + } + } + } +} \ No newline at end of file diff --git a/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs b/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs index 7b2463a..44392a1 100644 --- a/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs +++ b/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs @@ -9,28 +9,13 @@ namespace Musify.Infrastructure.MassTransit.Activities { public class UpdateTrackPictureActivity( IDatabase database, - ILogger logger, - IProcessTrackingStore processTrackingStore) + ILogger logger) : IActivity { public const string ExecuteEndpointName = "update-track-picture"; public async Task Execute(ExecuteContext executeContext) { - var processId = await processTrackingStore.GetOrCreateProcessAsync( - "RoutingSlip", - executeContext.CorrelationId ?? executeContext.TrackingNumber, - executeContext.ConversationId, - executeContext.MessageId, - executeContext.CancellationToken); - - var stepId = await processTrackingStore.StartStepAsync( - processId, - nameof(UpdateTrackPictureActivity), - ProcessStepComponentType.Activity, - 0, - executeContext.CancellationToken); - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); @@ -69,14 +54,12 @@ namespace Musify.Infrastructure.MassTransit.Activities logger.LogInformation("Updated track {TrackId} pictures", executeContext.Arguments.TrackId); - await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken); return executeContext.Completed(); } catch (Exception exception) { logger.LogError(exception, "Failed to update track {TrackId} pictures", executeContext.Arguments.TrackId); - await processTrackingStore.FailStepAsync(processId, stepId, exception.Message, executeContext.CancellationToken); throw; } } diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs new file mode 100644 index 0000000..f58f263 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs @@ -0,0 +1,115 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts.Infrastructure; +using Musify.Domain.Entities; + +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal class DeleteTrackActivity( + IDatabase database, + IStorageService storageService, + TrackConfiguration trackConfiguration, + ApplicationStorageConfiguration storageConfiguration, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "delete-track"; + + public async Task Execute(ExecuteContext executeContext) + { + Track track; + try + { + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track {TrackId} for deletion", executeContext.Arguments.TrackId); + throw; + } + + try + { + await RemovePicturesAsync(track, executeContext.CancellationToken); + track.PicturesProcessingStatus = ProcessingStatus.Completed; + await database.SaveChangesAsync(executeContext.CancellationToken); + + await RemoveAudiosAsync(track, executeContext.CancellationToken); + track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; + await database.SaveChangesAsync(executeContext.CancellationToken); + + database.Tracks.Remove(track); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted track {TrackId}", track.Id); + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete track {TrackId}", executeContext.Arguments.TrackId); + + try + { + track.PicturesProcessingStatus = ProcessingStatus.Failed; + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update processing statuses to Failed for track {TrackId}", executeContext.Arguments.TrackId); + } + + throw; + } + } + + async Task RemovePicturesAsync(Track track, CancellationToken cancellationToken) + { + await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName), cancellationToken); + + if (!track.IsPicturesProcessed) + { + logger.LogInformation( + "Track {TrackId} pictures are not fully processed, skipping removal of small, medium, and large pictures", + track.Id); + return; + } + + await RemoveFileAsync(trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName), cancellationToken); + await RemoveFileAsync(trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName), cancellationToken); + await RemoveFileAsync(trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName), cancellationToken); + } + + async Task RemoveAudiosAsync(Track track, CancellationToken cancellationToken) + { + await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName), cancellationToken); + + if (!track.IsAudioProcessed) + { + logger.LogInformation( + "Track {TrackId} audio is not fully processed, skipping removal of processed audio", + track.Id); + return; + } + + await RemoveFileAsync(trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName), cancellationToken); + } + + async Task RemoveFileAsync(string key, CancellationToken cancellationToken) + { + try + { + logger.LogDebug("Removing file {Key} from bucket {Bucket}", key, storageConfiguration.Bucket); + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to remove file {Key} from bucket {Bucket}", key, storageConfiguration.Bucket); + throw; + } + } + } +} diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs new file mode 100644 index 0000000..0caacb8 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal record DeleteTrackArguments( + Guid TrackId, + Guid UserId); +} diff --git a/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs b/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs index 44e4f07..2acd8f7 100644 --- a/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs +++ b/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs @@ -1,6 +1,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Arguments { public record GenerateAudioWorkflowPathsArguments( + Guid TrackId, string TemporaryRootDirectory, string SourceKey); } diff --git a/Infrastructure/MassTransit/Arguments/RemoveFolderFromBucketArguments.cs b/Infrastructure/MassTransit/Arguments/RemoveFolderFromBucketArguments.cs new file mode 100644 index 0000000..5628a4f --- /dev/null +++ b/Infrastructure/MassTransit/Arguments/RemoveFolderFromBucketArguments.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record RemoveFolderFromBucketArguments( + string Bucket, + string FolderKeyVariable); +} diff --git a/Infrastructure/MassTransit/Arguments/TranscodeDashAudioArguments.cs b/Infrastructure/MassTransit/Arguments/TranscodeDashAudioArguments.cs index c445504..379a14c 100644 --- a/Infrastructure/MassTransit/Arguments/TranscodeDashAudioArguments.cs +++ b/Infrastructure/MassTransit/Arguments/TranscodeDashAudioArguments.cs @@ -1,6 +1,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Arguments { public record TranscodeDashAudioArguments( + Guid TrackId, string SourceFilePathVariable, string WorkingDirectoryVariable); } \ No newline at end of file diff --git a/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs b/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs index 10edb2b..aabb5da 100644 --- a/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs +++ b/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -2,5 +2,5 @@ { public record UpdateTrackAudioArguments( Guid TrackId, - string AudioFolderKey); + string AudioFolderKeyVariable); } \ No newline at end of file diff --git a/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs new file mode 100644 index 0000000..b5c3ee1 --- /dev/null +++ b/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class DeleteTrackConsumer( + IBus bus, + DeleteTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "delete-track"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs b/Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs deleted file mode 100644 index bde736d..0000000 --- a/Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs +++ /dev/null @@ -1,23 +0,0 @@ -using MassTransit; -using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -namespace Musify.Infrastructure.MassTransit.Consumers -{ - public class RemoveFileConsumer( - IBus bus, - RemoveFileFromBucketRoutingSlipBuilder routingSlipBuilder) - : IConsumer - { - public const string QueueName = "remove-file"; - - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } - } -} diff --git a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index 059b1c4..9c0f695 100644 --- a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -14,6 +14,7 @@ using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Filters; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Musify.Infrastructure.MassTransit.Activities.Tracks; namespace Musify.Infrastructure.MassTransit { @@ -61,19 +62,20 @@ namespace Musify.Infrastructure.MassTransit serviceDescriptors.AddScoped(); serviceDescriptors.AddScoped(); - serviceDescriptors.AddScoped(); + serviceDescriptors.AddScoped(); serviceDescriptors.AddMassTransit(options => { options.AddConsumer(); - options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddActivity(); options.AddActivity(); @@ -95,15 +97,15 @@ namespace Musify.Infrastructure.MassTransit busFactoryConfigurator.ReceiveEndpoint(UpdatePlayListPictureConsumer.QueueName, endpointConfigurator => endpointConfigurator.ConfigureConsumer(busRegistrationContext)); - busFactoryConfigurator.ReceiveEndpoint(RemoveFileConsumer.QueueName, endpointConfigurator => - endpointConfigurator.ConfigureConsumer(busRegistrationContext)); - busFactoryConfigurator.ReceiveEndpoint(UpdateTrackAudioConsumer.QueueName, endpointConfigurator => endpointConfigurator.ConfigureConsumer(busRegistrationContext)); busFactoryConfigurator.ReceiveEndpoint(UpdateTrackPictureConsumer.QueueName, endpointConfigurator => endpointConfigurator.ConfigureConsumer(busRegistrationContext)); + busFactoryConfigurator.ReceiveEndpoint(DeleteTrackConsumer.QueueName, endpointConfigurator => + endpointConfigurator.ConfigureConsumer(busRegistrationContext)); + busFactoryConfigurator.ReceiveEndpoint(RoutingSlipCleanUpConsumer.QueueName, endpointConfigurator => endpointConfigurator.ConfigureConsumer(busRegistrationContext)); diff --git a/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs index 395670b..355a755 100644 --- a/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs +++ b/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -2,5 +2,5 @@ { public record UpdateTrackAudioLog( Guid TrackId, - string PreviousFolderAudioKey); + string? PreviousAudioFolderName); } diff --git a/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 85033b7..b5d9ff5 100644 --- a/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -2,6 +2,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip { internal static class ActivityNames { + internal const string DeleteTrack = "DeleteTrack"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; diff --git a/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs index 4d96026..b5713b3 100644 --- a/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -3,6 +3,7 @@ using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Activities.Audio; +using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Activities; @@ -25,6 +26,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders ActivityNames.GenerateAudioWorkflowPaths, EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), new GenerateAudioWorkflowPathsArguments( + message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)); @@ -40,6 +42,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders ActivityNames.TranscodeAudio, EndpointHelper.BuildExecuteActivityUri(TranscodeDashAudioActivity.ExecuteEndpointName), new TranscodeDashAudioArguments( + message.TrackId, RoutingSlipVariableNames.Audio.SourceFilePath, RoutingSlipVariableNames.Workflow.TemporalDirectory)); @@ -56,7 +59,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), new UpdateTrackAudioArguments( message.TrackId, - message.DestinationFolderKey)); + RoutingSlipVariableNames.Audio.TranscodedDirectory)); return routingSlipBuilder; } diff --git a/Infrastructure/MassTransit/RoutingSlip/Builders/RemoveFileFromBucketRoutingSlipBuilder.cs b/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs similarity index 55% rename from Infrastructure/MassTransit/RoutingSlip/Builders/RemoveFileFromBucketRoutingSlipBuilder.cs rename to Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs index c6cb98d..942254d 100644 --- a/Infrastructure/MassTransit/RoutingSlip/Builders/RemoveFileFromBucketRoutingSlipBuilder.cs +++ b/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs @@ -1,15 +1,13 @@ using MassTransit; using MassTransit.Courier.Contracts; -using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Consumers; -using Musify.Infrastructure.MassTransit.Activities; -using Musify.Infrastructure.MassTransit.Activities.Arguments; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public class RemoveFileFromBucketRoutingSlipBuilder + public class DeleteTrackRoutingSlipBuilder { - public RoutingSlipBuilder Build(RemoveFileEvent message, Guid? correlationId) + public RoutingSlipBuilder Build(Guid trackId, Guid userId, Guid? correlationId) { var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); @@ -19,11 +17,9 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); routingSlipBuilder.AddActivity( - ActivityNames.RemoveFile, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - message.Bucket, - message.Key)); + ActivityNames.DeleteTrack, + EndpointHelper.BuildExecuteActivityUri(DeleteTrackActivity.ExecuteEndpointName), + new DeleteTrackArguments(trackId, userId)); return routingSlipBuilder; } diff --git a/Infrastructure/MassTransit/RoutingSlip/Variables/RoutingSlipVariableNames.cs b/Infrastructure/MassTransit/RoutingSlip/Variables/RoutingSlipVariableNames.cs index 8ffad05..5392816 100644 --- a/Infrastructure/MassTransit/RoutingSlip/Variables/RoutingSlipVariableNames.cs +++ b/Infrastructure/MassTransit/RoutingSlip/Variables/RoutingSlipVariableNames.cs @@ -13,6 +13,7 @@ namespace Musify.Infrastructure.MassTransit internal const string SourceFilePath = "Audio.SourceFilePath"; internal const string WorkingDirectory = "Audio.WorkingDirectory"; internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; + internal const string PreviousAudioFolderName = "Audio.PreviousAudioFolderName"; } internal static class Picture diff --git a/Infrastructure/Services/StorageService.cs b/Infrastructure/Services/StorageService.cs index ee5faf1..fc5dbd6 100644 --- a/Infrastructure/Services/StorageService.cs +++ b/Infrastructure/Services/StorageService.cs @@ -180,5 +180,43 @@ namespace Musify.Infrastructure.Services throw; } } + + public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken) + { + var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/"; + var listRequest = new ListObjectsV2Request + { + BucketName = bucket, + Prefix = prefix + }; + + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + + if (listResponse.S3Objects.Count == 0) + break; + + var deleteRequest = new DeleteObjectsRequest + { + BucketName = bucket, + Objects = listResponse.S3Objects + .Select(o => new KeyVersion { Key = o.Key } ) + .ToList() + }; + + var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); + + if (deleteResponse.DeleteErrors.Count > 0) + { + var errors = string.Join(", ", deleteResponse.DeleteErrors.Select(e => $"{e.Key}: {e.Message}")); + throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); + } + + listRequest.ContinuationToken = listResponse.NextContinuationToken; + + } while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + } } } \ No newline at end of file