Change editor rule

This commit is contained in:
2026-09-24 13:24:43 +02:00
parent 16478678b5
commit 73e37dbecd
401 changed files with 14735 additions and 12995 deletions
@@ -1,41 +1,42 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class AudioConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "AudioTranscoder";
public class AudioConfiguration : IConfigurationOptions
{
public static string SectionName => "AudioTranscoder";
[Required]
public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5);
[Required]
public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5);
[Required]
public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration();
}
[Required]
public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration();
}
public class FfmpegConfiguration
{
[Required]
public string ExecutableName { get; set; } = "ffmpeg";
public class FfmpegConfiguration
{
[Required]
public string ExecutableName { get; set; } = "ffmpeg";
[Required]
public string OutputFileName { get; set; } = "audio.m4a";
[Required]
public string OutputFileName { get; set; } = "audio.m4a";
[Required]
public string AudioCodec { get; set; } = "aac";
[Required]
public string AudioCodec { get; set; } = "aac";
[Required]
public string AudioBitrate { get; set; } = "128k";
[Required]
public string AudioBitrate { get; set; } = "128k";
[Range(1, 8)]
public int AudioChannels { get; set; } = 2;
[Range(1, 8)]
public int AudioChannels { get; set; } = 2;
[Range(8000, 192000)]
public int AudioSampleRate { get; set; } = 48000;
[Range(8000, 192000)]
public int AudioSampleRate { get; set; } = 48000;
[Required]
public string AudioProfile { get; set; } = "aac_low";
[Required]
public string AudioProfile { get; set; } = "aac_low";
public string AdditionalArguments { get; set; } = string.Empty;
public string AdditionalArguments { get; set; } = string.Empty;
}
}
@@ -1,12 +1,13 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class DatabaseConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "Database";
public class DatabaseConfiguration : IConfigurationOptions
{
public static string SectionName => "Database";
[Required]
public required string ConnectionString { get; set; }
[Required]
public required string ConnectionString { get; set; }
}
}
@@ -1,25 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class InfrastructureStorageConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "InfrastructureStorage";
public class InfrastructureStorageConfiguration : IConfigurationOptions
{
public static string SectionName => "InfrastructureStorage";
[Url]
[Required]
public required string Address { get; set; }
[Url]
[Required]
public required string Address { get; set; }
[Required]
public required string AccessKey { get; set; }
[Required]
public required string AccessKey { get; set; }
[Required]
public required string SecretAccessKey { get; set; }
[Required]
public required string SecretAccessKey { get; set; }
[Required]
public bool ForcePathStyle { get; set; }
[Required]
public bool ForcePathStyle { get; set; }
[Required]
public bool UseHttp { get; set; }
[Required]
public bool UseHttp { get; set; }
}
}
@@ -1,18 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class MassTransitConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "MassTransit";
public class MassTransitConfiguration : IConfigurationOptions
{
public static string SectionName => "MassTransit";
[Required]
public required string Address { get; set; }
[Required]
public required string Address { get; set; }
[Required]
public required string Username { get; set; }
[Required]
public required string Username { get; set; }
[Required]
public required string Password { get; set; }
[Required]
public required string Password { get; set; }
}
}
@@ -1,13 +1,14 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class OpenTelemetryConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "OpenTelemetry";
public class OpenTelemetryConfiguration : IConfigurationOptions
{
public static string SectionName => "OpenTelemetry";
[Url]
[Required]
public required string OtlpEndpoint { get; set; }
[Url]
[Required]
public required string OtlpEndpoint { get; set; }
}
}
@@ -1,21 +1,22 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class StreamTicketConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "StreamTicket";
public class StreamTicketConfiguration : IConfigurationOptions
{
public static string SectionName => "StreamTicket";
[Required]
public required string PrivateKeyPath { get; set; }
[Required]
public required string PrivateKeyPath { get; set; }
[Required]
public string Audience { get; set; } = "media-gateway";
[Required]
public string Audience { get; set; } = "media-gateway";
[Required]
public string Issuer { get; set; } = "musify-webapi";
[Required]
public string Issuer { get; set; } = "musify-webapi";
[Range(60, 86400)]
public int TicketTtlSeconds { get; set; } = 3600;
[Range(60, 86400)]
public int TicketTtlSeconds { get; set; } = 3600;
}
}
@@ -1,18 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Configuration;
public class WorkerConfiguration : IConfigurationOptions
namespace Musify.Infrastructure.Configuration
{
public static string SectionName => "Workers";
public class WorkerConfiguration : IConfigurationOptions
{
public static string SectionName => "Workers";
[Required]
public WorkerRoutes Routes { get; set; } = new WorkerRoutes();
}
[Required]
public WorkerRoutes Routes { get; set; } = new WorkerRoutes();
}
public class WorkerRoutes
{
[Required]
public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath();
public class WorkerRoutes
{
[Required]
public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath();
}
}
@@ -4,27 +4,28 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Mixes;
namespace Musify.Infrastructure.Jobs;
public class DailyMixGenerationJob(
IDatabase database,
IMediator mediator,
ILogger<DailyMixGenerationJob> logger)
namespace Musify.Infrastructure.Jobs
{
public async Task RunAsync(CancellationToken cancellationToken)
public class DailyMixGenerationJob(
IDatabase database,
IMediator mediator,
ILogger<DailyMixGenerationJob> logger)
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
public async Task RunAsync(CancellationToken cancellationToken)
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count);
logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count);
foreach (var userId in userIds)
await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken);
foreach (var userId in userIds)
await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken);
logger.LogInformation("Finished daily mix generation");
logger.LogInformation("Finished daily mix generation");
}
}
}
@@ -1,12 +1,13 @@
using Mediator;
using Musify.Application.Tracks;
namespace Musify.Infrastructure.Jobs;
public class ListeningHistoryCleanupJob(IMediator mediator)
namespace Musify.Infrastructure.Jobs
{
public async Task RunAsync(CancellationToken cancellationToken)
public class ListeningHistoryCleanupJob(IMediator mediator)
{
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
public async Task RunAsync(CancellationToken cancellationToken)
{
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
}
}
}
@@ -2,40 +2,41 @@ using Hangfire;
using Microsoft.Extensions.Hosting;
using Musify.Application.Configuration;
namespace Musify.Infrastructure.Jobs;
/// <summary>Registers every recurring Hangfire job when the host starts.</summary>
public sealed class RecurringJobsRegistrar(
IRecurringJobManager recurringJobManager,
UploadIntentConfiguration uploadIntentConfiguration)
: IHostedService
namespace Musify.Infrastructure.Jobs
{
public Task StartAsync(CancellationToken cancellationToken)
/// <summary>Registers every recurring Hangfire job when the host starts.</summary>
public sealed class RecurringJobsRegistrar(
IRecurringJobManager recurringJobManager,
UploadIntentConfiguration uploadIntentConfiguration)
: IHostedService
{
var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc };
public Task StartAsync(CancellationToken cancellationToken)
{
var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc };
recurringJobManager.AddOrUpdate<DailyMixGenerationJob>(
"daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options);
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
"listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options);
recurringJobManager.AddOrUpdate<UploadIntentExpirationJob>(
"upload-intent-expiration", job => job.RunAsync(CancellationToken.None),
EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options);
recurringJobManager.AddOrUpdate<TempUploadsCleanupJob>(
"temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None),
EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options);
recurringJobManager.AddOrUpdate<DailyMixGenerationJob>(
"daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options);
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
"listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options);
recurringJobManager.AddOrUpdate<UploadIntentExpirationJob>(
"upload-intent-expiration", job => job.RunAsync(CancellationToken.None),
EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options);
recurringJobManager.AddOrUpdate<TempUploadsCleanupJob>(
"temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None),
EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options);
return Task.CompletedTask;
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes.</summary>
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)} * * *";
/// <summary>Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes.</summary>
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)} * * *";
}
}
}
@@ -1,10 +1,11 @@
using Mediator;
using Musify.Application.UploadIntents;
namespace Musify.Infrastructure.Jobs;
public class TempUploadsCleanupJob(IMediator mediator)
namespace Musify.Infrastructure.Jobs
{
public async Task RunAsync(CancellationToken cancellationToken) =>
await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken);
public class TempUploadsCleanupJob(IMediator mediator)
{
public async Task RunAsync(CancellationToken cancellationToken) =>
await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken);
}
}
@@ -1,10 +1,11 @@
using Mediator;
using Musify.Application.UploadIntents;
namespace Musify.Infrastructure.Jobs;
public class UploadIntentExpirationJob(IMediator mediator)
namespace Musify.Infrastructure.Jobs
{
public async Task RunAsync(CancellationToken cancellationToken) =>
await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken);
public class UploadIntentExpirationJob(IMediator mediator)
{
public async Task RunAsync(CancellationToken cancellationToken) =>
await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken);
}
}
@@ -3,29 +3,30 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Albums;
internal class PublishAlbumPictureProcessingEventActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishAlbumPictureProcessingEventActivity> logger)
: IExecuteActivity<PublishAlbumPictureProcessingEventArguments>
namespace Musify.Infrastructure.MassTransit.Activities.Albums
{
public const string ExecuteEndpointName = "publish-album-picture-processing-event";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishAlbumPictureProcessingEventArguments> executeContext)
internal class PublishAlbumPictureProcessingEventActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishAlbumPictureProcessingEventActivity> logger)
: IExecuteActivity<PublishAlbumPictureProcessingEventArguments>
{
var arguments = executeContext.Arguments;
public const string ExecuteEndpointName = "publish-album-picture-processing-event";
await publishEndpoint.Publish(
new UpdateAlbumPictureEvent(
arguments.AlbumId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
public async Task<ExecutionResult> Execute(ExecuteContext<PublishAlbumPictureProcessingEventArguments> executeContext)
{
var arguments = executeContext.Arguments;
logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId);
await publishEndpoint.Publish(
new UpdateAlbumPictureEvent(
arguments.AlbumId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
return executeContext.Completed();
logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId);
return executeContext.Completed();
}
}
}
@@ -5,43 +5,44 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Activities.Audio;
internal class GenerateAudioWorkflowPathsActivity(
IDatabase database,
ILogger<GenerateAudioWorkflowPathsActivity> logger)
: IExecuteActivity<GenerateAudioWorkflowPathsArguments>
namespace Musify.Infrastructure.MassTransit.Activities.Audio
{
public const string ExecuteEndpointName = "generate-audio-workflow-paths";
public async Task<ExecutionResult> Execute(ExecuteContext<GenerateAudioWorkflowPathsArguments> executeContext)
internal class GenerateAudioWorkflowPathsActivity(
IDatabase database,
ILogger<GenerateAudioWorkflowPathsActivity> logger)
: IExecuteActivity<GenerateAudioWorkflowPathsArguments>
{
var arguments = executeContext.Arguments;
var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken);
public const string ExecuteEndpointName = "generate-audio-workflow-paths";
try
public async Task<ExecutionResult> Execute(ExecuteContext<GenerateAudioWorkflowPathsArguments> executeContext)
{
track.Audio.TranscodeStatus = ProcessingStatus.Processing;
var arguments = executeContext.Arguments;
var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken);
var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString());
var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey));
Directory.CreateDirectory(workingDirectory);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory);
return executeContext.CompletedWithVariables(new Dictionary<string, object>
try
{
[RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory,
[RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath,
[RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory
});
}
catch
{
await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken);
throw;
track.Audio.TranscodeStatus = ProcessingStatus.Processing;
var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString());
var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey));
Directory.CreateDirectory(workingDirectory);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory);
return executeContext.CompletedWithVariables(new Dictionary<string, object>
{
[RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory,
[RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath,
[RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory
});
}
catch
{
await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken);
throw;
}
}
}
}
@@ -3,22 +3,23 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Infrastructure.MassTransit.Activities.Audio;
/// <summary>Shared bookkeeping of the audio processing activities.</summary>
internal static class TrackAudioStatus
namespace Musify.Infrastructure.MassTransit.Activities.Audio
{
public static async Task<Track> LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) =>
await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken)
?? throw new InvalidOperationException($"Track with id {trackId} not found");
public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken)
/// <summary>Shared bookkeeping of the audio processing activities.</summary>
internal static class TrackAudioStatus
{
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken);
if (track == null)
return;
public static async Task<Track> LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) =>
await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken)
?? throw new InvalidOperationException($"Track with id {trackId} not found");
track.Audio.TranscodeStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(cancellationToken);
public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken)
{
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken);
if (track == null)
return;
track.Audio.TranscodeStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(cancellationToken);
}
}
}
@@ -5,61 +5,62 @@ using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Activities.Audio;
internal class TranscodeAudioActivity(
IDatabase database,
IAudioTranscoderService audioTranscoder,
ILogger<TranscodeAudioActivity> logger)
: IActivity<TranscodeAudioArguments, TranscodeAudioLog>
namespace Musify.Infrastructure.MassTransit.Activities.Audio
{
public const string ExecuteEndpointName = "transcode-audio";
public async Task<ExecutionResult> Execute(ExecuteContext<TranscodeAudioArguments> executeContext)
internal class TranscodeAudioActivity(
IDatabase database,
IAudioTranscoderService audioTranscoder,
ILogger<TranscodeAudioActivity> logger)
: IActivity<TranscodeAudioArguments, TranscodeAudioLog>
{
var arguments = executeContext.Arguments;
var sourceFilePath = executeContext.GetVariable<string>(arguments.SourceFilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath);
var workingDirectory = executeContext.GetVariable<string>(arguments.WorkingDirectoryVariable);
ArgumentNullException.ThrowIfNull(workingDirectory);
public const string ExecuteEndpointName = "transcode-audio";
await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken);
try
public async Task<ExecutionResult> Execute(ExecuteContext<TranscodeAudioArguments> executeContext)
{
AudioTranscodeResult transcodeResult;
await using (var fileStream = File.OpenRead(sourceFilePath))
var arguments = executeContext.Arguments;
var sourceFilePath = executeContext.GetVariable<string>(arguments.SourceFilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath);
var workingDirectory = executeContext.GetVariable<string>(arguments.WorkingDirectoryVariable);
ArgumentNullException.ThrowIfNull(workingDirectory);
await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken);
try
{
transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync(
fileStream, workingDirectory, executeContext.CancellationToken);
AudioTranscodeResult transcodeResult;
await using (var fileStream = File.OpenRead(sourceFilePath))
{
transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync(
fileStream, workingDirectory, executeContext.CancellationToken);
}
if (transcodeResult.StatusCode != 0)
throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}.");
File.Delete(sourceFilePath);
return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary<string, object>
{
[RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds
});
}
if (transcodeResult.StatusCode != 0)
throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}.");
File.Delete(sourceFilePath);
return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary<string, object>
catch
{
[RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds
});
await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken);
throw;
}
}
catch
public Task<CompensationResult> Compensate(CompensateContext<TranscodeAudioLog> compensateContext)
{
await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken);
throw;
var workingDirectory = compensateContext.Log.WorkingDirectory;
if (Directory.Exists(workingDirectory))
Directory.Delete(workingDirectory, recursive: true);
else
logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory);
return Task.FromResult(compensateContext.Compensated());
}
}
public Task<CompensationResult> Compensate(CompensateContext<TranscodeAudioLog> compensateContext)
{
var workingDirectory = compensateContext.Log.WorkingDirectory;
if (Directory.Exists(workingDirectory))
Directory.Delete(workingDirectory, recursive: true);
else
logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory);
return Task.FromResult(compensateContext.Compensated());
}
}
@@ -6,61 +6,62 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Audio;
internal class UpdateTrackAudioActivity(
IDatabase database,
IPublishEndpoint publishEndpoint,
ILogger<UpdateTrackAudioActivity> logger)
: IActivity<UpdateTrackAudioArguments, UpdateTrackAudioLog>
namespace Musify.Infrastructure.MassTransit.Activities.Audio
{
public const string ExecuteEndpointName = "update-track-audio";
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateTrackAudioArguments> executeContext)
internal class UpdateTrackAudioActivity(
IDatabase database,
IPublishEndpoint publishEndpoint,
ILogger<UpdateTrackAudioActivity> logger)
: IActivity<UpdateTrackAudioArguments, UpdateTrackAudioLog>
{
var arguments = executeContext.Arguments;
var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken);
public const string ExecuteEndpointName = "update-track-audio";
try
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateTrackAudioArguments> executeContext)
{
if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey))
throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}");
var arguments = executeContext.Arguments;
var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken);
var durationSeconds = executeContext.GetVariable<double>(arguments.DurationSecondsVariable)
?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}");
try
{
if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey))
throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}");
var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds);
var durationSeconds = executeContext.GetVariable<double>(arguments.DurationSecondsVariable)
?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}");
track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey);
track.DurationSeconds = durationSeconds;
track.Audio.TranscodeStatus = ProcessingStatus.Completed;
await database.SaveChangesAsync(executeContext.CancellationToken);
var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds);
logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId);
track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey);
track.DurationSeconds = durationSeconds;
track.Audio.TranscodeStatus = ProcessingStatus.Completed;
await database.SaveChangesAsync(executeContext.CancellationToken);
await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken);
logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId);
return executeContext.Completed(log);
await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken);
return executeContext.Completed(log);
}
catch
{
await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken);
throw;
}
}
catch
public async Task<CompensationResult> Compensate(CompensateContext<UpdateTrackAudioLog> compensateContext)
{
await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken);
throw;
var log = compensateContext.Log;
var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken);
track.Audio.FolderName = log.PreviousAudioFolderName;
track.DurationSeconds = log.PreviousDurationSeconds;
track.Audio.TranscodeStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(compensateContext.CancellationToken);
logger.LogInformation("Compensated track {TrackId} audio", log.TrackId);
return compensateContext.Compensated();
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdateTrackAudioLog> compensateContext)
{
var log = compensateContext.Log;
var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken);
track.Audio.FolderName = log.PreviousAudioFolderName;
track.DurationSeconds = log.PreviousDurationSeconds;
track.Audio.TranscodeStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(compensateContext.CancellationToken);
logger.LogInformation("Compensated track {TrackId} audio", log.TrackId);
return compensateContext.Compensated();
}
}
@@ -4,45 +4,46 @@ using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files;
internal class CopyFileInBucketActivity(
IStorageService storageService,
ILogger<CopyFileInBucketActivity> logger)
: IActivity<CopyFileInBucketArguments, CopyFileInBucketLog>
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public const string ExecuteEndpointName = "copy-file-in-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<CopyFileInBucketArguments> executeContext)
internal class CopyFileInBucketActivity(
IStorageService storageService,
ILogger<CopyFileInBucketActivity> logger)
: IActivity<CopyFileInBucketArguments, CopyFileInBucketLog>
{
await storageService.CopyFileAsync(
executeContext.Arguments.SourceBucket,
executeContext.Arguments.SourceKey,
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationKey,
executeContext.CancellationToken);
public const string ExecuteEndpointName = "copy-file-in-bucket";
return executeContext.Completed(new CopyFileInBucketLog(
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationKey));
}
public async Task<CompensationResult> Compensate(CompensateContext<CopyFileInBucketLog> compensateContext)
{
try
public async Task<ExecutionResult> Execute(ExecuteContext<CopyFileInBucketArguments> executeContext)
{
await storageService.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey,
compensateContext.CancellationToken);
return compensateContext.Compensated();
await storageService.CopyFileAsync(
executeContext.Arguments.SourceBucket,
executeContext.Arguments.SourceKey,
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationKey,
executeContext.CancellationToken);
return executeContext.Completed(new CopyFileInBucketLog(
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationKey));
}
catch (Exception exception)
public async Task<CompensationResult> Compensate(CompensateContext<CopyFileInBucketLog> compensateContext)
{
logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}",
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey);
return compensateContext.Failed(exception);
try
{
await storageService.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey,
compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}",
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey);
return compensateContext.Failed(exception);
}
}
}
}
@@ -4,52 +4,53 @@ using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files;
internal class DownloadFileFromBucketActivity(
IStorageService storageService,
ILogger<DownloadFileFromBucketActivity> logger)
: IActivity<DownloadFileFromBucketArguments, DownloadFileFromBucketLog>
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public const string ExecuteEndpointName = "download-file-from-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromBucketArguments> executeContext)
internal class DownloadFileFromBucketActivity(
IStorageService storageService,
ILogger<DownloadFileFromBucketActivity> logger)
: IActivity<DownloadFileFromBucketArguments, DownloadFileFromBucketLog>
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath);
public const string ExecuteEndpointName = "download-file-from-bucket";
using var fileStream = await storageService.GetFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken)
?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found.");
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
if (fileStream.CanSeek)
fileStream.Position = 0;
using var destinationStream = File.Create(destinationPath);
await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken);
return executeContext.Completed(new DownloadFileFromBucketLog(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
destinationPath));
}
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromBucketLog> compensateContext)
{
try
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromBucketArguments> executeContext)
{
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath);
using var fileStream = await storageService.GetFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken)
?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found.");
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
if (fileStream.CanSeek)
fileStream.Position = 0;
using var destinationStream = File.Create(destinationPath);
await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken);
return executeContext.Completed(new DownloadFileFromBucketLog(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
destinationPath));
}
catch (Exception exception)
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromBucketLog> compensateContext)
{
logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
try
{
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
}
@@ -5,34 +5,35 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Files;
internal class RemoveFileFromBucketActivity(
IStorageService storageService,
ILogger<RemoveFileFromBucketActivity> logger)
: IExecuteActivity<RemoveFileFromBucketArguments>
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public const string ExecuteEndpointName = "remove-file-from-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<RemoveFileFromBucketArguments> executeContext)
internal class RemoveFileFromBucketActivity(
IStorageService storageService,
ILogger<RemoveFileFromBucketActivity> logger)
: IExecuteActivity<RemoveFileFromBucketArguments>
{
try
public const string ExecuteEndpointName = "remove-file-from-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<RemoveFileFromBucketArguments> executeContext)
{
await storageService.RemoveFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken);
return executeContext.Completed();
}
catch (AmazonS3Exception exception) when (
exception.StatusCode == HttpStatusCode.NotFound
|| string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation(
"Key {Key} not found in bucket {Bucket}, skipping",
executeContext.Arguments.Key,
executeContext.Arguments.Bucket);
return executeContext.Completed();
try
{
await storageService.RemoveFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken);
return executeContext.Completed();
}
catch (AmazonS3Exception exception) when (
exception.StatusCode == HttpStatusCode.NotFound
|| string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation(
"Key {Key} not found in bucket {Bucket}, skipping",
executeContext.Arguments.Key,
executeContext.Arguments.Bucket);
return executeContext.Completed();
}
}
}
}
@@ -4,51 +4,52 @@ using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files;
internal class TransferFilesToBucketActivity(
IStorageService storageService,
ILogger<TransferFilesToBucketActivity> logger)
: IActivity<TransferFilesToBucketArguments, TransferFilesToBucketLog>
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public const string ExecuteEndpointName = "transfer-files-to-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<TransferFilesToBucketArguments> executeContext)
internal class TransferFilesToBucketActivity(
IStorageService storageService,
ILogger<TransferFilesToBucketActivity> logger)
: IActivity<TransferFilesToBucketArguments, TransferFilesToBucketLog>
{
var folderPath = executeContext.GetVariable<string>(executeContext.Arguments.SourceDirectoryVariable);
ArgumentNullException.ThrowIfNull(folderPath);
var destinationKey = executeContext.Arguments.DestinationKey;
ArgumentNullException.ThrowIfNull(destinationKey);
public const string ExecuteEndpointName = "transfer-files-to-bucket";
var uploadedKeys = await storageService.TransferFilesAsync(
folderPath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
return executeContext.Completed(new TransferFilesToBucketLog(
executeContext.Arguments.DestinationBucket,
uploadedKeys.ToArray()));
}
public async Task<CompensationResult> Compensate(CompensateContext<TransferFilesToBucketLog> compensateContext)
{
try
public async Task<ExecutionResult> Execute(ExecuteContext<TransferFilesToBucketArguments> executeContext)
{
foreach (var uploadedKey in compensateContext.Log.UploadedKeys)
{
await storageService.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
uploadedKey,
compensateContext.CancellationToken);
}
var folderPath = executeContext.GetVariable<string>(executeContext.Arguments.SourceDirectoryVariable);
ArgumentNullException.ThrowIfNull(folderPath);
var destinationKey = executeContext.Arguments.DestinationKey;
ArgumentNullException.ThrowIfNull(destinationKey);
return compensateContext.Compensated();
var uploadedKeys = await storageService.TransferFilesAsync(
folderPath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
return executeContext.Completed(new TransferFilesToBucketLog(
executeContext.Arguments.DestinationBucket,
uploadedKeys.ToArray()));
}
catch (Exception exception)
public async Task<CompensationResult> Compensate(CompensateContext<TransferFilesToBucketLog> compensateContext)
{
logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket);
return compensateContext.Failed(exception);
try
{
foreach (var uploadedKey in compensateContext.Log.UploadedKeys)
{
await storageService.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
uploadedKey,
compensateContext.CancellationToken);
}
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket);
return compensateContext.Failed(exception);
}
}
}
}
@@ -4,49 +4,50 @@ using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files;
internal class UploadFileToBucketActivity(IStorageService storageService,
ILogger<UploadFileToBucketActivity> logger)
: IActivity<UploadFileToBucketArguments, UploadFileToBucketLog>
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public const string ExecuteEndpointName = "upload-file-to-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<UploadFileToBucketArguments> executeContext)
internal class UploadFileToBucketActivity(IStorageService storageService,
ILogger<UploadFileToBucketActivity> logger)
: IActivity<UploadFileToBucketArguments, UploadFileToBucketLog>
{
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.FilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath);
var fileName = Path.GetFileName(sourceFilePath);
var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName }
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
public const string ExecuteEndpointName = "upload-file-to-bucket";
await storageService.UploadFileAsync(
sourceFilePath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
executeContext.Arguments.DestinationBucket,
destinationKey));
}
public async Task<CompensationResult> Compensate(CompensateContext<UploadFileToBucketLog> compensateContext)
{
try
public async Task<ExecutionResult> Execute(ExecuteContext<UploadFileToBucketArguments> executeContext)
{
await storageService.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey,
compensateContext.CancellationToken);
return compensateContext.Compensated();
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.FilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath);
var fileName = Path.GetFileName(sourceFilePath);
var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName }
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
await storageService.UploadFileAsync(
sourceFilePath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
executeContext.Arguments.DestinationBucket,
destinationKey));
}
catch (Exception exception)
public async Task<CompensationResult> Compensate(CompensateContext<UploadFileToBucketLog> compensateContext)
{
logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}",
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey);
return compensateContext.Failed(exception);
try
{
await storageService.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey,
compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}",
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey);
return compensateContext.Failed(exception);
}
}
}
}
@@ -6,92 +6,93 @@ using Musify.Domain.Abstractions;
using Musify.Domain.Entities;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle;
/// <summary>Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped.</summary>
internal abstract class MarkLifeCycleActivity<TEntity>(IDatabase database, ILogger logger)
: IExecuteActivity<MarkLifeCycleArguments>
where TEntity : class, IHasLifeCycle
namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
{
public async Task<ExecutionResult> Execute(ExecuteContext<MarkLifeCycleArguments> executeContext)
/// <summary>Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped.</summary>
internal abstract class MarkLifeCycleActivity<TEntity>(IDatabase database, ILogger logger)
: IExecuteActivity<MarkLifeCycleArguments>
where TEntity : class, IHasLifeCycle
{
var arguments = executeContext.Arguments;
var entity = await database.Set<TEntity>()
.SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken);
if (entity == null)
public async Task<ExecutionResult> Execute(ExecuteContext<MarkLifeCycleArguments> executeContext)
{
logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status);
var arguments = executeContext.Arguments;
var entity = await database.Set<TEntity>()
.SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken);
if (entity == null)
{
logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status);
return executeContext.Completed();
}
entity.LifeCycleStatus = arguments.Status;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status);
return executeContext.Completed();
}
entity.LifeCycleStatus = arguments.Status;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status);
return executeContext.Completed();
}
}
/// <summary>Deletes an entity row. A missing entity is skipped.</summary>
internal abstract class DeleteEntityActivity<TEntity>(IDatabase database, ILogger logger)
: IExecuteActivity<DeleteEntityArguments>
where TEntity : class, IHasLifeCycle
{
public async Task<ExecutionResult> Execute(ExecuteContext<DeleteEntityArguments> executeContext)
/// <summary>Deletes an entity row. A missing entity is skipped.</summary>
internal abstract class DeleteEntityActivity<TEntity>(IDatabase database, ILogger logger)
: IExecuteActivity<DeleteEntityArguments>
where TEntity : class, IHasLifeCycle
{
var id = executeContext.Arguments.Id;
var entity = await database.Set<TEntity>()
.SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken);
if (entity == null)
public async Task<ExecutionResult> Execute(ExecuteContext<DeleteEntityArguments> executeContext)
{
logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id);
var id = executeContext.Arguments.Id;
var entity = await database.Set<TEntity>()
.SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken);
if (entity == null)
{
logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id);
return executeContext.Completed();
}
database.Set<TEntity>().Remove(entity);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id);
return executeContext.Completed();
}
}
database.Set<TEntity>().Remove(entity);
await database.SaveChangesAsync(executeContext.CancellationToken);
// One concrete activity per entity keeps a separate queue for each of them:
// the endpoint name is the kebab-cased class name.
internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger<MarkTrackLifeCycleActivity> logger)
: MarkLifeCycleActivity<Track>(database, logger)
{
public const string ExecuteEndpointName = "mark-track-life-cycle";
}
logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id);
return executeContext.Completed();
internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger<MarkPlayListLifeCycleActivity> logger)
: MarkLifeCycleActivity<PlayList>(database, logger)
{
public const string ExecuteEndpointName = "mark-play-list-life-cycle";
}
internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger<MarkAlbumLifeCycleActivity> logger)
: MarkLifeCycleActivity<Album>(database, logger)
{
public const string ExecuteEndpointName = "mark-album-life-cycle";
}
internal sealed class DeleteTrackActivity(IDatabase database, ILogger<DeleteTrackActivity> logger)
: DeleteEntityActivity<Track>(database, logger)
{
public const string ExecuteEndpointName = "delete-track";
}
internal sealed class DeletePlayListActivity(IDatabase database, ILogger<DeletePlayListActivity> logger)
: DeleteEntityActivity<PlayList>(database, logger)
{
public const string ExecuteEndpointName = "delete-play-list";
}
internal sealed class DeleteAlbumActivity(IDatabase database, ILogger<DeleteAlbumActivity> logger)
: DeleteEntityActivity<Album>(database, logger)
{
public const string ExecuteEndpointName = "delete-album";
}
}
// One concrete activity per entity keeps a separate queue for each of them:
// the endpoint name is the kebab-cased class name.
internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger<MarkTrackLifeCycleActivity> logger)
: MarkLifeCycleActivity<Track>(database, logger)
{
public const string ExecuteEndpointName = "mark-track-life-cycle";
}
internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger<MarkPlayListLifeCycleActivity> logger)
: MarkLifeCycleActivity<PlayList>(database, logger)
{
public const string ExecuteEndpointName = "mark-play-list-life-cycle";
}
internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger<MarkAlbumLifeCycleActivity> logger)
: MarkLifeCycleActivity<Album>(database, logger)
{
public const string ExecuteEndpointName = "mark-album-life-cycle";
}
internal sealed class DeleteTrackActivity(IDatabase database, ILogger<DeleteTrackActivity> logger)
: DeleteEntityActivity<Track>(database, logger)
{
public const string ExecuteEndpointName = "delete-track";
}
internal sealed class DeletePlayListActivity(IDatabase database, ILogger<DeletePlayListActivity> logger)
: DeleteEntityActivity<PlayList>(database, logger)
{
public const string ExecuteEndpointName = "delete-play-list";
}
internal sealed class DeleteAlbumActivity(IDatabase database, ILogger<DeleteAlbumActivity> logger)
: DeleteEntityActivity<Album>(database, logger)
{
public const string ExecuteEndpointName = "delete-album";
}
@@ -3,37 +3,38 @@ using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Activities.Pictures;
internal class GeneratePictureWorkflowPathsActivity(
ILogger<GeneratePictureWorkflowPathsActivity> logger)
: IExecuteActivity<GeneratePictureWorkflowPathsArguments>
namespace Musify.Infrastructure.MassTransit.Activities.Pictures
{
public const string ExecuteEndpointName = "generate-picture-workflow-paths";
internal const string ResizedPictureFileExtension = ".webp";
public Task<ExecutionResult> Execute(ExecuteContext<GeneratePictureWorkflowPathsArguments> executeContext)
internal class GeneratePictureWorkflowPathsActivity(
ILogger<GeneratePictureWorkflowPathsActivity> logger)
: IExecuteActivity<GeneratePictureWorkflowPathsArguments>
{
var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString());
public const string ExecuteEndpointName = "generate-picture-workflow-paths";
var sourceFilePath = Path.Combine(
workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey));
var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension);
var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension);
var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension);
internal const string ResizedPictureFileExtension = ".webp";
Directory.CreateDirectory(workingDirectory);
logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory);
return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary<string, object>
public Task<ExecutionResult> Execute(ExecuteContext<GeneratePictureWorkflowPathsArguments> executeContext)
{
[RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory,
[RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath,
[RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath,
[RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath,
[RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath
}));
var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString());
var sourceFilePath = Path.Combine(
workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey));
var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension);
var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension);
var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension);
Directory.CreateDirectory(workingDirectory);
logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory);
return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary<string, object>
{
[RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory,
[RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath,
[RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath,
[RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath,
[RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath
}));
}
}
}
@@ -4,60 +4,61 @@ using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Pictures;
internal class ResizePictureActivity(
IPictureService pictureHandler,
ILogger<ResizePictureActivity> logger)
: IActivity<ResizePictureLocalArguments, ResizePictureLog>
namespace Musify.Infrastructure.MassTransit.Activities.Pictures
{
public const string ExecuteEndpointName = "resize-picture";
public async Task<ExecutionResult> Execute(ExecuteContext<ResizePictureLocalArguments> executeContext)
internal class ResizePictureActivity(
IPictureService pictureHandler,
ILogger<ResizePictureActivity> logger)
: IActivity<ResizePictureLocalArguments, ResizePictureLog>
{
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.SourceFilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath);
var destinationFilePath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationFilePath);
public const string ExecuteEndpointName = "resize-picture";
if (!File.Exists(sourceFilePath))
public async Task<ExecutionResult> Execute(ExecuteContext<ResizePictureLocalArguments> executeContext)
{
logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath);
throw new FileNotFoundException($"Source file not found: {sourceFilePath}");
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.SourceFilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath);
var destinationFilePath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationFilePath);
if (!File.Exists(sourceFilePath))
{
logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath);
throw new FileNotFoundException($"Source file not found: {sourceFilePath}");
}
await using var fileStream = File.OpenRead(sourceFilePath);
await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync(
fileStream,
executeContext.Arguments.Width,
executeContext.Arguments.Height,
executeContext.CancellationToken);
var destinationDirectory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrEmpty(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
await using var destinationStream = File.Create(destinationFilePath);
await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken);
logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}",
sourceFilePath,
destinationFilePath);
return executeContext.Completed(new ResizePictureLog(destinationFilePath));
}
await using var fileStream = File.OpenRead(sourceFilePath);
await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync(
fileStream,
executeContext.Arguments.Width,
executeContext.Arguments.Height,
executeContext.CancellationToken);
var destinationDirectory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrEmpty(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
await using var destinationStream = File.Create(destinationFilePath);
await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken);
logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}",
sourceFilePath,
destinationFilePath);
return executeContext.Completed(new ResizePictureLog(destinationFilePath));
}
public Task<CompensationResult> Compensate(CompensateContext<ResizePictureLog> compensateContext)
{
try
public Task<CompensationResult> Compensate(CompensateContext<ResizePictureLog> compensateContext)
{
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
try
{
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
}
@@ -8,153 +8,154 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Pictures;
internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large);
/// <summary>Stores the processed picture names on the owner and reports it; compensation restores the previous names.</summary>
internal abstract class UpdatePicturesActivity<TEntity>(
IDatabase database,
IPublishEndpoint publishEndpoint,
ILogger logger)
: IActivity<UpdatePicturesArguments, UpdatePicturesLog>
where TEntity : class
namespace Musify.Infrastructure.MassTransit.Activities.Pictures
{
protected IDatabase Database { get; } = database;
internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large);
protected abstract Task<TEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>Reads the current names; throws when the entity has no pending picture.</summary>
protected abstract PictureNames Read(TEntity entity);
protected abstract void Write(TEntity entity, PictureNames names, bool succeeded);
protected abstract object ProcessedEvent(Guid id);
public async Task<ExecutionResult> Execute(ExecuteContext<UpdatePicturesArguments> executeContext)
/// <summary>Stores the processed picture names on the owner and reports it; compensation restores the previous names.</summary>
internal abstract class UpdatePicturesActivity<TEntity>(
IDatabase database,
IPublishEndpoint publishEndpoint,
ILogger logger)
: IActivity<UpdatePicturesArguments, UpdatePicturesLog>
where TEntity : class
{
var arguments = executeContext.Arguments;
var small = RequireVariable(executeContext, arguments.SmallPictureVariable);
var medium = RequireVariable(executeContext, arguments.MediumPictureVariable);
var large = RequireVariable(executeContext, arguments.LargePictureVariable);
protected IDatabase Database { get; } = database;
var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken)
?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found");
protected abstract Task<TEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
var previous = Read(entity);
Write(
entity,
new PictureNames(
Path.GetFileName(arguments.OriginalPictureKey),
Path.GetFileName(small),
Path.GetFileName(medium),
Path.GetFileName(large)),
succeeded: true);
await Database.SaveChangesAsync(executeContext.CancellationToken);
/// <summary>Reads the current names; throws when the entity has no pending picture.</summary>
protected abstract PictureNames Read(TEntity entity);
logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId);
protected abstract void Write(TEntity entity, PictureNames names, bool succeeded);
await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken);
protected abstract object ProcessedEvent(Guid id);
return executeContext.Completed(new UpdatePicturesLog(
arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large));
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdatePicturesLog> compensateContext)
{
var log = compensateContext.Log;
try
public async Task<ExecutionResult> Execute(ExecuteContext<UpdatePicturesArguments> executeContext)
{
var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken);
if (entity == null)
var arguments = executeContext.Arguments;
var small = RequireVariable(executeContext, arguments.SmallPictureVariable);
var medium = RequireVariable(executeContext, arguments.MediumPictureVariable);
var large = RequireVariable(executeContext, arguments.LargePictureVariable);
var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken)
?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found");
var previous = Read(entity);
Write(
entity,
new PictureNames(
Path.GetFileName(arguments.OriginalPictureKey),
Path.GetFileName(small),
Path.GetFileName(medium),
Path.GetFileName(large)),
succeeded: true);
await Database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId);
await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken);
return executeContext.Completed(new UpdatePicturesLog(
arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large));
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdatePicturesLog> compensateContext)
{
var log = compensateContext.Log;
try
{
var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken);
if (entity == null)
return compensateContext.Compensated();
var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName);
Write(entity, previous, succeeded: false);
await Database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName);
Write(entity, previous, succeeded: false);
await Database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId);
return compensateContext.Failed(exception);
}
}
catch (Exception exception)
protected static PictureNames ReadPictures(EntityPictures? pictures) =>
pictures == null
? throw new InvalidOperationException("The entity has no pending picture upload")
: new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName);
protected static void WritePictures(EntityPictures? pictures, PictureNames names)
{
logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId);
return compensateContext.Failed(exception);
if (pictures == null)
return;
pictures.OriginalName = names.Original ?? pictures.OriginalName;
pictures.SmallName = names.Small;
pictures.MediumName = names.Medium;
pictures.LargeName = names.Large;
}
private static string RequireVariable(ExecuteContext<UpdatePicturesArguments> executeContext, string variableName) =>
executeContext.GetVariable<string>(variableName)
?? throw new InvalidOperationException($"Missing routing slip variable {variableName}");
}
protected static PictureNames ReadPictures(EntityPictures? pictures) =>
pictures == null
? throw new InvalidOperationException("The entity has no pending picture upload")
: new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName);
protected static void WritePictures(EntityPictures? pictures, PictureNames names)
internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger<UpdateTrackPictureActivity> logger)
: UpdatePicturesActivity<Track>(database, publishEndpoint, logger)
{
if (pictures == null)
return;
public const string ExecuteEndpointName = "update-track-picture";
pictures.OriginalName = names.Original ?? pictures.OriginalName;
pictures.SmallName = names.Small;
pictures.MediumName = names.Medium;
pictures.LargeName = names.Large;
protected override Task<Track?> FindAsync(Guid id, CancellationToken cancellationToken) =>
Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken);
protected override PictureNames Read(Track track) =>
new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName);
protected override void Write(Track track, PictureNames names, bool succeeded)
{
track.Pictures.OriginalName = names.Original;
track.Pictures.SmallName = names.Small;
track.Pictures.MediumName = names.Medium;
track.Pictures.LargeName = names.Large;
track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed;
}
protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id);
}
private static string RequireVariable(ExecuteContext<UpdatePicturesArguments> executeContext, string variableName) =>
executeContext.GetVariable<string>(variableName)
?? throw new InvalidOperationException($"Missing routing slip variable {variableName}");
}
internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger<UpdateTrackPictureActivity> logger)
: UpdatePicturesActivity<Track>(database, publishEndpoint, logger)
{
public const string ExecuteEndpointName = "update-track-picture";
protected override Task<Track?> FindAsync(Guid id, CancellationToken cancellationToken) =>
Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken);
protected override PictureNames Read(Track track) =>
new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName);
protected override void Write(Track track, PictureNames names, bool succeeded)
internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger<UpdatePlayListPictureActivity> logger)
: UpdatePicturesActivity<PlayList>(database, publishEndpoint, logger)
{
track.Pictures.OriginalName = names.Original;
track.Pictures.SmallName = names.Small;
track.Pictures.MediumName = names.Medium;
track.Pictures.LargeName = names.Large;
track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed;
public const string ExecuteEndpointName = "update-play-list-picture";
protected override Task<PlayList?> FindAsync(Guid id, CancellationToken cancellationToken) =>
Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken);
protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures);
protected override void Write(PlayList playList, PictureNames names, bool succeeded) =>
WritePictures(playList.Pictures, names);
protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id);
}
protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id);
}
internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger<UpdateAlbumPictureActivity> logger)
: UpdatePicturesActivity<Album>(database, publishEndpoint, logger)
{
public const string ExecuteEndpointName = "update-album-picture";
internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger<UpdatePlayListPictureActivity> logger)
: UpdatePicturesActivity<PlayList>(database, publishEndpoint, logger)
{
public const string ExecuteEndpointName = "update-play-list-picture";
protected override Task<Album?> FindAsync(Guid id, CancellationToken cancellationToken) =>
Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken);
protected override Task<PlayList?> FindAsync(Guid id, CancellationToken cancellationToken) =>
Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken);
protected override PictureNames Read(Album album) => ReadPictures(album.Pictures);
protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures);
protected override void Write(Album album, PictureNames names, bool succeeded) =>
WritePictures(album.Pictures, names);
protected override void Write(PlayList playList, PictureNames names, bool succeeded) =>
WritePictures(playList.Pictures, names);
protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id);
}
internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger<UpdateAlbumPictureActivity> logger)
: UpdatePicturesActivity<Album>(database, publishEndpoint, logger)
{
public const string ExecuteEndpointName = "update-album-picture";
protected override Task<Album?> FindAsync(Guid id, CancellationToken cancellationToken) =>
Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken);
protected override PictureNames Read(Album album) => ReadPictures(album.Pictures);
protected override void Write(Album album, PictureNames names, bool succeeded) =>
WritePictures(album.Pictures, names);
protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id);
protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id);
}
}
@@ -3,29 +3,30 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.PlayLists;
internal class PublishPlayListPictureProcessingEventActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishPlayListPictureProcessingEventActivity> logger)
: IExecuteActivity<PublishPlayListPictureProcessingEventArguments>
namespace Musify.Infrastructure.MassTransit.Activities.PlayLists
{
public const string ExecuteEndpointName = "publish-play-list-picture-processing-event";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishPlayListPictureProcessingEventArguments> executeContext)
internal class PublishPlayListPictureProcessingEventActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishPlayListPictureProcessingEventActivity> logger)
: IExecuteActivity<PublishPlayListPictureProcessingEventArguments>
{
var arguments = executeContext.Arguments;
public const string ExecuteEndpointName = "publish-play-list-picture-processing-event";
await publishEndpoint.Publish(
new UpdatePlayListPictureEvent(
arguments.PlayListId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
public async Task<ExecutionResult> Execute(ExecuteContext<PublishPlayListPictureProcessingEventArguments> executeContext)
{
var arguments = executeContext.Arguments;
logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId);
await publishEndpoint.Publish(
new UpdatePlayListPictureEvent(
arguments.PlayListId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
return executeContext.Completed();
logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId);
return executeContext.Completed();
}
}
}
@@ -3,38 +3,39 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Tracks;
internal class PublishTrackProcessingEventsActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishTrackProcessingEventsActivity> logger)
: IExecuteActivity<PublishTrackProcessingEventsArguments>
namespace Musify.Infrastructure.MassTransit.Activities.Tracks
{
public const string ExecuteEndpointName = "publish-track-processing-events";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishTrackProcessingEventsArguments> executeContext)
internal class PublishTrackProcessingEventsActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishTrackProcessingEventsActivity> logger)
: IExecuteActivity<PublishTrackProcessingEventsArguments>
{
var arguments = executeContext.Arguments;
public const string ExecuteEndpointName = "publish-track-processing-events";
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
public async Task<ExecutionResult> Execute(ExecuteContext<PublishTrackProcessingEventsArguments> executeContext)
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdateTrackAudioEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalAudioKey,
arguments.Bucket,
arguments.AudioDestinationFolderKey),
executeContext.CancellationToken);
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId);
await publishEndpoint.Publish(
new UpdateTrackAudioEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalAudioKey,
arguments.Bucket,
arguments.AudioDestinationFolderKey),
executeContext.CancellationToken);
return executeContext.Completed();
logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId);
return executeContext.Completed();
}
}
}
@@ -6,56 +6,57 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents;
internal class ConsumeUploadIntentsActivity(
IDatabase database,
ILogger<ConsumeUploadIntentsActivity> logger)
: IActivity<ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>
namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents
{
public const string ExecuteEndpointName = "consume-upload-intents";
public async Task<ExecutionResult> Execute(ExecuteContext<ConsumeUploadIntentsArguments> executeContext)
internal class ConsumeUploadIntentsActivity(
IDatabase database,
ILogger<ConsumeUploadIntentsActivity> logger)
: IActivity<ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>
{
var intentIds = executeContext.Arguments.IntentIds;
public const string ExecuteEndpointName = "consume-upload-intents";
var intents = await database.UploadIntents
.Where(i => intentIds.Contains(i.Id))
.ToListAsync(executeContext.CancellationToken);
foreach (var intent in intents)
intent.Status = UploadIntentStatus.Consumed;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count);
return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds));
}
public async Task<CompensationResult> Compensate(CompensateContext<ConsumeUploadIntentsLog> compensateContext)
{
try
public async Task<ExecutionResult> Execute(ExecuteContext<ConsumeUploadIntentsArguments> executeContext)
{
var intentIds = compensateContext.Log.IntentIds;
var intentIds = executeContext.Arguments.IntentIds;
var intents = await database.UploadIntents
.Where(i => intentIds.Contains(i.Id))
.ToListAsync(compensateContext.CancellationToken);
.ToListAsync(executeContext.CancellationToken);
foreach (var intent in intents)
intent.Status = UploadIntentStatus.Issued;
intent.Status = UploadIntentStatus.Consumed;
await database.SaveChangesAsync(compensateContext.CancellationToken);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count);
logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count);
return compensateContext.Compensated();
return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds));
}
catch (Exception exception)
public async Task<CompensationResult> Compensate(CompensateContext<ConsumeUploadIntentsLog> compensateContext)
{
logger.LogError(exception, "Failed to compensate upload intent consumption");
return compensateContext.Failed(exception);
try
{
var intentIds = compensateContext.Log.IntentIds;
var intents = await database.UploadIntents
.Where(i => intentIds.Contains(i.Id))
.ToListAsync(compensateContext.CancellationToken);
foreach (var intent in intents)
intent.Status = UploadIntentStatus.Issued;
await database.SaveChangesAsync(compensateContext.CancellationToken);
logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate upload intent consumption");
return compensateContext.Failed(exception);
}
}
}
}
@@ -1,4 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record ConsumeUploadIntentsArguments(
Guid[] IntentIds);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record ConsumeUploadIntentsArguments(
Guid[] IntentIds);
}
@@ -1,7 +1,8 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record CopyFileInBucketArguments(
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationKey);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record CopyFileInBucketArguments(
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationKey);
}
@@ -1,3 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
internal record DeleteEntityArguments(Guid Id);
namespace Musify.Infrastructure.MassTransit.Arguments
{
internal record DeleteEntityArguments(Guid Id);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record DownloadFileFromBucketArguments(
string Bucket,
string Key,
string DestinationFilePathVariable);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record DownloadFileFromBucketArguments(
string Bucket,
string Key,
string DestinationFilePathVariable);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record GenerateAudioWorkflowPathsArguments(
Guid TrackId,
string TemporaryRootDirectory,
string SourceKey);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record GenerateAudioWorkflowPathsArguments(
Guid TrackId,
string TemporaryRootDirectory,
string SourceKey);
}
@@ -1,5 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record GeneratePictureWorkflowPathsArguments(
string TemporaryRootDirectory,
string SourceKey);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record GeneratePictureWorkflowPathsArguments(
string TemporaryRootDirectory,
string SourceKey);
}
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects;
namespace Musify.Infrastructure.MassTransit.Arguments;
internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status);
namespace Musify.Infrastructure.MassTransit.Arguments
{
internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments;
public record PublishAlbumPictureProcessingEventArguments(
Guid AlbumId,
string Bucket,
string FinalPictureKey,
ImageSizes Sizes);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishAlbumPictureProcessingEventArguments(
Guid AlbumId,
string Bucket,
string FinalPictureKey,
ImageSizes Sizes);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments;
public record PublishPlayListPictureProcessingEventArguments(
Guid PlayListId,
string Bucket,
string FinalPictureKey,
ImageSizes Sizes);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishPlayListPictureProcessingEventArguments(
Guid PlayListId,
string Bucket,
string FinalPictureKey,
ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments;
public record PublishTrackProcessingEventsArguments(
Guid TrackId,
string Bucket,
string FinalPictureKey,
string FinalAudioKey,
string AudioDestinationFolderKey,
ImageSizes Sizes);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishTrackProcessingEventsArguments(
Guid TrackId,
string Bucket,
string FinalPictureKey,
string FinalAudioKey,
string AudioDestinationFolderKey,
ImageSizes Sizes);
}
@@ -1,5 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record RemoveFileFromBucketArguments(
string Bucket,
string Key);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record RemoveFileFromBucketArguments(
string Bucket,
string Key);
}
@@ -1,7 +1,8 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record ResizePictureLocalArguments(
string SourceFilePathVariable,
string DestinationFilePathVariable,
int Width,
int Height);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record ResizePictureLocalArguments(
string SourceFilePathVariable,
string DestinationFilePathVariable,
int Width,
int Height);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record TranscodeAudioArguments(
Guid TrackId,
string SourceFilePathVariable,
string WorkingDirectoryVariable);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record TranscodeAudioArguments(
Guid TrackId,
string SourceFilePathVariable,
string WorkingDirectoryVariable);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record TransferFilesToBucketArguments(
string DestinationBucket,
string SourceDirectoryVariable,
string DestinationKey);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record TransferFilesToBucketArguments(
string DestinationBucket,
string SourceDirectoryVariable,
string DestinationKey);
}
@@ -1,8 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record UpdatePicturesArguments(
Guid SubjectId,
string OriginalPictureKey,
string SmallPictureVariable,
string MediumPictureVariable,
string LargePictureVariable);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdatePicturesArguments(
Guid SubjectId,
string OriginalPictureKey,
string SmallPictureVariable,
string MediumPictureVariable,
string LargePictureVariable);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record UpdateTrackAudioArguments(
Guid TrackId,
string AudioFolderKey,
string DurationSecondsVariable);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdateTrackAudioArguments(
Guid TrackId,
string AudioFolderKey,
string DurationSecondsVariable);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record UploadFileToBucketArguments(
string FilePathVariable,
string DestinationBucket,
string DestinationRoute);
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UploadFileToBucketArguments(
string FilePathVariable,
string DestinationBucket,
string DestinationRoute);
}
@@ -3,16 +3,17 @@ using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.LifeCycle;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer<AlbumProcessingFailed>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<AlbumProcessingFailed> context)
public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer<AlbumProcessingFailed>
{
var message = context.Message;
public Task Consume(ConsumeContext<AlbumProcessingFailed> context)
{
var message = context.Message;
return ProcessingFailedCleanup.ExecuteAsync(
bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket,
(ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey));
return ProcessingFailedCleanup.ExecuteAsync(
bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket,
(ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey));
}
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreateAlbumResourcesEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<CreateAlbumResourcesEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreateAlbumResourcesEvent>
{
public Task Consume(ConsumeContext<CreateAlbumResourcesEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreatePlayListResourcesEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<CreatePlayListResourcesEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreatePlayListResourcesEvent>
{
public Task Consume(ConsumeContext<CreatePlayListResourcesEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreateTrackResourcesEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<CreateTrackResourcesEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreateTrackResourcesEvent>
{
public Task Consume(ConsumeContext<CreateTrackResourcesEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,17 +2,18 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeleteAlbumEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public async Task Consume(ConsumeContext<DeleteAlbumEvent> context)
public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeleteAlbumEvent>
{
var message = context.Message;
var routingSlip = await routingSlipBuilder.BuildAlbumAsync(
message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken);
public async Task Consume(ConsumeContext<DeleteAlbumEvent> context)
{
var message = context.Message;
var routingSlip = await routingSlipBuilder.BuildAlbumAsync(
message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken);
await bus.Execute(routingSlip.Build());
await bus.Execute(routingSlip.Build());
}
}
}
@@ -2,17 +2,18 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeletePlayListEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public async Task Consume(ConsumeContext<DeletePlayListEvent> context)
public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeletePlayListEvent>
{
var message = context.Message;
var routingSlip = await routingSlipBuilder.BuildPlayListAsync(
message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken);
public async Task Consume(ConsumeContext<DeletePlayListEvent> context)
{
var message = context.Message;
var routingSlip = await routingSlipBuilder.BuildPlayListAsync(
message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken);
await bus.Execute(routingSlip.Build());
await bus.Execute(routingSlip.Build());
}
}
}
@@ -2,17 +2,18 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeleteTrackEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public async Task Consume(ConsumeContext<DeleteTrackEvent> context)
public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeleteTrackEvent>
{
var message = context.Message;
var routingSlip = await routingSlipBuilder.BuildTrackAsync(
message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken);
public async Task Consume(ConsumeContext<DeleteTrackEvent> context)
{
var message = context.Message;
var routingSlip = await routingSlipBuilder.BuildTrackAsync(
message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken);
await bus.Execute(routingSlip.Build());
await bus.Execute(routingSlip.Build());
}
}
}
@@ -3,16 +3,17 @@ using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.LifeCycle;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer<PlayListProcessingFailed>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<PlayListProcessingFailed> context)
public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer<PlayListProcessingFailed>
{
var message = context.Message;
public Task Consume(ConsumeContext<PlayListProcessingFailed> context)
{
var message = context.Message;
return ProcessingFailedCleanup.ExecuteAsync(
bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket,
(ActivityNames.RemovePlayListOriginalPicture, message.PictureKey));
return ProcessingFailedCleanup.ExecuteAsync(
bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket,
(ActivityNames.RemovePlayListOriginalPicture, message.PictureKey));
}
}
}
@@ -3,32 +3,33 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers;
internal static class ProcessingFailedCleanup
namespace Musify.Infrastructure.MassTransit.Consumers
{
/// <summary>Removes whatever files were left behind and marks the entity as failed.</summary>
public static Task ExecuteAsync(
IBus bus,
Guid subjectId,
string markStepName,
string markEndpointName,
string? bucket,
params (string StepName, string? Key)[] files)
internal static class ProcessingFailedCleanup
{
var builder = RoutingSlips.Create(correlationId: null);
if (!string.IsNullOrWhiteSpace(bucket))
/// <summary>Removes whatever files were left behind and marks the entity as failed.</summary>
public static Task ExecuteAsync(
IBus bus,
Guid subjectId,
string markStepName,
string markEndpointName,
string? bucket,
params (string StepName, string? Key)[] files)
{
foreach (var (stepName, key) in files)
var builder = RoutingSlips.Create(correlationId: null);
if (!string.IsNullOrWhiteSpace(bucket))
{
if (!string.IsNullOrWhiteSpace(key))
builder.AddRemoveFile(stepName, bucket, key);
foreach (var (stepName, key) in files)
{
if (!string.IsNullOrWhiteSpace(key))
builder.AddRemoveFile(stepName, bucket, key);
}
}
builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed));
return bus.Execute(builder.Build());
}
builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed));
return bus.Execute(builder.Build());
}
}
@@ -3,58 +3,59 @@ using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class ProcessingSlipFaultConsumer : IConsumer<RoutingSlipFaulted>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public const string QueueName = "processing-slip-fault";
public async Task Consume(ConsumeContext<RoutingSlipFaulted> context)
public class ProcessingSlipFaultConsumer : IConsumer<RoutingSlipFaulted>
{
var variables = context.Message.Variables;
public const string QueueName = "processing-slip-fault";
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject))
return;
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject)
|| !Guid.TryParse(subjectObject?.ToString(), out var subjectId))
public async Task Consume(ConsumeContext<RoutingSlipFaulted> context)
{
return;
var variables = context.Message.Variables;
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject))
return;
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject)
|| !Guid.TryParse(subjectObject?.ToString(), out var subjectId))
{
return;
}
switch (kindObject?.ToString())
{
case RoutingSlipVariableNames.ProcessKinds.TrackPicture:
await context.Publish(new TrackPictureProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.TrackAudio:
await context.Publish(new TrackAudioProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.PlayListPicture:
await context.Publish(new PlayListPictureProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.AlbumPicture:
await context.Publish(new AlbumPictureProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.TrackCreation:
await context.Publish(new TrackProcessingFailed(
subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket),
Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey),
Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey)));
break;
case RoutingSlipVariableNames.ProcessKinds.AlbumCreation:
await context.Publish(new AlbumProcessingFailed(
subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket),
Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey)));
break;
case RoutingSlipVariableNames.ProcessKinds.PlayListCreation:
await context.Publish(new PlayListProcessingFailed(
subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket),
Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey)));
break;
}
}
switch (kindObject?.ToString())
{
case RoutingSlipVariableNames.ProcessKinds.TrackPicture:
await context.Publish(new TrackPictureProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.TrackAudio:
await context.Publish(new TrackAudioProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.PlayListPicture:
await context.Publish(new PlayListPictureProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.AlbumPicture:
await context.Publish(new AlbumPictureProcessingFailed(subjectId));
break;
case RoutingSlipVariableNames.ProcessKinds.TrackCreation:
await context.Publish(new TrackProcessingFailed(
subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket),
Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey),
Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey)));
break;
case RoutingSlipVariableNames.ProcessKinds.AlbumCreation:
await context.Publish(new AlbumProcessingFailed(
subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket),
Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey)));
break;
case RoutingSlipVariableNames.ProcessKinds.PlayListCreation:
await context.Publish(new PlayListProcessingFailed(
subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket),
Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey)));
break;
}
private static string? Variable(IDictionary<string, object> variables, string name) =>
variables.TryGetValue(name, out var value) ? value?.ToString() : null;
}
private static string? Variable(IDictionary<string, object> variables, string name) =>
variables.TryGetValue(name, out var value) ? value?.ToString() : null;
}
@@ -3,41 +3,42 @@ using MassTransit.Courier.Contracts;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class RoutingSlipCleanUpConsumer(
ILogger<RoutingSlipCleanUpConsumer> logger) :
IConsumer<RoutingSlipCompleted>,
IConsumer<RoutingSlipFaulted>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public const string QueueName = "routing-slip-clean-up";
public Task Consume(ConsumeContext<RoutingSlipCompleted> context)
=> DeleteFolder(context.Message.Variables);
public Task Consume(ConsumeContext<RoutingSlipFaulted> context)
=> DeleteFolder(context.Message.Variables);
private Task DeleteFolder(IDictionary<string, object> variables)
public class RoutingSlipCleanUpConsumer(
ILogger<RoutingSlipCleanUpConsumer> logger) :
IConsumer<RoutingSlipCompleted>,
IConsumer<RoutingSlipFaulted>
{
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject))
return Task.CompletedTask;
public const string QueueName = "routing-slip-clean-up";
var pathString = pathObject.ToString();
public Task Consume(ConsumeContext<RoutingSlipCompleted> context)
=> DeleteFolder(context.Message.Variables);
if (string.IsNullOrEmpty(pathString))
return Task.CompletedTask;
public Task Consume(ConsumeContext<RoutingSlipFaulted> context)
=> DeleteFolder(context.Message.Variables);
try
private Task DeleteFolder(IDictionary<string, object> variables)
{
Directory.Delete(pathString, recursive: true);
logger.LogInformation("Deleted temporal directory at path {Path}", pathString);
}
catch (DirectoryNotFoundException)
{
logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString);
}
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject))
return Task.CompletedTask;
return Task.CompletedTask;
var pathString = pathObject.ToString();
if (string.IsNullOrEmpty(pathString))
return Task.CompletedTask;
try
{
Directory.Delete(pathString, recursive: true);
logger.LogInformation("Deleted temporal directory at path {Path}", pathString);
}
catch (DirectoryNotFoundException)
{
logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString);
}
return Task.CompletedTask;
}
}
}
@@ -3,17 +3,18 @@ using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.LifeCycle;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class TrackProcessingFailedConsumer(IBus bus) : IConsumer<TrackProcessingFailed>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<TrackProcessingFailed> context)
public class TrackProcessingFailedConsumer(IBus bus) : IConsumer<TrackProcessingFailed>
{
var message = context.Message;
public Task Consume(ConsumeContext<TrackProcessingFailed> context)
{
var message = context.Message;
return ProcessingFailedCleanup.ExecuteAsync(
bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket,
(ActivityNames.RemoveTrackOriginalPicture, message.PictureKey),
(ActivityNames.RemoveTrackOriginalAudio, message.AudioKey));
return ProcessingFailedCleanup.ExecuteAsync(
bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket,
(ActivityNames.RemoveTrackOriginalPicture, message.PictureKey),
(ActivityNames.RemoveTrackOriginalAudio, message.AudioKey));
}
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<UpdateAlbumPictureEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureEvent>
{
public Task Consume(ConsumeContext<UpdateAlbumPictureEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureSourceEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<UpdateAlbumPictureSourceEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureSourceEvent>
{
public Task Consume(ConsumeContext<UpdateAlbumPictureSourceEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdatePlayListPictureEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<UpdatePlayListPictureEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdatePlayListPictureEvent>
{
public Task Consume(ConsumeContext<UpdatePlayListPictureEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdatePlayListPictureSourceEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<UpdatePlayListPictureSourceEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdatePlayListPictureSourceEvent>
{
public Task Consume(ConsumeContext<UpdatePlayListPictureSourceEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateTrackAudioEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<UpdateTrackAudioEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateTrackAudioEvent>
{
public Task Consume(ConsumeContext<UpdateTrackAudioEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -2,11 +2,12 @@ using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers;
public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateTrackPictureEvent>
namespace Musify.Infrastructure.MassTransit.Consumers
{
public Task Consume(ConsumeContext<UpdateTrackPictureEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateTrackPictureEvent>
{
public Task Consume(ConsumeContext<UpdateTrackPictureEvent> context) =>
bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build());
}
}
@@ -13,62 +13,63 @@ using Musify.Infrastructure.MassTransit.Consumers;
using Musify.Infrastructure.MassTransit.Logs;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit;
public static partial class MassTransitDependencyInjection
namespace Musify.Infrastructure.MassTransit
{
private static void RegisterRoutingSlipBuilders(IServiceCollection services)
public static partial class MassTransitDependencyInjection
{
services.AddScoped<AudioWorkflowRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<DeleteRoutingSlipBuilder>();
services.AddScoped<PictureSourceRoutingSlipBuilder>();
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
}
private static void RegisterRoutingSlipBuilders(IServiceCollection services)
{
services.AddScoped<AudioWorkflowRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<DeleteRoutingSlipBuilder>();
services.AddScoped<PictureSourceRoutingSlipBuilder>();
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
}
private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options)
{
options.AddConsumer<AlbumProcessingFailedConsumer>();
options.AddConsumer<CreateAlbumConsumer>();
options.AddConsumer<CreatePlayListConsumer>();
options.AddConsumer<CreateTrackConsumer>();
options.AddConsumer<DeleteAlbumConsumer>();
options.AddConsumer<DeletePlayListConsumer>();
options.AddConsumer<DeleteTrackConsumer>();
options.AddConsumer<PlayListProcessingFailedConsumer>();
options.AddConsumer<ProcessingSlipFaultConsumer>();
options.AddConsumer<RoutingSlipCleanUpConsumer>();
options.AddConsumer<TrackProcessingFailedConsumer>();
options.AddConsumer<UpdateAlbumPictureConsumer>();
options.AddConsumer<UpdateAlbumPictureSourceConsumer>();
options.AddConsumer<UpdatePlayListPictureConsumer>();
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
options.AddConsumer<UpdateTrackAudioConsumer>();
options.AddConsumer<UpdateTrackPictureConsumer>();
private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options)
{
options.AddConsumer<AlbumProcessingFailedConsumer>();
options.AddConsumer<CreateAlbumConsumer>();
options.AddConsumer<CreatePlayListConsumer>();
options.AddConsumer<CreateTrackConsumer>();
options.AddConsumer<DeleteAlbumConsumer>();
options.AddConsumer<DeletePlayListConsumer>();
options.AddConsumer<DeleteTrackConsumer>();
options.AddConsumer<PlayListProcessingFailedConsumer>();
options.AddConsumer<ProcessingSlipFaultConsumer>();
options.AddConsumer<RoutingSlipCleanUpConsumer>();
options.AddConsumer<TrackProcessingFailedConsumer>();
options.AddConsumer<UpdateAlbumPictureConsumer>();
options.AddConsumer<UpdateAlbumPictureSourceConsumer>();
options.AddConsumer<UpdatePlayListPictureConsumer>();
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
options.AddConsumer<UpdateTrackAudioConsumer>();
options.AddConsumer<UpdateTrackPictureConsumer>();
options.AddExecuteActivity<DeleteAlbumActivity, DeleteEntityArguments>();
options.AddExecuteActivity<DeletePlayListActivity, DeleteEntityArguments>();
options.AddExecuteActivity<DeleteTrackActivity, DeleteEntityArguments>();
options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>();
options.AddExecuteActivity<GeneratePictureWorkflowPathsActivity, GeneratePictureWorkflowPathsArguments>();
options.AddExecuteActivity<MarkAlbumLifeCycleActivity, MarkLifeCycleArguments>();
options.AddExecuteActivity<MarkPlayListLifeCycleActivity, MarkLifeCycleArguments>();
options.AddExecuteActivity<MarkTrackLifeCycleActivity, MarkLifeCycleArguments>();
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
options.AddExecuteActivity<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>();
options.AddExecuteActivity<PublishTrackProcessingEventsActivity, PublishTrackProcessingEventsArguments>();
options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>();
options.AddExecuteActivity<DeleteAlbumActivity, DeleteEntityArguments>();
options.AddExecuteActivity<DeletePlayListActivity, DeleteEntityArguments>();
options.AddExecuteActivity<DeleteTrackActivity, DeleteEntityArguments>();
options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>();
options.AddExecuteActivity<GeneratePictureWorkflowPathsActivity, GeneratePictureWorkflowPathsArguments>();
options.AddExecuteActivity<MarkAlbumLifeCycleActivity, MarkLifeCycleArguments>();
options.AddExecuteActivity<MarkPlayListLifeCycleActivity, MarkLifeCycleArguments>();
options.AddExecuteActivity<MarkTrackLifeCycleActivity, MarkLifeCycleArguments>();
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
options.AddExecuteActivity<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>();
options.AddExecuteActivity<PublishTrackProcessingEventsActivity, PublishTrackProcessingEventsArguments>();
options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>();
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
options.AddActivity<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>();
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
options.AddActivity<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>();
options.AddActivity<TransferFilesToBucketActivity, TransferFilesToBucketArguments, TransferFilesToBucketLog>();
options.AddActivity<UpdateAlbumPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
options.AddActivity<UpdatePlayListPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
options.AddActivity<UpdateTrackAudioActivity, UpdateTrackAudioArguments, UpdateTrackAudioLog>();
options.AddActivity<UpdateTrackPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
options.AddActivity<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>();
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
options.AddActivity<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>();
options.AddActivity<TransferFilesToBucketActivity, TransferFilesToBucketArguments, TransferFilesToBucketLog>();
options.AddActivity<UpdateAlbumPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
options.AddActivity<UpdatePlayListPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
options.AddActivity<UpdateTrackAudioActivity, UpdateTrackAudioArguments, UpdateTrackAudioLog>();
options.AddActivity<UpdateTrackPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
}
}
}
@@ -7,107 +7,108 @@ using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Sagas;
using Musify.Infrastructure.Persistence;
namespace Musify.Infrastructure.MassTransit;
public static partial class MassTransitDependencyInjection
namespace Musify.Infrastructure.MassTransit
{
public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration)
public static partial class MassTransitDependencyInjection
{
services.AddValidatedOptions<MassTransitConfiguration>(configuration);
services.AddScoped<IEventBus, MassTransitEventBus>();
services.AddMassTransit(options =>
public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration)
{
AddOutbox(options);
services.AddValidatedOptions<MassTransitConfiguration>(configuration);
services.AddScoped<IEventBus, MassTransitEventBus>();
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MassTransitConfiguration>()));
});
return services;
}
public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<MassTransitConfiguration>(configuration);
services.AddValidatedOptions<WorkerConfiguration>(configuration);
services.AddScoped<IEventBus, MassTransitEventBus>();
RegisterRoutingSlipBuilders(services);
services.AddMassTransit(options =>
{
AddOutbox(options);
options.SetKebabCaseEndpointNameFormatter();
RegisterConsumersAndActivities(options);
options.AddSagaStateMachine<TrackProcessingStateMachine, TrackProcessingState>()
.EntityFrameworkRepository(ConfigureSagaRepository<TrackProcessingState>);
options.AddSagaStateMachine<PlayListProcessingStateMachine, PlayListProcessingState>()
.EntityFrameworkRepository(ConfigureSagaRepository<PlayListProcessingState>);
options.AddSagaStateMachine<AlbumProcessingStateMachine, AlbumProcessingState>()
.EntityFrameworkRepository(ConfigureSagaRepository<AlbumProcessingState>);
options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) =>
services.AddMassTransit(options =>
{
UseStandardRetry(endpointConfigurator);
endpointConfigurator.UseEntityFrameworkOutbox<Database>(registrationContext);
AddOutbox(options);
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MassTransitConfiguration>()));
});
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
return services;
}
public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<MassTransitConfiguration>(configuration);
services.AddValidatedOptions<WorkerConfiguration>(configuration);
services.AddScoped<IEventBus, MassTransitEventBus>();
RegisterRoutingSlipBuilders(services);
services.AddMassTransit(options =>
{
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MassTransitConfiguration>());
AddOutbox(options);
busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext);
options.SetKebabCaseEndpointNameFormatter();
RegisterConsumersAndActivities(options);
options.AddSagaStateMachine<TrackProcessingStateMachine, TrackProcessingState>()
.EntityFrameworkRepository(ConfigureSagaRepository<TrackProcessingState>);
options.AddSagaStateMachine<PlayListProcessingStateMachine, PlayListProcessingState>()
.EntityFrameworkRepository(ConfigureSagaRepository<PlayListProcessingState>);
options.AddSagaStateMachine<AlbumProcessingStateMachine, AlbumProcessingState>()
.EntityFrameworkRepository(ConfigureSagaRepository<AlbumProcessingState>);
options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) =>
{
UseStandardRetry(endpointConfigurator);
endpointConfigurator.UseEntityFrameworkOutbox<Database>(registrationContext);
});
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
{
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MassTransitConfiguration>());
busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext);
});
});
});
return services;
}
return services;
}
private static void AddOutbox(IBusRegistrationConfigurator options) =>
options.AddEntityFrameworkOutbox<Database>(outbox =>
private static void AddOutbox(IBusRegistrationConfigurator options) =>
options.AddEntityFrameworkOutbox<Database>(outbox =>
{
outbox.UsePostgres();
outbox.UseBusOutbox();
outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
});
private static void ConfigureSagaRepository<TSaga>(IEntityFrameworkSagaRepositoryConfigurator<TSaga> repository)
where TSaga : class, ISaga
{
outbox.UsePostgres();
outbox.UseBusOutbox();
repository.ConcurrencyMode = ConcurrencyMode.Pessimistic;
repository.ExistingDbContext<Database>();
repository.UsePostgres();
}
outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
});
private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) =>
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
private static void ConfigureSagaRepository<TSaga>(IEntityFrameworkSagaRepositoryConfigurator<TSaga> repository)
where TSaga : class, ISaga
{
repository.ConcurrencyMode = ConcurrencyMode.Pessimistic;
repository.ExistingDbContext<Database>();
repository.UsePostgres();
}
private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) =>
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
private static void ConfigureRabbitMqHost(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
MassTransitConfiguration massTransitConfiguration)
{
busFactoryConfigurator.Host(massTransitConfiguration.Address, options =>
private static void ConfigureRabbitMqHost(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
MassTransitConfiguration massTransitConfiguration)
{
options.Username(massTransitConfiguration.Username);
options.Password(massTransitConfiguration.Password);
});
busFactoryConfigurator.Host(massTransitConfiguration.Address, options =>
{
options.Username(massTransitConfiguration.Username);
options.Password(massTransitConfiguration.Password);
});
}
}
}
@@ -1,4 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record ConsumeUploadIntentsLog(
Guid[] IntentIds);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record ConsumeUploadIntentsLog(
Guid[] IntentIds);
}
@@ -1,5 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record CopyFileInBucketLog(
string DestinationBucket,
string DestinationKey);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record CopyFileInBucketLog(
string DestinationBucket,
string DestinationKey);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record DownloadFileFromBucketLog(
string Bucket,
string Key,
string DestinationFilePath);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record DownloadFileFromBucketLog(
string Bucket,
string Key,
string DestinationFilePath);
}
@@ -1,3 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record GeneratePictureWorkflowPathsLog(string WorkingDirectory);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record GeneratePictureWorkflowPathsLog(string WorkingDirectory);
}
@@ -1,3 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record ResizePictureLog(string DestinationFilePath);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record ResizePictureLog(string DestinationFilePath);
}
@@ -1,3 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record TranscodeAudioLog(string WorkingDirectory);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record TranscodeAudioLog(string WorkingDirectory);
}
@@ -1,3 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys);
}
@@ -1,8 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record UpdatePicturesLog(
Guid SubjectId,
string? PreviousOriginalName,
string? PreviousSmallName,
string? PreviousMediumName,
string? PreviousLargeName);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdatePicturesLog(
Guid SubjectId,
string? PreviousOriginalName,
string? PreviousSmallName,
string? PreviousMediumName,
string? PreviousLargeName);
}
@@ -1,6 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record UpdateTrackAudioLog(
Guid TrackId,
string? PreviousAudioFolderName,
double PreviousDurationSeconds);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdateTrackAudioLog(
Guid TrackId,
string? PreviousAudioFolderName,
double PreviousDurationSeconds);
}
@@ -1,3 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey);
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey);
}
@@ -1,11 +1,12 @@
using MassTransit;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit;
public class MassTransitEventBus(IPublishEndpoint publishEndpoint)
: IEventBus
namespace Musify.Infrastructure.MassTransit
{
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class =>
publishEndpoint.Publish(message, cancellationToken);
public class MassTransitEventBus(IPublishEndpoint publishEndpoint)
: IEventBus
{
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class =>
publishEndpoint.Publish(message, cancellationToken);
}
}
@@ -1,62 +1,63 @@
namespace Musify.Infrastructure.MassTransit.RoutingSlip;
internal static class ActivityNames
namespace Musify.Infrastructure.MassTransit.RoutingSlip
{
internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving";
internal const string MarkTrackAsFailed = "MarkTrackAsFailed";
internal const string DeleteTrackFromDb = "DeleteTrackFromDb";
internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving";
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving";
internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb";
internal static class ActivityNames
{
internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving";
internal const string MarkTrackAsFailed = "MarkTrackAsFailed";
internal const string DeleteTrackFromDb = "DeleteTrackFromDb";
internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving";
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving";
internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb";
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
internal const string DownloadFile = "DownloadFile";
internal const string DownloadFile = "DownloadFile";
internal const string TranscodeAudio = "TranscodeAudio";
internal const string TranscodeAudio = "TranscodeAudio";
internal const string ResizeSmall = "ResizeSmall";
internal const string ResizeMedium = "ResizeMedium";
internal const string ResizeLarge = "ResizeLarge";
internal const string ResizeSmall = "ResizeSmall";
internal const string ResizeMedium = "ResizeMedium";
internal const string ResizeLarge = "ResizeLarge";
internal const string UploadSmall = "UploadSmall";
internal const string UploadMedium = "UploadMedium";
internal const string UploadLarge = "UploadLarge";
internal const string UploadSmall = "UploadSmall";
internal const string UploadMedium = "UploadMedium";
internal const string UploadLarge = "UploadLarge";
internal const string TransferFiles = "TransferFiles";
internal const string TransferFiles = "TransferFiles";
internal const string UpdateTrackPicture = "UpdateTrackPicture";
internal const string UpdatePlayListPicture = "UpdatePlayListPicture";
internal const string UpdateAlbumPicture = "UpdateAlbumPicture";
internal const string UpdateTrackAudio = "UpdateTrackAudio";
internal const string UpdateTrackPicture = "UpdateTrackPicture";
internal const string UpdatePlayListPicture = "UpdatePlayListPicture";
internal const string UpdateAlbumPicture = "UpdateAlbumPicture";
internal const string UpdateTrackAudio = "UpdateTrackAudio";
internal const string CopyPictureToFinal = "CopyPictureToFinal";
internal const string CopyAudioToFinal = "CopyAudioToFinal";
internal const string CopyPictureToFinal = "CopyPictureToFinal";
internal const string CopyAudioToFinal = "CopyAudioToFinal";
internal const string ConsumeUploadIntents = "ConsumeUploadIntents";
internal const string ConsumeUploadIntents = "ConsumeUploadIntents";
internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents";
internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent";
internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent";
internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents";
internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent";
internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent";
internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture";
internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture";
internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture";
internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture";
internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio";
internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio";
internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture";
internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture";
internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture";
internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture";
internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio";
internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio";
internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture";
internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture";
internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture";
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture";
internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture";
internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture";
internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture";
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture";
internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture";
internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture";
internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture";
internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture";
internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture";
internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture";
internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture";
internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture";
}
}
@@ -5,40 +5,41 @@ using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) =>
RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.GenerateAudioWorkflowPaths,
GenerateAudioWorkflowPathsActivity.ExecuteEndpointName,
new GenerateAudioWorkflowPathsArguments(
message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey))
.AddStep(
ActivityNames.DownloadFile,
DownloadFileFromBucketActivity.ExecuteEndpointName,
new DownloadFileFromBucketArguments(
message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath))
.AddStep(
ActivityNames.TranscodeAudio,
TranscodeAudioActivity.ExecuteEndpointName,
new TranscodeAudioArguments(
message.TrackId,
RoutingSlipVariableNames.Audio.SourceFilePath,
RoutingSlipVariableNames.Workflow.TemporalDirectory))
.AddStep(
ActivityNames.TransferFiles,
TransferFilesToBucketActivity.ExecuteEndpointName,
new TransferFilesToBucketArguments(
message.DestinationBucket,
RoutingSlipVariableNames.Audio.TranscodedDirectory,
message.DestinationFolderKey))
.AddStep(
ActivityNames.UpdateTrackAudio,
UpdateTrackAudioActivity.ExecuteEndpointName,
new UpdateTrackAudioArguments(
message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio);
public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
{
public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) =>
RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.GenerateAudioWorkflowPaths,
GenerateAudioWorkflowPathsActivity.ExecuteEndpointName,
new GenerateAudioWorkflowPathsArguments(
message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey))
.AddStep(
ActivityNames.DownloadFile,
DownloadFileFromBucketActivity.ExecuteEndpointName,
new DownloadFileFromBucketArguments(
message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath))
.AddStep(
ActivityNames.TranscodeAudio,
TranscodeAudioActivity.ExecuteEndpointName,
new TranscodeAudioArguments(
message.TrackId,
RoutingSlipVariableNames.Audio.SourceFilePath,
RoutingSlipVariableNames.Workflow.TemporalDirectory))
.AddStep(
ActivityNames.TransferFiles,
TransferFilesToBucketActivity.ExecuteEndpointName,
new TransferFilesToBucketArguments(
message.DestinationBucket,
RoutingSlipVariableNames.Audio.TranscodedDirectory,
message.DestinationFolderKey))
.AddStep(
ActivityNames.UpdateTrackAudio,
UpdateTrackAudioActivity.ExecuteEndpointName,
new UpdateTrackAudioArguments(
message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio);
}
}
@@ -5,41 +5,42 @@ using Musify.Infrastructure.MassTransit.Activities.Tracks;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
public class CreateTrackRoutingSlipBuilder
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
public class CreateTrackRoutingSlipBuilder
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey))
.AddStep(
ActivityNames.CopyAudioToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey))
.AddStep(
ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId]))
.AddStep(
ActivityNames.PublishTrackProcessingEvents,
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
new PublishTrackProcessingEventsArguments(
message.TrackId,
message.Bucket,
message.PictureDestinationKey,
message.AudioDestinationKey,
message.AudioProcessedFolderKey,
message.Sizes))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey))
.AddStep(
ActivityNames.CopyAudioToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey))
.AddStep(
ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId]))
.AddStep(
ActivityNames.PublishTrackProcessingEvents,
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
new PublishTrackProcessingEventsArguments(
message.TrackId,
message.Bucket,
message.PictureDestinationKey,
message.AudioDestinationKey,
message.AudioProcessedFolderKey,
message.Sizes))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket);
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey);
builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey);
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket);
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey);
builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey);
return builder;
return builder;
}
}
}
@@ -6,130 +6,131 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Activities.LifeCycle;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
/// <summary>Marks an entity as removing, deletes its files from the bucket and finally deletes its row.</summary>
public class DeleteRoutingSlipBuilder(
IDatabase database,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration,
ApplicationStorageConfiguration storageConfiguration)
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
private sealed record PictureStepNames(string Original, string Small, string Medium, string Large);
private static readonly PictureStepNames AlbumSteps = new(
ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture,
ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture);
private static readonly PictureStepNames PlayListSteps = new(
ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture,
ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture);
public async Task<RoutingSlipBuilder> BuildTrackAsync(
Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken)
/// <summary>Marks an entity as removing, deletes its files from the bucket and finally deletes its row.</summary>
public class DeleteRoutingSlipBuilder(
IDatabase database,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration,
ApplicationStorageConfiguration storageConfiguration)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.MarkTrackAsRemoving,
MarkTrackLifeCycleActivity.ExecuteEndpointName,
new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing));
private sealed record PictureStepNames(string Original, string Small, string Medium, string Large);
var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken);
if (track != null)
private static readonly PictureStepNames AlbumSteps = new(
ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture,
ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture);
private static readonly PictureStepNames PlayListSteps = new(
ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture,
ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture);
public async Task<RoutingSlipBuilder> BuildTrackAsync(
Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken)
{
var bucket = storageConfiguration.Bucket;
var routes = trackConfiguration.Routes;
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.MarkTrackAsRemoving,
MarkTrackLifeCycleActivity.ExecuteEndpointName,
new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing));
if (!string.IsNullOrEmpty(track.Pictures.OriginalName))
var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken);
if (track != null)
{
builder.AddRemoveFile(
ActivityNames.RemoveTrackOriginalPicture, bucket,
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
var bucket = storageConfiguration.Bucket;
var routes = trackConfiguration.Routes;
if (!string.IsNullOrEmpty(track.Pictures.OriginalName))
{
builder.AddRemoveFile(
ActivityNames.RemoveTrackOriginalPicture, bucket,
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
}
// Unprocessed tracks still point at the shared preset pictures, which must never be deleted.
if (track.Pictures.IsProcessed)
{
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName));
builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName));
}
if (!string.IsNullOrEmpty(track.Audio.OriginalName))
{
builder.AddRemoveFile(
ActivityNames.RemoveTrackOriginalAudio, bucket,
routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName));
}
if (track.Audio.IsProcessed)
{
builder.AddRemoveFile(
ActivityNames.RemoveTrackProcessedAudio, bucket,
routes.BuildProcessedAudioPath(track.Audio.FolderName));
}
}
// Unprocessed tracks still point at the shared preset pictures, which must never be deleted.
if (track.Pictures.IsProcessed)
{
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName));
builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName));
}
if (!string.IsNullOrEmpty(track.Audio.OriginalName))
{
builder.AddRemoveFile(
ActivityNames.RemoveTrackOriginalAudio, bucket,
routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName));
}
if (track.Audio.IsProcessed)
{
builder.AddRemoveFile(
ActivityNames.RemoveTrackProcessedAudio, bucket,
routes.BuildProcessedAudioPath(track.Audio.FolderName));
}
return builder.AddStep(
ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId));
}
return builder.AddStep(
ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId));
}
public async Task<RoutingSlipBuilder> BuildAlbumAsync(
Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.MarkAlbumAsRemoving,
MarkAlbumLifeCycleActivity.ExecuteEndpointName,
new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing));
public async Task<RoutingSlipBuilder> BuildAlbumAsync(
Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.MarkAlbumAsRemoving,
MarkAlbumLifeCycleActivity.ExecuteEndpointName,
new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing));
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == albumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == albumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures);
AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures);
return builder.AddStep(
ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId));
}
return builder.AddStep(
ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId));
}
public async Task<RoutingSlipBuilder> BuildPlayListAsync(
Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.MarkPlayListAsRemoving,
MarkPlayListLifeCycleActivity.ExecuteEndpointName,
new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing));
public async Task<RoutingSlipBuilder> BuildPlayListAsync(
Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.MarkPlayListAsRemoving,
MarkPlayListLifeCycleActivity.ExecuteEndpointName,
new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing));
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == playListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == playListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures);
AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures);
return builder.AddStep(
ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId));
}
return builder.AddStep(
ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId));
}
private void AddPictureRemovals(
RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures)
{
if (pictures == null)
return;
private void AddPictureRemovals(
RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures)
{
if (pictures == null)
return;
var bucket = storageConfiguration.Bucket;
var bucket = storageConfiguration.Bucket;
if (pictures.OriginalName is { } original)
builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original));
if (pictures.SmallName is { } small)
builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small));
if (pictures.MediumName is { } medium)
builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium));
if (pictures.LargeName is { } large)
builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large));
if (pictures.OriginalName is { } original)
builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original));
if (pictures.SmallName is { } small)
builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small));
if (pictures.MediumName is { } medium)
builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium));
if (pictures.LargeName is { } large)
builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large));
}
}
}
@@ -6,69 +6,70 @@ using Musify.Infrastructure.MassTransit.Activities.PlayLists;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
/// <summary>
/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow.
/// Creation faults mark the whole entity as failed; update faults only report the picture as failed.
/// </summary>
public class PictureSourceRoutingSlipBuilder
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) =>
BuildSlip(
message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation,
ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName,
new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes));
public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) =>
BuildSlip(
message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture,
ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName,
new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes));
public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) =>
BuildSlip(
message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation,
ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName,
new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes));
public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) =>
BuildSlip(
message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture,
ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName,
new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes));
private static RoutingSlipBuilder BuildSlip(
Guid subjectId,
Guid pictureIntentId,
string bucket,
string pictureSourceKey,
string pictureDestinationKey,
Guid? correlationId,
string processKind,
string publishStepName,
string publishEndpointName,
object publishArguments)
/// <summary>
/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow.
/// Creation faults mark the whole entity as failed; update faults only report the picture as failed.
/// </summary>
public class PictureSourceRoutingSlipBuilder
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey))
.AddStep(
ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([pictureIntentId]))
.AddStep(publishStepName, publishEndpointName, publishArguments)
.TrackFaults(subjectId, processKind);
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) =>
BuildSlip(
message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation,
ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName,
new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes));
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket);
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey);
public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) =>
BuildSlip(
message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture,
ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName,
new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes));
return builder;
public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) =>
BuildSlip(
message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation,
ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName,
new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes));
public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) =>
BuildSlip(
message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey,
correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture,
ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName,
new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes));
private static RoutingSlipBuilder BuildSlip(
Guid subjectId,
Guid pictureIntentId,
string bucket,
string pictureSourceKey,
string pictureDestinationKey,
Guid? correlationId,
string processKind,
string publishStepName,
string publishEndpointName,
object publishArguments)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey))
.AddStep(
ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([pictureIntentId]))
.AddStep(publishStepName, publishEndpointName, publishArguments)
.TrackFaults(subjectId, processKind);
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket);
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey);
return builder;
}
}
}
@@ -6,81 +6,82 @@ using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.Pictures;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
/// <summary>Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner.</summary>
public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) =>
BuildPictureWorkflow(
message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId,
ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName,
RoutingSlipVariableNames.ProcessKinds.TrackPicture);
public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) =>
BuildPictureWorkflow(
message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId,
ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName,
RoutingSlipVariableNames.ProcessKinds.PlayListPicture);
public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) =>
BuildPictureWorkflow(
message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId,
ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName,
RoutingSlipVariableNames.ProcessKinds.AlbumPicture);
private RoutingSlipBuilder BuildPictureWorkflow(
Guid subjectId,
string bucket,
string sourceKey,
ImageSizes sizes,
Guid? correlationId,
string updateStepName,
string updateEndpointName,
string processKind)
/// <summary>Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner.</summary>
public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.GeneratePictureWorkflowPaths,
GeneratePictureWorkflowPathsActivity.ExecuteEndpointName,
new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey))
.AddStep(
ActivityNames.DownloadFile,
DownloadFileFromBucketActivity.ExecuteEndpointName,
new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath));
public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) =>
BuildPictureWorkflow(
message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId,
ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName,
RoutingSlipVariableNames.ProcessKinds.TrackPicture);
AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small);
AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium);
AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large);
public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) =>
BuildPictureWorkflow(
message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId,
ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName,
RoutingSlipVariableNames.ProcessKinds.PlayListPicture);
return builder
.AddStep(
updateStepName,
updateEndpointName,
new UpdatePicturesArguments(
subjectId,
sourceKey,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath))
.TrackFaults(subjectId, processKind);
public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) =>
BuildPictureWorkflow(
message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId,
ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName,
RoutingSlipVariableNames.ProcessKinds.AlbumPicture);
private RoutingSlipBuilder BuildPictureWorkflow(
Guid subjectId,
string bucket,
string sourceKey,
ImageSizes sizes,
Guid? correlationId,
string updateStepName,
string updateEndpointName,
string processKind)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
ActivityNames.GeneratePictureWorkflowPaths,
GeneratePictureWorkflowPathsActivity.ExecuteEndpointName,
new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey))
.AddStep(
ActivityNames.DownloadFile,
DownloadFileFromBucketActivity.ExecuteEndpointName,
new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath));
AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small);
AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium);
AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large);
return builder
.AddStep(
updateStepName,
updateEndpointName,
new UpdatePicturesArguments(
subjectId,
sourceKey,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath))
.TrackFaults(subjectId, processKind);
}
private static void AddResize(
RoutingSlipBuilder builder,
string resizeStepName,
string uploadStepName,
string bucket,
string resizedFileVariable,
ImageSize size) =>
builder
.AddStep(
resizeStepName,
ResizePictureActivity.ExecuteEndpointName,
new ResizePictureLocalArguments(
RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height))
.AddStep(
uploadStepName,
UploadFileToBucketActivity.ExecuteEndpointName,
new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath));
}
private static void AddResize(
RoutingSlipBuilder builder,
string resizeStepName,
string uploadStepName,
string bucket,
string resizedFileVariable,
ImageSize size) =>
builder
.AddStep(
resizeStepName,
ResizePictureActivity.ExecuteEndpointName,
new ResizePictureLocalArguments(
RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height))
.AddStep(
uploadStepName,
UploadFileToBucketActivity.ExecuteEndpointName,
new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath));
}
@@ -1,41 +1,42 @@
namespace Musify.Infrastructure.MassTransit.RoutingSlip;
internal static class RoutingSlipVariableNames
namespace Musify.Infrastructure.MassTransit.RoutingSlip
{
internal static class Workflow
internal static class RoutingSlipVariableNames
{
internal const string TemporalDirectory = "Workflow.TemporalDirectory";
internal const string CorrelationId = "Workflow.CorrelationId";
internal const string SubjectId = "Workflow.SubjectId";
internal const string ProcessKind = "Workflow.ProcessKind";
internal const string Bucket = "Workflow.Bucket";
internal const string PictureKey = "Workflow.PictureKey";
internal const string AudioKey = "Workflow.AudioKey";
}
internal static class Workflow
{
internal const string TemporalDirectory = "Workflow.TemporalDirectory";
internal const string CorrelationId = "Workflow.CorrelationId";
internal const string SubjectId = "Workflow.SubjectId";
internal const string ProcessKind = "Workflow.ProcessKind";
internal const string Bucket = "Workflow.Bucket";
internal const string PictureKey = "Workflow.PictureKey";
internal const string AudioKey = "Workflow.AudioKey";
}
internal static class ProcessKinds
{
internal const string TrackPicture = "TrackPicture";
internal const string TrackAudio = "TrackAudio";
internal const string PlayListPicture = "PlayListPicture";
internal const string AlbumPicture = "AlbumPicture";
internal const string TrackCreation = "TrackCreation";
internal const string AlbumCreation = "AlbumCreation";
internal const string PlayListCreation = "PlayListCreation";
}
internal static class ProcessKinds
{
internal const string TrackPicture = "TrackPicture";
internal const string TrackAudio = "TrackAudio";
internal const string PlayListPicture = "PlayListPicture";
internal const string AlbumPicture = "AlbumPicture";
internal const string TrackCreation = "TrackCreation";
internal const string AlbumCreation = "AlbumCreation";
internal const string PlayListCreation = "PlayListCreation";
}
internal static class Audio
{
internal const string SourceFilePath = "Audio.SourceFilePath";
internal const string TranscodedDirectory = "Audio.TranscodedDirectory";
internal const string DurationSeconds = "Audio.DurationSeconds";
}
internal static class Audio
{
internal const string SourceFilePath = "Audio.SourceFilePath";
internal const string TranscodedDirectory = "Audio.TranscodedDirectory";
internal const string DurationSeconds = "Audio.DurationSeconds";
}
internal static class Picture
{
internal const string OriginalFilePath = "Picture.OriginalFilePath";
internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath";
internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath";
internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath";
internal static class Picture
{
internal const string OriginalFilePath = "Picture.OriginalFilePath";
internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath";
internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath";
internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath";
}
}
}
@@ -4,45 +4,46 @@ using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip;
/// <summary>Shared building blocks for every routing slip in the worker.</summary>
internal static class RoutingSlips
namespace Musify.Infrastructure.MassTransit.RoutingSlip
{
public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute");
public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}");
/// <summary>A new slip that carries the correlation id and always cleans its temporary directory up.</summary>
public static RoutingSlipBuilder Create(Guid? correlationId)
/// <summary>Shared building blocks for every routing slip in the worker.</summary>
internal static class RoutingSlips
{
var builder = new RoutingSlipBuilder(NewId.NextGuid());
builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
builder.AddSubscription(
ConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute");
return builder;
public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}");
/// <summary>A new slip that carries the correlation id and always cleans its temporary directory up.</summary>
public static RoutingSlipBuilder Create(Guid? correlationId)
{
var builder = new RoutingSlipBuilder(NewId.NextGuid());
builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
builder.AddSubscription(
ConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
return builder;
}
public static RoutingSlipBuilder AddStep(
this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments)
{
builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments);
return builder;
}
/// <summary>Publishes a failure event for the subject when the slip faults.</summary>
public static RoutingSlipBuilder TrackFaults(
this RoutingSlipBuilder builder, Guid subjectId, string processKind)
{
builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId);
builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind);
builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted);
return builder;
}
public static RoutingSlipBuilder AddRemoveFile(
this RoutingSlipBuilder builder, string stepName, string bucket, string key) =>
builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key));
}
public static RoutingSlipBuilder AddStep(
this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments)
{
builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments);
return builder;
}
/// <summary>Publishes a failure event for the subject when the slip faults.</summary>
public static RoutingSlipBuilder TrackFaults(
this RoutingSlipBuilder builder, Guid subjectId, string processKind)
{
builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId);
builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind);
builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted);
return builder;
}
public static RoutingSlipBuilder AddRemoveFile(
this RoutingSlipBuilder builder, string stepName, string bucket, string key) =>
builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key));
}
@@ -1,18 +1,19 @@
using MassTransit;
namespace Musify.Infrastructure.MassTransit.Sagas;
public class AlbumProcessingState : SagaStateMachineInstance
namespace Musify.Infrastructure.MassTransit.Sagas
{
public Guid CorrelationId { get; set; }
public class AlbumProcessingState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = null!;
public string CurrentState { get; set; } = null!;
public string? Bucket { get; set; }
public string? Bucket { get; set; }
public string? PictureKey { get; set; }
public string? PictureKey { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}
@@ -1,58 +1,59 @@
using MassTransit;
using Musify.Application.Events;
namespace Musify.Infrastructure.MassTransit.Sagas;
public sealed class AlbumProcessingStateMachine : MassTransitStateMachine<AlbumProcessingState>
namespace Musify.Infrastructure.MassTransit.Sagas
{
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<CreateAlbumResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<AlbumPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<AlbumPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public AlbumProcessingStateMachine()
public sealed class AlbumProcessingStateMachine : MassTransitStateMachine<AlbumProcessingState>
{
InstanceState(state => state.CurrentState);
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId));
Event(() => PictureProcessed, config =>
public Event<CreateAlbumResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<AlbumPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<AlbumPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public AlbumProcessingStateMachine()
{
config.CorrelateById(context => context.Message.AlbumId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => PictureFailed, config =>
{
config.CorrelateById(context => context.Message.AlbumId);
config.OnMissingInstance(instance => instance.Discard());
});
InstanceState(state => state.CurrentState);
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
})
.TransitionTo(Processing));
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId));
Event(() => PictureProcessed, config =>
{
config.CorrelateById(context => context.Message.AlbumId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => PictureFailed, config =>
{
config.CorrelateById(context => context.Message.AlbumId);
config.OnMissingInstance(instance => instance.Discard());
});
During(Processing,
When(PictureProcessed).Finalize(),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new AlbumProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey)));
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
})
.TransitionTo(Processing));
During(Failed,
Ignore(PictureProcessed),
Ignore(PictureFailed));
During(Processing,
When(PictureProcessed).Finalize(),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new AlbumProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey)));
SetCompletedWhenFinalized();
During(Failed,
Ignore(PictureProcessed),
Ignore(PictureFailed));
SetCompletedWhenFinalized();
}
}
}
@@ -1,18 +1,19 @@
using MassTransit;
namespace Musify.Infrastructure.MassTransit.Sagas;
public class PlayListProcessingState : SagaStateMachineInstance
namespace Musify.Infrastructure.MassTransit.Sagas
{
public Guid CorrelationId { get; set; }
public class PlayListProcessingState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = null!;
public string CurrentState { get; set; } = null!;
public string? Bucket { get; set; }
public string? Bucket { get; set; }
public string? PictureKey { get; set; }
public string? PictureKey { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}
@@ -1,58 +1,59 @@
using MassTransit;
using Musify.Application.Events;
namespace Musify.Infrastructure.MassTransit.Sagas;
public sealed class PlayListProcessingStateMachine : MassTransitStateMachine<PlayListProcessingState>
namespace Musify.Infrastructure.MassTransit.Sagas
{
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<CreatePlayListResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<PlayListPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<PlayListPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public PlayListProcessingStateMachine()
public sealed class PlayListProcessingStateMachine : MassTransitStateMachine<PlayListProcessingState>
{
InstanceState(state => state.CurrentState);
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId));
Event(() => PictureProcessed, config =>
public Event<CreatePlayListResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<PlayListPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<PlayListPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public PlayListProcessingStateMachine()
{
config.CorrelateById(context => context.Message.PlayListId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => PictureFailed, config =>
{
config.CorrelateById(context => context.Message.PlayListId);
config.OnMissingInstance(instance => instance.Discard());
});
InstanceState(state => state.CurrentState);
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
})
.TransitionTo(Processing));
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId));
Event(() => PictureProcessed, config =>
{
config.CorrelateById(context => context.Message.PlayListId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => PictureFailed, config =>
{
config.CorrelateById(context => context.Message.PlayListId);
config.OnMissingInstance(instance => instance.Discard());
});
During(Processing,
When(PictureProcessed).Finalize(),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new PlayListProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey)));
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
})
.TransitionTo(Processing));
During(Failed,
Ignore(PictureProcessed),
Ignore(PictureFailed));
During(Processing,
When(PictureProcessed).Finalize(),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new PlayListProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey)));
SetCompletedWhenFinalized();
During(Failed,
Ignore(PictureProcessed),
Ignore(PictureFailed));
SetCompletedWhenFinalized();
}
}
}
@@ -1,24 +1,25 @@
using MassTransit;
namespace Musify.Infrastructure.MassTransit.Sagas;
public class TrackProcessingState : SagaStateMachineInstance
namespace Musify.Infrastructure.MassTransit.Sagas
{
public Guid CorrelationId { get; set; }
public class TrackProcessingState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = null!;
public string CurrentState { get; set; } = null!;
public bool PictureProcessed { get; set; }
public bool PictureProcessed { get; set; }
public bool AudioProcessed { get; set; }
public bool AudioProcessed { get; set; }
public string? Bucket { get; set; }
public string? Bucket { get; set; }
public string? PictureKey { get; set; }
public string? PictureKey { get; set; }
public string? AudioKey { get; set; }
public string? AudioKey { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}
@@ -1,95 +1,96 @@
using MassTransit;
using Musify.Application.Events;
namespace Musify.Infrastructure.MassTransit.Sagas;
public sealed class TrackProcessingStateMachine : MassTransitStateMachine<TrackProcessingState>
namespace Musify.Infrastructure.MassTransit.Sagas
{
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<CreateTrackResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<TrackPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<TrackAudioProcessed> AudioProcessed { get; private set; } = null!;
public Event<TrackPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public Event<TrackAudioProcessingFailed> AudioFailed { get; private set; } = null!;
public TrackProcessingStateMachine()
public sealed class TrackProcessingStateMachine : MassTransitStateMachine<TrackProcessingState>
{
InstanceState(state => state.CurrentState);
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => PictureProcessed, config =>
public Event<CreateTrackResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<TrackPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<TrackAudioProcessed> AudioProcessed { get; private set; } = null!;
public Event<TrackPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public Event<TrackAudioProcessingFailed> AudioFailed { get; private set; } = null!;
public TrackProcessingStateMachine()
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => AudioProcessed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => PictureFailed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => AudioFailed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
InstanceState(state => state.CurrentState);
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.AudioKey = context.Message.AudioDestinationKey;
})
.TransitionTo(Processing));
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => PictureProcessed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => AudioProcessed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => PictureFailed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
Event(() => AudioFailed, config =>
{
config.CorrelateById(context => context.Message.TrackId);
config.OnMissingInstance(instance => instance.Discard());
});
During(Processing,
When(PictureProcessed)
.Then(context =>
{
context.Saga.PictureProcessed = true;
context.Saga.UpdatedAt = DateTime.UtcNow;
})
.If(context => context.Saga.AudioProcessed, binder => binder.Finalize()),
When(AudioProcessed)
.Then(context =>
{
context.Saga.AudioProcessed = true;
context.Saga.UpdatedAt = DateTime.UtcNow;
})
.If(context => context.Saga.PictureProcessed, binder => binder.Finalize()),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new TrackProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey,
context.Saga.AudioKey)),
When(AudioFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new TrackProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey,
context.Saga.AudioKey)));
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.AudioKey = context.Message.AudioDestinationKey;
})
.TransitionTo(Processing));
During(Failed,
Ignore(PictureProcessed),
Ignore(AudioProcessed),
Ignore(PictureFailed),
Ignore(AudioFailed));
During(Processing,
When(PictureProcessed)
.Then(context =>
{
context.Saga.PictureProcessed = true;
context.Saga.UpdatedAt = DateTime.UtcNow;
})
.If(context => context.Saga.AudioProcessed, binder => binder.Finalize()),
When(AudioProcessed)
.Then(context =>
{
context.Saga.AudioProcessed = true;
context.Saga.UpdatedAt = DateTime.UtcNow;
})
.If(context => context.Saga.PictureProcessed, binder => binder.Finalize()),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new TrackProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey,
context.Saga.AudioKey)),
When(AudioFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new TrackProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey,
context.Saga.AudioKey)));
SetCompletedWhenFinalized();
During(Failed,
Ignore(PictureProcessed),
Ignore(AudioProcessed),
Ignore(PictureFailed),
Ignore(AudioFailed));
SetCompletedWhenFinalized();
}
}
}
@@ -8,46 +8,47 @@ using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Musify.Infrastructure.Observability;
public static class OpenTelemetryDependencyInjection
namespace Musify.Infrastructure.Observability
{
public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration)
public static class OpenTelemetryDependencyInjection
{
services.AddValidatedOptions<OpenTelemetryConfiguration>(configuration);
public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<OpenTelemetryConfiguration>(configuration);
services
.AddOptions<OtlpExporterOptions>()
.Configure<OpenTelemetryConfiguration>((exporterOptions, openTelemetryConfiguration) =>
{
exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint);
});
services
.AddOptions<OtlpExporterOptions>()
.Configure<OpenTelemetryConfiguration>((exporterOptions, openTelemetryConfiguration) =>
{
exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint);
});
services
.AddOpenTelemetry()
.ConfigureResource(resource =>
{
resource.AddService("Musify");
})
.WithTracing(tracing =>
tracing.AddSource("MassTransit")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter()
)
.WithMetrics(metrics =>
metrics.AddMeter("MassTransit")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddProcessInstrumentation()
.AddOtlpExporter()
)
.WithLogging(logging =>
logging.AddOtlpExporter()
);
services
.AddOpenTelemetry()
.ConfigureResource(resource =>
{
resource.AddService("Musify");
})
.WithTracing(tracing =>
tracing.AddSource("MassTransit")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter()
)
.WithMetrics(metrics =>
metrics.AddMeter("MassTransit")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddProcessInstrumentation()
.AddOtlpExporter()
)
.WithLogging(logging =>
logging.AddOtlpExporter()
);
return services;
return services;
}
}
}
@@ -2,42 +2,43 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Musify.Domain.Abstractions;
namespace Musify.Infrastructure.Persistence;
public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
namespace Musify.Infrastructure.Persistence
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
{
ApplyTimestamps(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
ApplyTimestamps(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void ApplyTimestamps(DbContext? context)
{
if (context == null)
return;
context.ChangeTracker.DetectChanges();
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
if (entry.State == EntityState.Added)
ApplyTimestamps(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
ApplyTimestamps(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void ApplyTimestamps(DbContext? context)
{
if (context == null)
return;
context.ChangeTracker.DetectChanges();
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
}
}
}
}
@@ -8,81 +8,82 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration;
using Npgsql;
namespace Musify.Infrastructure.Persistence;
public class Database(DatabaseConfiguration configuration)
: DbContext, Musify.Application.Contracts.IDatabase
namespace Musify.Infrastructure.Persistence
{
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
public class Database(DatabaseConfiguration configuration)
: DbContext, Musify.Application.Contracts.IDatabase
{
optionsBuilder
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
}
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
ModelConfiguration.Apply(modelBuilder);
}
public DbSet<User> Users => Set<User>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new DatabaseTransaction(transaction);
}
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
{
try
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
await SaveChangesAsync(cancellationToken);
return Result.Success;
optionsBuilder
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
}
catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
return onUniqueViolation;
base.OnModelCreating(modelBuilder);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
ModelConfiguration.Apply(modelBuilder);
}
}
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken) =>
inner.CommitAsync(cancellationToken);
public DbSet<User> Users => Set<User>();
public ValueTask DisposeAsync() => inner.DisposeAsync();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new DatabaseTransaction(transaction);
}
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
{
try
{
await SaveChangesAsync(cancellationToken);
return Result.Success;
}
catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return onUniqueViolation;
}
}
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken) =>
inner.CommitAsync(cancellationToken);
public ValueTask DisposeAsync() => inner.DisposeAsync();
}
}
}
@@ -4,18 +4,19 @@ using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Persistence;
public static class DatabaseDependencyInjection
namespace Musify.Infrastructure.Persistence
{
public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration)
public static class DatabaseDependencyInjection
{
services.AddValidatedOptions<DatabaseConfiguration>(configuration);
public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<DatabaseConfiguration>(configuration);
services.AddDbContext<Database>();
services.AddDbContext<Database>();
services.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
services.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
return services;
return services;
}
}
}
@@ -2,24 +2,25 @@ using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Persistence;
public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory<Database>
namespace Musify.Infrastructure.Persistence
{
public Database CreateDbContext(string[] args)
public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory<Database>
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("DesignSettings.json", optional: true)
.AddUserSecrets<DatabaseDesignTimeFactory>()
.AddEnvironmentVariables()
.Build();
public Database CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("DesignSettings.json", optional: true)
.AddUserSecrets<DatabaseDesignTimeFactory>()
.AddEnvironmentVariables()
.Build();
var databaseConfiguration = configuration
.GetRequiredSection(DatabaseConfiguration.SectionName)
.Get<DatabaseConfiguration>()
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
var databaseConfiguration = configuration
.GetRequiredSection(DatabaseConfiguration.SectionName)
.Get<DatabaseConfiguration>()
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
return new Database(databaseConfiguration);
return new Database(databaseConfiguration);
}
}
}
@@ -3,152 +3,153 @@ using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Sagas;
namespace Musify.Infrastructure.Persistence;
/// <summary>
/// Entity model shared by <see cref="Database"/> and the in-memory test database, so both always agree.
/// </summary>
public static class ModelConfiguration
namespace Musify.Infrastructure.Persistence
{
public static void Apply(ModelBuilder modelBuilder)
/// <summary>
/// Entity model shared by <see cref="Database"/> and the in-memory test database, so both always agree.
/// </summary>
public static class ModelConfiguration
{
var trackProcessing = modelBuilder.Entity<TrackProcessingState>();
trackProcessing.HasKey(state => state.CorrelationId);
trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
trackProcessing.Property(state => state.CurrentState).HasMaxLength(64);
public static void Apply(ModelBuilder modelBuilder)
{
var trackProcessing = modelBuilder.Entity<TrackProcessingState>();
trackProcessing.HasKey(state => state.CorrelationId);
trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
trackProcessing.Property(state => state.CurrentState).HasMaxLength(64);
modelBuilder.Entity<Track>()
.OwnsOne(track => track.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus");
});
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
modelBuilder.Entity<Track>()
.OwnsOne(track => track.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus");
});
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
modelBuilder.Entity<Track>()
.OwnsOne(track => track.Audio, audio =>
{
audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64);
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus");
});
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
modelBuilder.Entity<Track>()
.OwnsOne(track => track.Audio, audio =>
{
audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64);
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus");
});
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
modelBuilder.Entity<Track>()
.HasOne(track => track.Owner)
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Track>()
.HasOne(track => track.Owner)
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PlayList>()
.Property(playList => playList.Visibility)
.HasDefaultValue(PlayListVisibility.Private);
modelBuilder.Entity<PlayList>()
.Property(playList => playList.Visibility)
.HasDefaultValue(PlayListVisibility.Private);
modelBuilder.Entity<PlayList>()
.OwnsOne(playList => playList.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
});
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
modelBuilder.Entity<PlayList>()
.OwnsOne(playList => playList.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
});
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
modelBuilder.Entity<Album>()
.OwnsOne(album => album.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
});
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
modelBuilder.Entity<Album>()
.OwnsOne(album => album.Pictures, pictures =>
{
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
});
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
modelBuilder.Entity<Album>()
.HasOne(album => album.Owner)
.WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Album>()
.HasOne(album => album.Owner)
.WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.AlbumId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.AlbumId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Track)
.WithMany(track => track.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Track)
.WithMany(track => track.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>()
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.IsUnique();
modelBuilder.Entity<AlbumHasTrack>()
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.IsUnique();
modelBuilder.Entity<PlayListHasTrack>()
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
.IsUnique();
modelBuilder.Entity<PlayListHasTrack>()
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
.IsUnique();
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Mix)
.WithMany(mix => mix.Items)
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Mix)
.WithMany(mix => mix.Items)
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
modelBuilder.Entity<TrackLike>()
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<TrackLike>()
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<TrackTag>()
.HasOne(trackTag => trackTag.Track)
.WithMany(track => track.Tags)
.HasForeignKey(trackTag => trackTag.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackTag>()
.HasOne(trackTag => trackTag.Track)
.WithMany(track => track.Tags)
.HasForeignKey(trackTag => trackTag.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackTag>()
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
.IsUnique();
modelBuilder.Entity<TrackTag>()
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
.HasForeignKey(follow => follow.FollowerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
.HasForeignKey(follow => follow.FollowerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Followed)
.WithMany()
.HasForeignKey(follow => follow.FollowedId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Followed)
.WithMany()
.HasForeignKey(follow => follow.FollowedId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
.IsUnique();
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
playListProcessing.Property(state => state.CurrentState).HasMaxLength(64);
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
playListProcessing.Property(state => state.CurrentState).HasMaxLength(64);
var albumProcessing = modelBuilder.Entity<AlbumProcessingState>();
albumProcessing.HasKey(state => state.CorrelationId);
albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
albumProcessing.Property(state => state.CurrentState).HasMaxLength(64);
var albumProcessing = modelBuilder.Entity<AlbumProcessingState>();
albumProcessing.HasKey(state => state.CorrelationId);
albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
albumProcessing.Property(state => state.CurrentState).HasMaxLength(64);
}
}
}
@@ -5,171 +5,172 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Services;
public sealed partial class AudioTranscoderService(
ILogger<AudioTranscoderService> logger,
AudioConfiguration audioConfiguration)
: IAudioTranscoderService
namespace Musify.Infrastructure.Services
{
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<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken)
public sealed partial class AudioTranscoderService(
ILogger<AudioTranscoderService> logger,
AudioConfiguration audioConfiguration)
: IAudioTranscoderService
{
Directory.CreateDirectory(destinationPath);
private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError);
logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath);
[GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)]
private static partial Regex DurationRegex();
var executionResult = await ExecuteFfmpegAsync(
BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg),
destinationPath,
audioStream,
"TranscodeToAudioFile",
cancellationToken);
if (executionResult.ExitCode != 0)
public async Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken)
{
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);
}
Directory.CreateDirectory(destinationPath);
logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath);
logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath);
var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName);
var duration = await GetAudioDurationAsync(outputPath, cancellationToken);
return new AudioTranscodeResult(executionResult.ExitCode, duration);
}
var executionResult = await ExecuteFfmpegAsync(
BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg),
destinationPath,
audioStream,
"TranscodeToAudioFile",
cancellationToken);
private async Task<TimeSpan> GetAudioDurationAsync(string filePath, CancellationToken cancellationToken)
{
var executionResult = await ExecuteFfmpegAsync(
BuildProbeDurationArguments(filePath),
Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory,
input: null,
"ProbeAudioDuration",
cancellationToken);
var match = DurationRegex().Match(executionResult.StandardError);
if (!match.Success)
throw new InvalidOperationException($"Could not determine audio duration for {filePath}.");
var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds);
}
private async Task<FfmpegExecutionResult> ExecuteFfmpegAsync(
string arguments,
string workingDirectory,
Stream? input,
string operationName,
CancellationToken cancellationToken)
{
using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null);
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 (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);
}
private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput)
=> new()
{
StartInfo = new ProcessStartInfo
if (executionResult.ExitCode != 0)
{
FileName = audioConfiguration.Ffmpeg.ExecutableName,
WorkingDirectory = workingDirectory,
Arguments = arguments,
RedirectStandardInput = redirectStandardInput,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
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);
}
};
private async Task<FfmpegExecutionResult> WaitForProcessResultAsync(
Process process,
Task<string> outputTask,
Task<string> errorTask,
string operationName,
CancellationToken cancellationToken)
{
using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout);
logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath);
try
{
await process.WaitForExitAsync(linkedCancellationTokens.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKillProcess(process);
throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id}).");
var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName);
var duration = await GetAudioDurationAsync(outputPath, cancellationToken);
return new AudioTranscodeResult(executionResult.ExitCode, duration);
}
var standardError = await errorTask;
var standardOutput = await outputTask;
private async Task<TimeSpan> GetAudioDurationAsync(string filePath, CancellationToken cancellationToken)
{
var executionResult = await ExecuteFfmpegAsync(
BuildProbeDurationArguments(filePath),
Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory,
input: null,
"ProbeAudioDuration",
cancellationToken);
logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B",
operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length);
var match = DurationRegex().Match(executionResult.StandardError);
if (!match.Success)
throw new InvalidOperationException($"Could not determine audio duration for {filePath}.");
return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError);
var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds);
}
private async Task<FfmpegExecutionResult> ExecuteFfmpegAsync(
string arguments,
string workingDirectory,
Stream? input,
string operationName,
CancellationToken cancellationToken)
{
using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null);
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 (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);
}
private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput)
=> new()
{
StartInfo = new ProcessStartInfo
{
FileName = audioConfiguration.Ffmpeg.ExecutableName,
WorkingDirectory = workingDirectory,
Arguments = arguments,
RedirectStandardInput = redirectStandardInput,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
private async Task<FfmpegExecutionResult> WaitForProcessResultAsync(
Process process,
Task<string> outputTask,
Task<string> errorTask,
string operationName,
CancellationToken cancellationToken)
{
using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout);
try
{
await process.WaitForExitAsync(linkedCancellationTokens.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKillProcess(process);
throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id}).");
}
var standardError = await errorTask;
var standardOutput = await outputTask;
logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B",
operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length);
return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError);
}
private void TryKillProcess(Process process)
{
try
{
if (!process.HasExited)
process.Kill();
}
catch (InvalidOperationException exception)
{
logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id);
}
}
private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration)
{
var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName);
var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments)
? string.Empty
: $" {ffmpegConfiguration.AdditionalArguments.Trim()}";
return "-y -hide_banner -loglevel error " +
"-i pipe:0 " +
"-vn " +
"-map 0:a " +
$"-c:a {ffmpegConfiguration.AudioCodec} " +
$"-b:a {ffmpegConfiguration.AudioBitrate} " +
$"-ac {ffmpegConfiguration.AudioChannels} " +
$"-ar {ffmpegConfiguration.AudioSampleRate} " +
$"-profile:a {ffmpegConfiguration.AudioProfile} " +
"-movflags +faststart" +
additionalArguments +
$" \"{outputPath}\"";
}
private static string BuildProbeDurationArguments(string filePath)
=> $"-hide_banner -i \"{filePath}\" -f null -";
}
private void TryKillProcess(Process process)
{
try
{
if (!process.HasExited)
process.Kill();
}
catch (InvalidOperationException exception)
{
logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id);
}
}
private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration)
{
var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName);
var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments)
? string.Empty
: $" {ffmpegConfiguration.AdditionalArguments.Trim()}";
return "-y -hide_banner -loglevel error " +
"-i pipe:0 " +
"-vn " +
"-map 0:a " +
$"-c:a {ffmpegConfiguration.AudioCodec} " +
$"-b:a {ffmpegConfiguration.AudioBitrate} " +
$"-ac {ffmpegConfiguration.AudioChannels} " +
$"-ar {ffmpegConfiguration.AudioSampleRate} " +
$"-profile:a {ffmpegConfiguration.AudioProfile} " +
"-movflags +faststart" +
additionalArguments +
$" \"{outputPath}\"";
}
private static string BuildProbeDurationArguments(string filePath)
=> $"-hide_banner -i \"{filePath}\" -f null -";
}
@@ -3,29 +3,30 @@ using Musify.Application.Contracts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Musify.Infrastructure.Services;
public class PictureService(ILogger<PictureService> logger)
: IPictureService
namespace Musify.Infrastructure.Services
{
public async Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken)
public class PictureService(ILogger<PictureService> logger)
: IPictureService
{
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
public async Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken)
{
Size = new Size(width, height),
Mode = ResizeMode.Max
}));
logger.LogDebug("Resizing picture to {Width}x{Height}", width, height);
if (pictureStream.CanSeek && pictureStream.Position > 0)
pictureStream.Position = 0;
var memoryStream = new MemoryStream();
await picture.SaveAsWebpAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
using var picture = await Image.LoadAsync(pictureStream, cancellationToken);
picture.Mutate(options => options.Resize(new ResizeOptions
{
Size = new Size(width, height),
Mode = ResizeMode.Max
}));
logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length);
return memoryStream;
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;
}
}
}
@@ -8,68 +8,69 @@ using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Jobs;
namespace Musify.Infrastructure.Services;
public static class ServicesDependencyInjection
namespace Musify.Infrastructure.Services
{
public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration)
public static class ServicesDependencyInjection
{
services.AddValidatedOptions<InfrastructureStorageConfiguration>(configuration);
services.AddSingleton<IAmazonS3>(serviceProvider =>
public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration)
{
var configuration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>();
var s3Configuration = new AmazonS3Config
services.AddValidatedOptions<InfrastructureStorageConfiguration>(configuration);
services.AddSingleton<IAmazonS3>(serviceProvider =>
{
ServiceURL = configuration.Address,
ForcePathStyle = configuration.ForcePathStyle,
UseHttp = configuration.UseHttp,
};
return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration);
});
var configuration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>();
var s3Configuration = new AmazonS3Config
{
ServiceURL = configuration.Address,
ForcePathStyle = configuration.ForcePathStyle,
UseHttp = configuration.UseHttp,
};
return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration);
});
services.AddSingleton<IStorageService, StorageService>();
return services;
}
services.AddSingleton<IStorageService, StorageService>();
return services;
}
public static IServiceCollection AddPictureService(this IServiceCollection services)
{
services.AddScoped<IPictureService, PictureService>();
return services;
}
public static IServiceCollection AddJobs(this IServiceCollection services)
{
services.AddHangfire((serviceProvider, hangfireConfiguration) =>
public static IServiceCollection AddPictureService(this IServiceCollection services)
{
var databaseConfiguration = serviceProvider.GetRequiredService<DatabaseConfiguration>();
hangfireConfiguration.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(databaseConfiguration.ConnectionString));
});
services.AddScoped<IPictureService, PictureService>();
return services;
}
services.AddHangfireServer();
services.AddScoped<DailyMixGenerationJob>();
services.AddScoped<ListeningHistoryCleanupJob>();
services.AddScoped<UploadIntentExpirationJob>();
services.AddScoped<TempUploadsCleanupJob>();
services.AddHostedService<RecurringJobsRegistrar>();
public static IServiceCollection AddJobs(this IServiceCollection services)
{
services.AddHangfire((serviceProvider, hangfireConfiguration) =>
{
var databaseConfiguration = serviceProvider.GetRequiredService<DatabaseConfiguration>();
hangfireConfiguration.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(databaseConfiguration.ConnectionString));
});
return services;
}
services.AddHangfireServer();
services.AddScoped<DailyMixGenerationJob>();
services.AddScoped<ListeningHistoryCleanupJob>();
services.AddScoped<UploadIntentExpirationJob>();
services.AddScoped<TempUploadsCleanupJob>();
services.AddHostedService<RecurringJobsRegistrar>();
public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<StreamTicketConfiguration>(configuration);
return services;
}
services.AddSingleton<IStreamTicketService, StreamTicketService>();
return services;
}
public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<StreamTicketConfiguration>(configuration);
public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<AudioConfiguration>(configuration);
services.AddSingleton<IStreamTicketService, StreamTicketService>();
return services;
}
services.AddScoped<IAudioTranscoderService, AudioTranscoderService>();
return services;
public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration)
{
services.AddValidatedOptions<AudioConfiguration>(configuration);
services.AddScoped<IAudioTranscoderService, AudioTranscoderService>();
return services;
}
}
}

Some files were not shown because too many files have changed in this diff Show More