diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index c5c0a70..90a8601 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -2,11 +2,12 @@ namespace Musify.Application.Contracts; public interface IStorageService { + /// Opens an object for reading, or returns null when it does not exist. public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); + public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 38e16ff..e5c6a26 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,17 +9,17 @@ using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes; public record GenerateMixesForUserCommand(long UserId) - : ICommand>; + : ICommand; public class GenerateMixesForUserCommandHandler( IDatabase database, MixConfiguration mixConfiguration, ILogger logger) - : ICommandHandler> + : ICommandHandler { private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); - public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) + public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; @@ -33,7 +32,7 @@ public class GenerateMixesForUserCommandHandler( if (libraryTrackIds.Count == 0) { logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); - return Result.Success; + return Unit.Value; } var historyTrackIds = await database.ListeningHistories @@ -63,7 +62,7 @@ public class GenerateMixesForUserCommandHandler( if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return Result.Success; + return Unit.Value; } await ReplaceMixesAsync(userId, drafts, cancellationToken); @@ -71,7 +70,7 @@ public class GenerateMixesForUserCommandHandler( await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return Result.Success; + return Unit.Value; } private List Take(IEnumerable trackIds) => diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs index 18660f5..70adca1 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -69,7 +69,7 @@ public sealed class UploadIntentIssuer( foreach (var item in planned) { var uploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken); + storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); var intent = new UploadIntent { diff --git a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs new file mode 100644 index 0000000..05fc48d --- /dev/null +++ b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs @@ -0,0 +1,38 @@ +using Mediator; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.UploadIntents; + +/// Deletes temporary upload objects that nobody claimed within the retention window. +public record DeleteStaleTempUploadsCommand : ICommand; + +public class DeleteStaleTempUploadsCommandHandler( + IStorageService storageService, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler +{ + public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) + { + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); + var deleted = 0; + + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + + if (deleted > 0) + logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); + + return deleted; + } +} diff --git a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs new file mode 100644 index 0000000..9b6e3d2 --- /dev/null +++ b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs @@ -0,0 +1,46 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.UploadIntents; + +/// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. +public record ExpireUploadIntentsCommand : ICommand; + +public class ExpireUploadIntentsCommandHandler( + IDatabase database, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler +{ + public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + + var expiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) + .ToListAsync(cancellationToken); + + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; + + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + var oldExpiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); + + database.UploadIntents.RemoveRange(oldExpiredIntents); + + if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) + return 0; + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", + expiredIntents.Count, oldExpiredIntents.Count); + + return expiredIntents.Count + oldExpiredIntents.Count; + } +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index a0128b3..d610c1e 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -23,14 +23,7 @@ public class DailyMixGenerationJob( logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count); foreach (var userId in userIds) - { - var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); - if (result.IsError) - { - logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}", - userId, result.FirstError.Description); - } - } + await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); logger.LogInformation("Finished daily mix generation"); } diff --git a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs new file mode 100644 index 0000000..ed278f6 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs @@ -0,0 +1,41 @@ +using Hangfire; +using Microsoft.Extensions.Hosting; +using Musify.Application.Configuration; + +namespace Musify.Infrastructure.Jobs; + +/// Registers every recurring Hangfire job when the host starts. +public sealed class RecurringJobsRegistrar( + IRecurringJobManager recurringJobManager, + UploadIntentConfiguration uploadIntentConfiguration) + : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; + + recurringJobManager.AddOrUpdate( + "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); + recurringJobManager.AddOrUpdate( + "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); + recurringJobManager.AddOrUpdate( + "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. + internal static string EveryCron(int intervalSeconds) + { + var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); + return minutes < 60 + ? $"*/{minutes} * * * *" + : $"0 */{Math.Max(1, minutes / 60)} * * *"; + } +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs new file mode 100644 index 0000000..5295904 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs @@ -0,0 +1,10 @@ +using Mediator; +using Musify.Application.UploadIntents; + +namespace Musify.Infrastructure.Jobs; + +public class TempUploadsCleanupJob(IMediator mediator) +{ + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); +} diff --git a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs deleted file mode 100644 index d866c1f..0000000 --- a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.Jobs; - -public class TemporalUploadsCleanUpJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})", - uploadIntentConfiguration.TempCleanupJobIntervalSeconds, - uploadIntentConfiguration.TempRootPrefix); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TempCleanupJobIntervalSeconds)); - - do - { - try - { - await RunAsync(stoppingToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogError(exception, "Error in TemporalUploadsCleanUpJob"); - } - } - while (await timer.WaitForNextTickAsync(stoppingToken)); - } - - private async Task RunAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var storageService = scope.ServiceProvider.GetRequiredService(); - - var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); - var deleted = 0; - var errors = 0; - - await foreach (var (key, lastModified) in storageService.ListObjectsAsync( - storageConfiguration.Bucket, - uploadIntentConfiguration.TempRootPrefix, - cancellationToken)) - { - if (lastModified >= cutOff) - continue; - - try - { - await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); - deleted++; - } - catch (Exception exception) - { - errors++; - logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key); - } - } - - if (deleted > 0 || errors > 0) - logger.LogInformation("TemporalUploadsCleanUpJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors); - } -} diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index 40cf862..3a6d37d 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -1,70 +1,10 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; +using Mediator; +using Musify.Application.UploadIntents; namespace Musify.Infrastructure.Jobs; -public class UploadIntentExpirationJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService +public class UploadIntentExpirationJob(IMediator mediator) { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds)); - - do - { - try - { - await RunAsync(stoppingToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogError(exception, "Error in UploadIntentExpirationJob"); - } - } - while (await timer.WaitForNextTickAsync(stoppingToken)); - } - - private async Task RunAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var database = scope.ServiceProvider.GetRequiredService(); - - var now = DateTime.UtcNow; - - var expiredIntents = await database.UploadIntents - .Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now) - .ToListAsync(cancellationToken); - - if (expiredIntents.Count > 0) - { - foreach (var intent in expiredIntents) - intent.Status = UploadIntentStatus.Expired; - - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count); - } - - var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); - - var oldExpiredIntents = await database.UploadIntents - .Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff) - .ToListAsync(cancellationToken); - - if (oldExpiredIntents.Count > 0) - { - database.UploadIntents.RemoveRange(oldExpiredIntents); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); - } - } + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs index 94325d5..a10b3d4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs @@ -6,10 +6,6 @@ namespace Musify.Infrastructure.MassTransit; public class MassTransitEventBus(IPublishEndpoint publishEndpoint) : IEventBus { - public async Task PublishAsync(T message, CancellationToken cancellationToken) where T : class - { - ArgumentNullException.ThrowIfNull(message, nameof(message)); - - await publishEndpoint.Publish(message, cancellationToken); - } + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => + publishEndpoint.Publish(message, cancellationToken); } diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index bc13de5..c671eb3 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -7,53 +7,41 @@ using Musify.Infrastructure.Configuration; namespace Musify.Infrastructure.Services; -public partial class AudioService(ILogger logger, +public sealed partial class AudioTranscoderService( + ILogger logger, AudioConfiguration audioConfiguration) : IAudioTranscoderService { - private record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); + private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] private static partial Regex DurationRegex(); public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { - try + Directory.CreateDirectory(destinationPath); + + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + + var executionResult = await ExecuteFfmpegAsync( + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", + cancellationToken); + + if (executionResult.ExitCode != 0) { - Directory.CreateDirectory(destinationPath); - - logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); - - var executionResult = await ExecuteFfmpegWithInputAsync( - BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), - destinationPath, - audioStream, - "TranscodeToAudioFile", - cancellationToken); - - if (executionResult.ExitCode != 0) - { - logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", - executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); - return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); - } - - logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); - - var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); - var duration = await GetAudioDurationAsync(outputPath, cancellationToken); - return new AudioTranscodeResult(executionResult.ExitCode, duration); - } - catch (TimeoutException timeoutException) - { - logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioConfiguration.TranscodingTimeout, destinationPath); - throw; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to transcode audio for {DestinationPath}", destinationPath); - throw; + logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", + executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); + return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); } + + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + + var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); + var duration = await GetAudioDurationAsync(outputPath, cancellationToken); + return new AudioTranscodeResult(executionResult.ExitCode, duration); } private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) @@ -61,6 +49,7 @@ public partial class AudioService(ILogger logger, var executionResult = await ExecuteFfmpegAsync( BuildProbeDurationArguments(filePath), Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + input: null, "ProbeAudioDuration", cancellationToken); @@ -75,37 +64,14 @@ public partial class AudioService(ILogger logger, return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); } - private async Task ExecuteFfmpegWithInputAsync( - string arguments, - string workingDirectory, - Stream inputStream, - string operationName, - CancellationToken cancellationToken) - { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); - process.Start(); - - logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - if (inputStream.CanSeek) - inputStream.Position = 0; - - await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); - process.StandardInput.Close(); - - return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); - } - private async Task ExecuteFfmpegAsync( string arguments, string workingDirectory, + Stream? input, string operationName, CancellationToken cancellationToken) { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); process.Start(); logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); @@ -113,6 +79,15 @@ public partial class AudioService(ILogger logger, var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + if (input != null) + { + if (input.CanSeek) + input.Position = 0; + + await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + } + return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); } @@ -148,11 +123,8 @@ public partial class AudioService(ILogger logger, } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId})", - operationName, audioConfiguration.TranscodingTimeout, process.Id); - TryKillProcess(process); - throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout}."); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); } var standardError = await errorTask; @@ -171,9 +143,9 @@ public partial class AudioService(ILogger logger, if (!process.HasExited) process.Kill(); } - catch (Exception exception) + catch (InvalidOperationException exception) { - logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId})", process.Id); + logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); } } diff --git a/backend/Core/Musify.Infrastructure/Services/PictureService.cs b/backend/Core/Musify.Infrastructure/Services/PictureService.cs index 7b7e182..e938bb8 100644 --- a/backend/Core/Musify.Infrastructure/Services/PictureService.cs +++ b/backend/Core/Musify.Infrastructure/Services/PictureService.cs @@ -10,30 +10,22 @@ public class PictureService(ILogger logger) { public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - try + logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); + if (pictureStream.CanSeek && pictureStream.Position > 0) + pictureStream.Position = 0; + + using var picture = await Image.LoadAsync(pictureStream, cancellationToken); + picture.Mutate(options => options.Resize(new ResizeOptions { - logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); - if (pictureStream.CanSeek && pictureStream.Position > 0) - pictureStream.Position = 0; + Size = new Size(width, height), + Mode = ResizeMode.Max + })); - using var picture = await Image.LoadAsync(pictureStream, cancellationToken); - picture.Mutate(options => options.Resize(new ResizeOptions - { - Size = new Size(width, height), - Mode = ResizeMode.Max - })); + var memoryStream = new MemoryStream(); + await picture.SaveAsWebpAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; - var memoryStream = new MemoryStream(); - await picture.SaveAsWebpAsync(memoryStream, cancellationToken); - memoryStream.Position = 0; - - logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); - return memoryStream; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height); - throw; - } + logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); + return memoryStream; } } diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index e1ce0c5..0ad4da5 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -28,7 +28,7 @@ public static class ServicesDependencyInjection return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); }); - services.AddScoped(); + services.AddSingleton(); return services; } @@ -45,17 +45,7 @@ public static class ServicesDependencyInjection return services; } - public static IServiceCollection AddUploadIntentJobs(this IServiceCollection services, IConfiguration configuration) - { - services.AddUploadIntentConfiguration(configuration); - - services.AddHostedService(); - services.AddHostedService(); - - return services; - } - - public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddJobs(this IServiceCollection services) { services.AddHangfire((serviceProvider, hangfireConfiguration) => { @@ -67,6 +57,9 @@ public static class ServicesDependencyInjection services.AddHangfireServer(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); return services; } @@ -83,7 +76,7 @@ public static class ServicesDependencyInjection { services.AddValidatedOptions(configuration); - services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 57fbecc..a44fb6c 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -1,29 +1,27 @@ +using System.Runtime.CompilerServices; using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Transfer; using Microsoft.Extensions.Logging; using MimeMapping; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData; namespace Musify.Infrastructure.Services; -public class StorageService(IAmazonS3 amazonS3, ILogger logger, +public sealed class StorageService( + IAmazonS3 amazonS3, + ILogger logger, InfrastructureStorageConfiguration storageClientConfiguration) : IStorageService { public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var request = new GetObjectRequest - { - BucketName = bucket, - Key = key, - }; - try { - var response = await amazonS3.GetObjectAsync(request, cancellationToken); + var response = await amazonS3.GetObjectAsync( + new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); return response.ResponseStream; } @@ -32,11 +30,6 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); return null; } - catch (Exception exception) - { - logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); - throw; - } } public async Task GetUploadUrlAsync( @@ -44,8 +37,8 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, string key, string contentType, TimeSpan expirationTime, - CancellationToken cancellationToken, - bool preventOverwrite = true) + bool preventOverwrite = true, + CancellationToken cancellationToken = default) { var request = new GetPreSignedUrlRequest { @@ -71,140 +64,81 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); } - catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } - catch (Exception exception) - { - logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); - throw; - } } public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var request = new DeleteObjectRequest - { - BucketName = bucket, - Key = key, - }; - logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); - await amazonS3.DeleteObjectAsync(request, cancellationToken); + await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); } - public async Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) + public async Task> TransferFilesAsync( + string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) { - var trasnsferUtility = new TransferUtility(amazonS3); - - var files = Directory.GetFiles(sourceDirectory, "*"); + using var transferUtility = new TransferUtility(amazonS3); var uploadedKeys = new List(); - var successCount = 0; - var failedCount = 0; - foreach (var file in files) + foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) { - string fileName = Path.GetFileName(file); - string filePath = Path.Combine(sourceDirectory, file); + var key = StorageKey.Combine(route, Path.GetFileName(filePath)); - var key = string.Join('/', new[] { route, fileName } - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); - var contentType = MimeUtility.GetMimeMapping(filePath); - try - { - await trasnsferUtility.UploadAsync( - new TransferUtilityUploadRequest - { - FilePath = filePath, - BucketName = bucket, - Key = key, - ContentType = contentType - }, - cancellationToken); - uploadedKeys.Add(key); - successCount++; - logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); - } - catch (Exception exception) - { - failedCount++; - logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key); - throw; - } + await transferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = MimeUtility.GetMimeMapping(filePath) + }, + cancellationToken); + + uploadedKeys.Add(key); + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); } - logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount); + logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); return uploadedKeys; } public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) { - try - { - using var fileStream = File.OpenRead(filePath); - var contentType = MimeUtility.GetMimeMapping(filePath); - await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); - throw; - } + await using var fileStream = File.OpenRead(filePath); + await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); } public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) { - var request = new PutObjectRequest - { - BucketName = bucket, - ContentType = contentType, - InputStream = sourceStream, - Key = key - }; - - try - { - await amazonS3.PutObjectAsync(request, cancellationToken); - logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); - throw; - } + await amazonS3.PutObjectAsync( + new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, + cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); } - public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, - CancellationToken cancellationToken) + public async Task CopyFileAsync( + string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken) { - var request = new CopyObjectRequest - { - SourceBucket = sourceBucket, - SourceKey = sourceKey, - DestinationBucket = destinationBucket, - DestinationKey = destinationKey - }; + await amazonS3.CopyObjectAsync( + new CopyObjectRequest + { + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey + }, + cancellationToken); - try - { - await amazonS3.CopyObjectAsync(request, cancellationToken); - logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - throw; - } + logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); } public async IAsyncEnumerable ListObjectsAsync( - string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) { var listRequest = new ListObjectsV2Request { @@ -221,6 +155,6 @@ public class StorageService(IAmazonS3 amazonS3, ILogger logger, listRequest.ContinuationToken = listResponse.NextContinuationToken; } - while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + while (listResponse.IsTruncated == true); } } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index c43a3ac..6f65216 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Mediator; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Caching.Memory; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Musify.Application.Users; namespace Musify.Api.Authentication; @@ -87,15 +88,15 @@ public sealed class JwtBearerEventsHandler( try { + var userInfoEndpoint = await ResolveUserInfoEndpointAsync(tokenValidatedContext, cancellationToken); + var client = httpClientFactory.CreateClient(); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo") + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) { Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } }; - var response = await client.SendAsync(request, cancellationToken); + using var response = await client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); @@ -111,6 +112,20 @@ public sealed class JwtBearerEventsHandler( } } + private async Task ResolveUserInfoEndpointAsync( + TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + { + var configurationManager = tokenValidatedContext.Options.ConfigurationManager; + if (configurationManager != null) + { + var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); + if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) + return endpoint; + } + + return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; + } + public override Task AuthenticationFailed(AuthenticationFailedContext context) { logger.LogWarning(context.Exception, "JWT authentication failed"); diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index 30947c4..0ea3523 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,6 +1,4 @@ -using Hangfire; using Musify.Application.Configuration; -using Musify.Infrastructure.Jobs; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; @@ -26,25 +24,10 @@ services.AddAudioTranscoder(configuration); services.AddPictureService(); services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); -services.AddUploadIntentJobs(configuration); +services.AddUploadIntentConfiguration(configuration); services.AddMediator(); -services.AddDailyMixGenerationJob(configuration); +services.AddJobs(); var host = builder.Build(); -using (var scope = host.Services.CreateScope()) -{ - var recurringJobManager = scope.ServiceProvider.GetRequiredService(); - recurringJobManager.AddOrUpdate( - "daily-mix-generation", - job => job.RunAsync(CancellationToken.None), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - recurringJobManager.AddOrUpdate( - "listening-history-cleanup", - job => job.RunAsync(CancellationToken.None), - Cron.Daily(3), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); -} - host.Run(); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 793eca0..6f6d5eb 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -18,7 +18,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) private async Task CreatePictureIntentAsync(HttpClient client) { fixture.StorageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); fixture.StorageService .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 05d4a2e..98da07e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -17,7 +17,7 @@ public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBa private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index e7eb3c6..0a30e30 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -17,9 +17,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase var user = TestEntities.User(); await SeedAsync(user); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); } @@ -32,9 +30,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase user, track, TestEntities.ListeningHistory(user.Id, track.Id)); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(mixes); Assert.Equal(MixKind.Daily, mixes[0].Kind); @@ -54,9 +50,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase user, listenedTrack, unheardTrack, staleMix, staleItem, TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index d08a8ae..928762e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -17,7 +17,7 @@ public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTes private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index 6eb2e52..33376ef 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -18,7 +18,7 @@ public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://storage.musify.test/presigned-upload"); var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); diff --git a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs new file mode 100644 index 0000000..45f604b --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tests.TestSupport; +using Musify.Application.UploadIntents; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.UploadIntents; + +public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase +{ + [Fact] + public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + { + var owner = TestEntities.User(); + var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); + var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); + await SeedAsync(owner, overdue, active); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); + + var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, changed); + var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); + Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); + } + + [Fact] + public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() + { + var owner = TestEntities.User(); + var old = TestEntities.UploadIntent( + owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); + await SeedAsync(owner, old); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); + + await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() + { + var storage = Substitute.For(); + storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( + new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), + new StorageObject("temp/new", DateTime.UtcNow))); + var handler = new DeleteStaleTempUploadsCommandHandler( + storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), + NoOpLogger()); + + var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); + await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); + } + + private static async IAsyncEnumerable Objects(params StorageObject[] objects) + { + foreach (var item in objects) + yield return item; + + await Task.CompletedTask; + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index 7065485..f96cbba 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -117,7 +117,7 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; var uploadUrl = await service.GetUploadUrlAsync( - InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); using var httpClient = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)