chore: group .NET into backend/ and drop non-.NET test artifacts

- Move all .NET (6 projects, solution, Directory.*.props, nuget.config)
  into backend/ so the monorepo root can host non-.NET projects later
- Remove throwaway test artifacts: player.html, pl.json
- Trim dead Flutter rules from .gitignore

Build from backend/Musify.slnx: 0 warnings, 0 errors.
This commit is contained in:
2026-07-05 03:43:52 +02:00
parent c7fcf2d289
commit f2933caa90
247 changed files with 0 additions and 229 deletions
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class AudioTranscoderConfiguration
{
public const string SectionName = "AudioTranscoder";
[Required]
public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5);
[Required]
public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration();
}
public class FfmpegConfiguration
{
[Required]
public string ExecutableName { get; set; } = "ffmpeg";
[Required]
public string OutputFileName { get; set; } = "audio.m4a";
[Required]
public string AudioCodec { get; set; } = "aac";
[Required]
public string AudioBitrate { get; set; } = "128k";
[Range(1, 8)]
public int AudioChannels { get; set; } = 2;
[Range(8000, 192000)]
public int AudioSampleRate { get; set; } = 48000;
[Required]
public string AudioProfile { get; set; } = "aac_low";
public string AdditionalArguments { get; set; } = string.Empty;
}
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class DatabaseConfiguration
{
public const string SectionName = "Database";
[Required]
public required string ConnectionString { get; set; }
}
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class InfrastructureStorageConfiguration
{
public const string SectionName = "InfrastructureStorage";
[Url]
[Required]
public required string Address { get; set; }
[Required]
public required string AccessKey { get; set; }
[Required]
public required string SecretAccessKey { get; set; }
[Required]
public bool ForcePathStyle { get; set; }
[Required]
public bool UseHttp { get; set; }
}
}
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class MassTransitConfiguration
{
public const string SectionName = "MassTransit";
[Required]
public required string Address { get; set; }
[Required]
public required string Username { get; set; }
[Required]
public required string Password { get; set; }
}
}
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class OpenTelemetryConfiguration
{
public const string SectionName = "OpenTelemetry";
[Url]
[Required]
public required string OtlpEndpoint { get; set; }
}
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class StreamTicketConfiguration
{
public const string SectionName = "StreamTicket";
[Required]
public required string PrivateKeyPath { get; set; }
[Required]
public string Audience { get; set; } = "media-gateway";
[Required]
public string Issuer { get; set; } = "musify-webapi";
[Range(60, 86400)]
public int TicketTtlSeconds { get; set; } = 3600;
}
}
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class WorkerConfiguration
{
public const string SectionName = "Workers";
[Required]
public WorkerRoutes Routes { get; set; } = new WorkerRoutes();
}
public class WorkerRoutes
{
[Required]
public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath();
}
}
@@ -0,0 +1,5 @@
{
"Database": {
"ConnectionString": ""
}
}
@@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Jobs
{
public class TemporalUploadsCleanUpJob(
IServiceScopeFactory scopeFactory,
ILogger<TemporalUploadsCleanUpJob> logger,
ApplicationStorageConfiguration storageConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})",
uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds,
uploadIntentConfiguration.TemporalRootPrefix);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunAsync(stoppingToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Error in TemporalUploadsCleanUpJob");
}
await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds), stoppingToken);
}
}
async Task RunAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TemporalUploadsRetentionDays);
var deleted = 0;
var errors = 0;
await foreach (var (key, lastModified) in storageService.ListObjectsAsync(
storageConfiguration.Bucket,
uploadIntentConfiguration.TemporalRootPrefix,
cancellationToken))
{
if (lastModified >= cutOff)
continue;
try
{
await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken);
deleted++;
}
catch (Exception exception)
{
errors++;
logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key);
}
}
if (deleted > 0 || errors > 0)
logger.LogInformation("TemporalUploadsCleanUpJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors);
}
}
}
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Jobs
{
public class UploadIntentExpirationJob(
IServiceScopeFactory scopeFactory,
ILogger<UploadIntentExpirationJob> logger,
UploadIntentConfiguration uploadIntentConfiguration)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunAsync(stoppingToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Error in UploadIntentExpirationJob");
}
await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds), stoppingToken);
}
}
async Task RunAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var database = scope.ServiceProvider.GetRequiredService<IDatabase>();
var now = DateTime.UtcNow;
var expiredIntents = await database.UploadIntents
.Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now)
.ToListAsync(cancellationToken);
if (expiredIntents.Count > 0)
{
foreach (var intent in expiredIntents)
intent.Status = UploadIntentStatus.Expired;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count);
}
if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0)
{
var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays);
var oldExpiredIntents = await database.UploadIntents
.Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff)
.ToListAsync(cancellationToken);
if (oldExpiredIntents.Count > 0)
{
database.UploadIntents.RemoveRange(oldExpiredIntents);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count);
}
}
}
}
}
@@ -0,0 +1,73 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities
{
internal class GenerateAudioWorkflowPathsActivity(
IDatabase database,
ILogger<GenerateAudioWorkflowPathsActivity> logger)
: IExecuteActivity<GenerateAudioWorkflowPathsArguments>
{
public const string ExecuteEndpointName = "generate-audio-workflow-paths";
public async Task<ExecutionResult> Execute(ExecuteContext<GenerateAudioWorkflowPathsArguments> executeContext)
{
Track track;
try
{
var getTrack = await database.Tracks.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken);
track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found.");
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to retrieve track for TrackId {TrackId}",
executeContext.Arguments.TrackId);
throw;
}
try
{
track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing;
var folderName = Guid.NewGuid().ToString();
var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey);
var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName);
var sourceFilePath = Path.Combine(workingDirectory, sourceFileName);
Directory.CreateDirectory(workingDirectory);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory);
return executeContext.CompletedWithVariables(new Dictionary<string, object>
{
[RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory,
[RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath,
[RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory
} );
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate audio workflow paths for {SourceKey}",
executeContext.Arguments.SourceKey);
try
{
track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(executeContext.CancellationToken);
}
catch (Exception dbException)
{
logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}",
executeContext.Arguments.TrackId);
}
throw;
}
}
}
}
@@ -0,0 +1,89 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
internal class TranscodeAudioActivity(
IDatabase database,
IAudioTranscoderService audioTranscoder,
ILogger<TranscodeAudioActivity> logger)
: IActivity<TranscodeAudioArguments, TranscodeAudioLog>
{
public const string ExecuteEndpointName = "transcode-audio";
public async Task<ExecutionResult> Execute(ExecuteContext<TranscodeAudioArguments> executeContext)
{
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.SourceFilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath));
var workingDirectory = executeContext.GetVariable<string>(executeContext.Arguments.WorkingDirectoryVariable);
ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory));
Track track;
try
{
var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken);
track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found");
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId);
throw;
}
try
{
await using (var fileStream = File.OpenRead(sourceFilePath))
{
await audioTranscoder.TranscodeToAudioFileAsync(
fileStream,
workingDirectory,
executeContext.CancellationToken);
}
File.Delete(sourceFilePath);
return executeContext.Completed();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to transcode {SourceFilePath}", sourceFilePath);
try
{
track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(executeContext.CancellationToken);
}
catch (Exception dbException)
{
logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}",
executeContext.Arguments.TrackId);
}
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<TranscodeAudioLog> compensateContext)
{
try
{
if (Directory.Exists(compensateContext.Log.WorkingDirectory))
Directory.Delete(compensateContext.Log.WorkingDirectory, recursive: true);
else
logger.LogWarning("Working directory {WorkingDirectory} not found during compensation",
compensateContext.Log.WorkingDirectory);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate transcoded audio directory {WorkingDirectory}", compensateContext.Log.WorkingDirectory);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
}
@@ -0,0 +1,101 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
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,
ILogger<UpdateTrackAudioActivity> logger)
: IActivity<UpdateTrackAudioArguments, UpdateTrackAudioLog>
{
public const string ExecuteEndpointName = "update-track-audio";
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateTrackAudioArguments> executeContext)
{
Track track;
try
{
var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken);
track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found");
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to retrieve track {TrackId}",
executeContext.Arguments.TrackId);
throw;
}
var audioFolderKey = executeContext.Arguments.AudioFolderKey;
logger.LogDebug("Resolved audio folder key: {AudioFolderKey}", audioFolderKey);
if (string.IsNullOrWhiteSpace(audioFolderKey))
{
logger.LogWarning("Audio folder key is empty for track {TrackId}; skipping update", track.Id);
return executeContext.Completed();
}
try
{
var log = new UpdateTrackAudioLog(
track.Id,
track.AudioFolderName);
track.AudioFolderName = Path.GetFileName(audioFolderKey);
track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId);
return executeContext.Completed(log);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update track {TrackId} audio",
executeContext.Arguments.TrackId);
try
{
track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(executeContext.CancellationToken);
}
catch (Exception dbException)
{
logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}",
executeContext.Arguments.TrackId);
}
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdateTrackAudioLog> compensateContext)
{
try
{
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken);
if (track is null)
throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found");
track.AudioFolderName = compensateContext.Log.PreviousAudioFolderName;
track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(compensateContext.CancellationToken);
logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate track {TrackId} audio",
compensateContext.Log.TrackId);
return compensateContext.Failed(exception);
}
}
}
}
@@ -0,0 +1,61 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
internal class CopyFileInBucketActivity(
IStorageService storageHandler,
ILogger<CopyFileInBucketActivity> logger)
: IActivity<CopyFileInBucketArguments, CopyFileInBucketLog>
{
public const string ExecuteEndpointName = "copy-file-in-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<CopyFileInBucketArguments> executeContext)
{
try
{
await storageHandler.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)
{
logger.LogError(exception, "Failed to copy {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}",
executeContext.Arguments.SourceBucket,
executeContext.Arguments.SourceKey,
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationKey);
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<CopyFileInBucketLog> compensateContext)
{
try
{
await storageHandler.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);
}
}
}
}
@@ -0,0 +1,63 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
internal class DownloadFileFromBucketActivity(
IStorageService storageHandler,
ILogger<DownloadFileFromBucketActivity> logger)
: IActivity<DownloadFileFromBucketArguments, DownloadFileFromBucketLog>
{
public const string ExecuteEndpointName = "download-file-from-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromBucketArguments> executeContext)
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
try
{
using var fileStream = await storageHandler.GetFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken);
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)
{
logger.LogError(exception, "Failed to download file from bucket");
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromBucketLog> compensateContext)
{
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));
}
}
}
}
@@ -0,0 +1,46 @@
using MassTransit;
using Amazon.S3;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using System.Net;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
internal class RemoveFileFromBucketActivity(
IStorageService storageHandler,
ILogger<RemoveFileFromBucketActivity> logger)
: IExecuteActivity<RemoveFileFromBucketArguments>
{
public const string ExecuteEndpointName = "remove-file-from-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<RemoveFileFromBucketArguments> executeContext)
{
try
{
await storageHandler.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();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}",
executeContext.Arguments.Key,
executeContext.Arguments.Bucket);
throw;
}
}
}
}
@@ -0,0 +1,66 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class TransferFilesToBucketActivity(
IStorageService storageHandler,
ILogger<TransferFilesToBucketActivity> logger)
: IActivity<TransferFilesToBucketArguments, TransferFilesToBucketLog>
{
public const string ExecuteEndpointName = "transfer-files-to-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<TransferFilesToBucketArguments> executeContext)
{
var folderPath = executeContext.GetVariable<string>(executeContext.Arguments.SourceDirectoryVariable);
ArgumentNullException.ThrowIfNull(folderPath, nameof(folderPath));
var destinationKey = executeContext.Arguments.DestinationKey;
ArgumentNullException.ThrowIfNull(destinationKey, nameof(destinationKey));
try
{
var uploadedKeys = Directory.GetFiles(folderPath)
.Select(file => Path.Combine(destinationKey, Path.GetFileName(file)))
.ToArray();
await storageHandler.TransferFilesAsync(
folderPath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
return executeContext.Completed();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to transfer files from {FolderPath} to {DestinationBucket}",
folderPath,
executeContext.Arguments.DestinationBucket);
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<TransferFilesToBucketLog> compensateContext)
{
try
{
foreach (var uploadedKey in compensateContext.Log.UploadedKeys)
{
await storageHandler.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);
}
}
}
}
@@ -0,0 +1,61 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public class UploadFileToBucketActivity(IStorageService storageHandler,
ILogger<UploadFileToBucketActivity> logger)
: IActivity<UploadFileToBucketArguments, UploadFileToBucketLog>
{
public const string ExecuteEndpointName = "upload-file-to-bucket";
public async Task<ExecutionResult> Execute(ExecuteContext<UploadFileToBucketArguments> executeContext)
{
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.FilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(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('/', '\\')));
try
{
await storageHandler.UploadFileAsync(
sourceFilePath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
executeContext.Arguments.DestinationBucket,
destinationKey));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload file to bucket");
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UploadFileToBucketLog> compensateContext)
{
try
{
await storageHandler.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);
}
}
}
}
@@ -0,0 +1,51 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class GeneratePictureWorkflowPathsActivity(
ILogger<GeneratePictureWorkflowPathsActivity> logger)
: IExecuteActivity<GeneratePictureWorkflowPathsArguments>
{
public const string ExecuteEndpointName = "generate-picture-workflow-paths";
internal const string ResizedPictureFileExtension = ".webp";
public async Task<ExecutionResult> Execute(ExecuteContext<GeneratePictureWorkflowPathsArguments> executeContext)
{
try
{
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 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
} );
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate picture workflow paths for {SourceKey}",
executeContext.Arguments.SourceKey);
throw;
}
}
}
}
@@ -0,0 +1,72 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class ResizePictureActivity(
IPictureService pictureHandler,
ILogger<ResizePictureActivity> logger)
: IActivity<ResizePictureLocalArguments, ResizePictureLog>
{
public const string ExecuteEndpointName = "resize-picture";
public async Task<ExecutionResult> Execute(ExecuteContext<ResizePictureLocalArguments> executeContext)
{
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.SourceFilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath));
var destinationFilePath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationFilePath, nameof(destinationFilePath));
try
{
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);
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));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to resize picture {SourceFilePath}", sourceFilePath);
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<ResizePictureLog> compensateContext)
{
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));
}
}
}
}
@@ -0,0 +1,95 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class UpdatePlayListPictureActivity(
IDatabase database,
ILogger<UpdatePlayListPictureActivity> logger)
: IActivity<UpdatePlayListPictureArguments, UpdatePlayListPictureLog>
{
public const string ExecuteEndpointName = "update-playlist-picture";
public async Task<ExecutionResult> Execute(ExecuteContext<UpdatePlayListPictureArguments> executeContext)
{
var smallResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.SmallPictureVariable);
ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable));
var mediumResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.MediumPictureVariable);
ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable));
var largeResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.LargePictureVariable);
ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable));
try
{
var playList = await database.PlayLists.FindAsync(
[executeContext.Arguments.PlayListId],
cancellationToken: executeContext.CancellationToken);
if (playList is null)
{
logger.LogWarning("Playlist {PlayListId} not found",
executeContext.Arguments.PlayListId);
throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found");
}
var log = new UpdatePlayListPictureLog(
playList.Id,
playList.OriginalPictureName,
playList.SmallPictureName,
playList.MediumPictureName,
playList.LargePictureName);
playList.OriginalPictureName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey);
playList.SmallPictureName = Path.GetFileName(smallResizedVariable);
playList.MediumPictureName = Path.GetFileName(mediumResizedVariable);
playList.LargePictureName = Path.GetFileName(largeResizedVariable);
database.PlayLists.Update(playList);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated playlist {PlayListId} pictures",
executeContext.Arguments.PlayListId);
return executeContext.Completed();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId} pictures",
executeContext.Arguments.PlayListId);
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdatePlayListPictureLog> compensateContext)
{
try
{
var playList = await database.PlayLists.FindAsync(
[compensateContext.Log.PlayListId],
cancellationToken: compensateContext.CancellationToken);
if (playList is null)
{
return compensateContext.Compensated();
}
playList.OriginalPictureName = compensateContext.Log.PreviousOriginalPictureKey;
playList.SmallPictureName = compensateContext.Log.PreviousSmallPictureKey;
playList.MediumPictureName = compensateContext.Log.PreviousMediumPictureKey;
playList.LargePictureName = compensateContext.Log.PreviousLargePictureKey;
database.PlayLists.Update(playList);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate playlist {PlayListId} pictures", compensateContext.Log.PlayListId);
return compensateContext.Failed(exception);
}
}
}
}
@@ -0,0 +1,97 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class UpdateTrackPictureActivity(
IDatabase database,
ILogger<UpdateTrackPictureActivity> logger)
: IActivity<UpdateTrackPictureArguments, UpdateTrackPictureLog>
{
public const string ExecuteEndpointName = "update-track-picture";
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateTrackPictureArguments> executeContext)
{
var smallResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.SmallPictureVariable);
ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable));
var mediumResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.MediumPictureVariable);
ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable));
var largeResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.LargePictureVariable);
ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable));
try
{
var track = await database.Tracks.FindAsync(
[executeContext.Arguments.TrackId],
cancellationToken: executeContext.CancellationToken);
if (track is null)
{
logger.LogWarning("Track {TrackId} not found",
executeContext.Arguments.TrackId);
throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found");
}
var log = new UpdateTrackPictureLog(
track.Id,
track.OriginalPictureName,
track.SmallPictureName,
track.MediumPictureName,
track.LargePictureName);
track.OriginalPictureName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey);
track.SmallPictureName = Path.GetFileName(smallResizedVariable);
track.MediumPictureName = Path.GetFileName(mediumResizedVariable);
track.LargePictureName = Path.GetFileName(largeResizedVariable);
track.PicturesProcessingStatus = ProcessingStatus.Completed;
database.Tracks.Update(track);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated track {TrackId} pictures",
executeContext.Arguments.TrackId);
return executeContext.Completed();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update track {TrackId} pictures",
executeContext.Arguments.TrackId);
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdateTrackPictureLog> compensateContext)
{
try
{
var track = await database.Tracks.FindAsync(
[compensateContext.Log.TrackId],
cancellationToken: compensateContext.CancellationToken);
if (track is null)
return compensateContext.Compensated();
track.OriginalPictureName = compensateContext.Log.PreviousOriginalPictureKey;
track.SmallPictureName = compensateContext.Log.PreviousSmallPictureKey;
track.MediumPictureName = compensateContext.Log.PreviousMediumPictureKey;
track.LargePictureName = compensateContext.Log.PreviousLargePictureKey;
track.PicturesProcessingStatus = ProcessingStatus.Failed;
database.Tracks.Update(track);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate track {TrackId} pictures", compensateContext.Log.TrackId);
return compensateContext.Failed(exception);
}
}
}
}
@@ -0,0 +1,36 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.PlayLists
{
internal class DeletePlayListFromDbActivity(
IDatabase database,
ILogger<DeletePlayListFromDbActivity> logger)
: IExecuteActivity<DeletePlayListFromDbArguments>
{
public const string ExecuteEndpointName = "delete-playList-from-db";
public async Task<ExecutionResult> Execute(ExecuteContext<DeletePlayListFromDbArguments> executeContext)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken);
if (playList is null)
{
logger.LogInformation(
"PlayList {PlayListId} not found while deleting from DB, skipping",
executeContext.Arguments.PlayListId);
return executeContext.Completed();
}
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Deleted playlist {PlayListId} from DB", playList.Id);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,37 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit.Activities.PlayLists
{
internal class MarkPlayListAsRemovingActivity(
IDatabase database,
ILogger<MarkPlayListAsRemovingActivity> logger)
: IExecuteActivity<MarkPlayListAsRemovingArguments>
{
public const string ExecuteEndpointName = "mark-playList-as-removing";
public async Task<ExecutionResult> Execute(ExecuteContext<MarkPlayListAsRemovingArguments> executeContext)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken);
if (playList is null)
{
logger.LogInformation(
"PlayList {PlayListId} not found while marking as removing, skipping",
executeContext.Arguments.PlayListId);
return executeContext.Completed();
}
playList.LifeCycleStatus = LifeCycleStatus.Removing;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked playlist {PlayListId} as removing", playList.Id);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,34 @@
using MassTransit;
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>
{
public const string ExecuteEndpointName = "publish-playList-picture-processing-event";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishPlayListPictureProcessingEventArguments> executeContext)
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdatePlayListPictureEvent(
arguments.PlayListId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Small,
arguments.Medium,
arguments.Large),
executeContext.CancellationToken);
logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,36 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Tracks
{
internal class DeleteTrackFromDbActivity(
IDatabase database,
ILogger<DeleteTrackFromDbActivity> logger)
: IExecuteActivity<DeleteTrackFromDbArguments>
{
public const string ExecuteEndpointName = "delete-track-from-db";
public async Task<ExecutionResult> Execute(ExecuteContext<DeleteTrackFromDbArguments> executeContext)
{
var track = await database.Tracks
.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken);
if (track is null)
{
logger.LogInformation(
"Track {TrackId} not found while deleting from DB, skipping",
executeContext.Arguments.TrackId);
return executeContext.Completed();
}
database.Tracks.Remove(track);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Deleted track {TrackId} from DB", track.Id);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,37 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit.Activities.Tracks
{
internal class MarkTrackAsRemovingActivity(
IDatabase database,
ILogger<MarkTrackAsRemovingActivity> logger)
: IExecuteActivity<MarkTrackAsRemovingArguments>
{
public const string ExecuteEndpointName = "mark-track-as-removing";
public async Task<ExecutionResult> Execute(ExecuteContext<MarkTrackAsRemovingArguments> executeContext)
{
var track = await database.Tracks
.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken);
if (track is null)
{
logger.LogInformation(
"Track {TrackId} not found while marking as removing, skipping",
executeContext.Arguments.TrackId);
return executeContext.Completed();
}
track.LifeCycleStatus = LifeCycleStatus.Removing;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked track {TrackId} as removing", track.Id);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,43 @@
using MassTransit;
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>
{
public const string ExecuteEndpointName = "publish-track-processing-events";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishTrackProcessingEventsArguments> executeContext)
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Small,
arguments.Medium,
arguments.Large),
executeContext.CancellationToken);
await publishEndpoint.Publish(
new UpdateTrackAudioEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalAudioKey,
arguments.Bucket,
arguments.AudioDestinationFolderKey),
executeContext.CancellationToken);
logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,62 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
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>
{
public const string ExecuteEndpointName = "consume-upload-intents";
public async Task<ExecutionResult> Execute(ExecuteContext<ConsumeUploadIntentsArguments> executeContext)
{
var intentIds = executeContext.Arguments.IntentIds;
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
{
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);
}
}
}
}
@@ -0,0 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record ConsumeUploadIntentsArguments(
Guid[] IntentIds);
}
@@ -0,0 +1,8 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record CopyFileInBucketArguments(
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationKey);
}
@@ -0,0 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
internal record DeletePlayListFromDbArguments(
Guid PlayListId);
}
@@ -0,0 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
internal record DeleteTrackFromDbArguments(
Guid TrackId);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record DownloadFileFromBucketArguments(
string Bucket,
string Key,
string DestinationFilePathVariable);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record GenerateAudioWorkflowPathsArguments(
Guid TrackId,
string TemporaryRootDirectory,
string SourceKey);
}
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record GeneratePictureWorkflowPathsArguments(
string TemporaryRootDirectory,
string SourceKey);
}
@@ -0,0 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
internal record MarkPlayListAsRemovingArguments(
Guid PlayListId);
}
@@ -0,0 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
internal record MarkTrackAsRemovingArguments(
Guid TrackId);
}
@@ -0,0 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishPlayListPictureProcessingEventArguments(
Guid PlayListId,
string Bucket,
string FinalPictureKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishTrackProcessingEventsArguments(
Guid TrackId,
string Bucket,
string FinalPictureKey,
string FinalAudioKey,
string AudioDestinationFolderKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record RemoveFileFromBucketArguments(
string Bucket,
string Key);
}
@@ -0,0 +1,8 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record ResizePictureLocalArguments(
string SourceFilePathVariable,
string DestinationFilePathVariable,
int Width,
int Height);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record TranscodeAudioArguments(
Guid TrackId,
string SourceFilePathVariable,
string WorkingDirectoryVariable);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record TransferFilesToBucketArguments(
string DestinationBucket,
string SourceDirectoryVariable,
string DestinationKey);
}
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdatePlayListPictureArguments(
Guid PlayListId,
string OriginalPictureKey,
string SmallPictureVariable,
string MediumPictureVariable,
string LargePictureVariable);
}
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdateTrackAudioArguments(
Guid TrackId,
string AudioFolderKey);
}
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdateTrackPictureArguments(
Guid TrackId,
string OriginalPictureKey,
string SmallPictureVariable,
string MediumPictureVariable,
string LargePictureVariable);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UploadFileToBucketArguments(
string FilePathVariable,
string DestinationBucket,
string DestinationRoute);
}
@@ -0,0 +1,23 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class CreatePlayListConsumer(
IBus bus,
PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreatePlayListResourcesEvent>
{
public const string QueueName = "create-playList";
public async Task Consume(ConsumeContext<CreatePlayListResourcesEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,23 @@
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>
{
public const string QueueName = "create-track";
public async Task Consume(ConsumeContext<CreateTrackResourcesEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,27 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class DeletePlayListConsumer(
IBus bus,
DeletePlayListRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeletePlayListEvent>
{
public const string QueueName = "delete-playList";
public async Task Consume(ConsumeContext<DeletePlayListEvent> consumeContext)
{
var routingSlip = (await routingSlipBuilder
.BuildAsync(
consumeContext.Message.PlayListId,
consumeContext.Message.UserId,
consumeContext.CorrelationId,
consumeContext.CancellationToken))
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,23 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class DeleteTrackConsumer(
IBus bus,
DeleteTrackRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DeleteTrackEvent>
{
public const string QueueName = "delete-track";
public async Task Consume(ConsumeContext<DeleteTrackEvent> consumeContext)
{
var routingSlip = (await routingSlipBuilder
.BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken))
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,11 @@
namespace Musify.Infrastructure.MassTransit.Consumers
{
internal static class EndpointHelper
{
internal static Uri BuildExecuteActivityUri(string endpointName) => new($"queue:{endpointName}_execute");
internal static Uri BuildCompensateActivityUri(string endpointName) => new($"queue:{endpointName}_compensate");
internal static Uri BuildConsumerUri(string endpointName) => new($"queue:{endpointName}");
}
}
@@ -0,0 +1,48 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Microsoft.Extensions.Logging;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class RoutingSlipCleanUpConsumer(
ILogger<RoutingSlipCleanUpConsumer> logger) :
IConsumer<RoutingSlipCompleted>,
IConsumer<RoutingSlipFaulted>
{
public const string QueueName = "routing-slip-events";
public Task Consume(ConsumeContext<RoutingSlipCompleted> consumeContext)
=> DeleteFolder(consumeContext.Message.Variables);
public Task Consume(ConsumeContext<RoutingSlipFaulted> consumeContext)
=> DeleteFolder(consumeContext.Message.Variables);
Task DeleteFolder(IDictionary<string, object> variables)
{
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject))
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 directoryNotFoundException)
{
logger.LogWarning(directoryNotFoundException, "Temporal directory at path {Path} was not found for deletion", pathString);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete temporal directory at path {Path}", pathString);
throw;
}
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,23 @@
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>
{
public const string QueueName = "update-playList-picture";
public async Task Consume(ConsumeContext<UpdatePlayListPictureEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,23 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class UpdatePlayListPictureSourceConsumer(
IBus bus,
PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdatePlayListPictureSourceEvent>
{
public const string QueueName = "update-playList-picture-source";
public async Task Consume(ConsumeContext<UpdatePlayListPictureSourceEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,23 @@
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>
{
public const string QueueName = "update-track-audio";
public async Task Consume(ConsumeContext<UpdateTrackAudioEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,23 @@
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>
{
public const string QueueName = "update-track-picture";
public async Task Consume(ConsumeContext<UpdateTrackPictureEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,91 @@
using MassTransit;
using Musify.Infrastructure.MassTransit.Consumers;
using Musify.Infrastructure.Persistence;
namespace Musify.Infrastructure.MassTransit
{
public static partial class MassTransitDependencyInjection
{
static void ConfigureConsumerEndpoint<TConsumer>(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
IBusRegistrationContext busRegistrationContext,
string queueName)
where TConsumer : class, IConsumer
{
busFactoryConfigurator.ReceiveEndpoint(queueName, endpointConfigurator =>
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.ConfigureConsumer<TConsumer>(busRegistrationContext);
});
}
static void ConfigureExecuteActivityEndpoint<TActivity, TArguments>(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
IBusRegistrationContext busRegistrationContext,
string endpointName)
where TActivity : class, IExecuteActivity<TArguments>
where TArguments : class
{
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator =>
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.ExecuteActivityHost<TActivity, TArguments>(busRegistrationContext);
});
}
static void ConfigureActivityEndpoint<TActivity, TArguments, TLog>(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
IBusRegistrationContext busRegistrationContext,
string endpointName)
where TActivity : class, IActivity<TArguments, TLog>
where TArguments : class
where TLog : class
{
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator =>
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.ExecuteActivityHost<TActivity, TArguments>(
EndpointHelper.BuildCompensateActivityUri(endpointName),
busRegistrationContext);
});
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.CompensateQueue(endpointName), endpointConfigurator =>
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.CompensateActivityHost<TActivity, TLog>(busRegistrationContext);
});
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Musify.Infrastructure.MassTransit
{
public static partial class MassTransitDependencyInjection
{
static void RegisterValidatedOptions<TOptions>(
IServiceCollection services,
IConfiguration configuration,
string sectionName)
where TOptions : class
{
services
.AddOptionsWithValidateOnStart<TOptions>()
.Bind(configuration.GetRequiredSection(sectionName))
.ValidateDataAnnotations();
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<TOptions>>().Value);
}
}
}
@@ -0,0 +1,212 @@
using MassTransit;
using Microsoft.Extensions.DependencyInjection;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Activities;
using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.PlayLists;
using Musify.Infrastructure.MassTransit.Activities.Tracks;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
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
{
static void RegisterRoutingSlipBuilders(IServiceCollection services)
{
services.AddScoped<AudioWorkflowRoutingSlipBuilder>();
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
services.AddScoped<DeleteTrackRoutingSlipBuilder>();
services.AddScoped<DeletePlayListRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
}
static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options)
{
options.AddConsumer<UpdatePlayListPictureConsumer>();
options.AddConsumer<UpdateTrackPictureConsumer>();
options.AddConsumer<RoutingSlipCleanUpConsumer>();
options.AddConsumer<UpdateTrackAudioConsumer>();
options.AddConsumer<DeleteTrackConsumer>();
options.AddConsumer<DeletePlayListConsumer>();
options.AddConsumer<CreateTrackConsumer>();
options.AddConsumer<CreatePlayListConsumer>();
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>();
options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>();
options.AddExecuteActivity<GeneratePictureWorkflowPathsActivity, GeneratePictureWorkflowPathsArguments>();
options.AddExecuteActivity<MarkTrackAsRemovingActivity, MarkTrackAsRemovingArguments>();
options.AddExecuteActivity<DeleteTrackFromDbActivity, DeleteTrackFromDbArguments>();
options.AddExecuteActivity<MarkPlayListAsRemovingActivity, MarkPlayListAsRemovingArguments>();
options.AddExecuteActivity<DeletePlayListFromDbActivity, DeletePlayListFromDbArguments>();
options.AddExecuteActivity<PublishTrackProcessingEventsActivity, PublishTrackProcessingEventsArguments>();
options.AddExecuteActivity<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>();
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
options.AddActivity<UpdatePlayListPictureActivity, UpdatePlayListPictureArguments, UpdatePlayListPictureLog>();
options.AddActivity<UpdateTrackPictureActivity, UpdateTrackPictureArguments, UpdateTrackPictureLog>();
options.AddActivity<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>();
options.AddActivity<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>();
options.AddActivity<TransferFilesToBucketActivity, TransferFilesToBucketArguments, TransferFilesToBucketLog>();
options.AddActivity<UpdateTrackAudioActivity, UpdateTrackAudioArguments, UpdateTrackAudioLog>();
options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
}
static void ConfigureRabbitMqWorkerEndpoints(
IBusRegistrationContext busRegistrationContext,
IRabbitMqBusFactoryConfigurator busFactoryConfigurator)
{
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MassTransitConfiguration>());
ConfigureConsumerEndpoint<UpdatePlayListPictureConsumer>(
busFactoryConfigurator,
busRegistrationContext,
UpdatePlayListPictureConsumer.QueueName);
ConfigureConsumerEndpoint<UpdateTrackAudioConsumer>(
busFactoryConfigurator,
busRegistrationContext,
UpdateTrackAudioConsumer.QueueName);
ConfigureConsumerEndpoint<UpdateTrackPictureConsumer>(
busFactoryConfigurator,
busRegistrationContext,
UpdateTrackPictureConsumer.QueueName);
ConfigureConsumerEndpoint<DeleteTrackConsumer>(
busFactoryConfigurator,
busRegistrationContext,
DeleteTrackConsumer.QueueName);
ConfigureConsumerEndpoint<DeletePlayListConsumer>(
busFactoryConfigurator,
busRegistrationContext,
DeletePlayListConsumer.QueueName);
ConfigureConsumerEndpoint<CreateTrackConsumer>(
busFactoryConfigurator,
busRegistrationContext,
CreateTrackConsumer.QueueName);
ConfigureConsumerEndpoint<CreatePlayListConsumer>(
busFactoryConfigurator,
busRegistrationContext,
CreatePlayListConsumer.QueueName);
ConfigureConsumerEndpoint<UpdatePlayListPictureSourceConsumer>(
busFactoryConfigurator,
busRegistrationContext,
UpdatePlayListPictureSourceConsumer.QueueName);
ConfigureConsumerEndpoint<RoutingSlipCleanUpConsumer>(
busFactoryConfigurator,
busRegistrationContext,
RoutingSlipCleanUpConsumer.QueueName);
ConfigureExecuteActivityEndpoint<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>(
busFactoryConfigurator,
busRegistrationContext,
RemoveFileFromBucketActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>(
busFactoryConfigurator,
busRegistrationContext,
GenerateAudioWorkflowPathsActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<GeneratePictureWorkflowPathsActivity, GeneratePictureWorkflowPathsArguments>(
busFactoryConfigurator,
busRegistrationContext,
GeneratePictureWorkflowPathsActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<MarkTrackAsRemovingActivity, MarkTrackAsRemovingArguments>(
busFactoryConfigurator,
busRegistrationContext,
MarkTrackAsRemovingActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<DeleteTrackFromDbActivity, DeleteTrackFromDbArguments>(
busFactoryConfigurator,
busRegistrationContext,
DeleteTrackFromDbActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<MarkPlayListAsRemovingActivity, MarkPlayListAsRemovingArguments>(
busFactoryConfigurator,
busRegistrationContext,
MarkPlayListAsRemovingActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<DeletePlayListFromDbActivity, DeletePlayListFromDbArguments>(
busFactoryConfigurator,
busRegistrationContext,
DeletePlayListFromDbActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<PublishTrackProcessingEventsActivity, PublishTrackProcessingEventsArguments>(
busFactoryConfigurator,
busRegistrationContext,
PublishTrackProcessingEventsActivity.ExecuteEndpointName);
ConfigureExecuteActivityEndpoint<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>(
busFactoryConfigurator,
busRegistrationContext,
PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>(
busFactoryConfigurator,
busRegistrationContext,
ResizePictureActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<UpdatePlayListPictureActivity, UpdatePlayListPictureArguments, UpdatePlayListPictureLog>(
busFactoryConfigurator,
busRegistrationContext,
UpdatePlayListPictureActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<UpdateTrackPictureActivity, UpdateTrackPictureArguments, UpdateTrackPictureLog>(
busFactoryConfigurator,
busRegistrationContext,
UpdateTrackPictureActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>(
busFactoryConfigurator,
busRegistrationContext,
DownloadFileFromBucketActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>(
busFactoryConfigurator,
busRegistrationContext,
TranscodeAudioActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<TransferFilesToBucketActivity, TransferFilesToBucketArguments, TransferFilesToBucketLog>(
busFactoryConfigurator,
busRegistrationContext,
TransferFilesToBucketActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<UpdateTrackAudioActivity, UpdateTrackAudioArguments, UpdateTrackAudioLog>(
busFactoryConfigurator,
busRegistrationContext,
UpdateTrackAudioActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>(
busFactoryConfigurator,
busRegistrationContext,
UploadFileToBucketActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>(
busFactoryConfigurator,
busRegistrationContext,
CopyFileInBucketActivity.ExecuteEndpointName);
ConfigureActivityEndpoint<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>(
busFactoryConfigurator,
busRegistrationContext,
ConsumeUploadIntentsActivity.ExecuteEndpointName);
}
}
}
@@ -0,0 +1,85 @@
using MassTransit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Persistence;
namespace Musify.Infrastructure.MassTransit
{
public static partial class MassTransitDependencyInjection
{
public static IServiceCollection AddMassTransitClient(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
RegisterValidatedOptions<MassTransitConfiguration>(
serviceDescriptors,
configuration,
MassTransitConfiguration.SectionName);
serviceDescriptors.AddScoped<IEventBus, MassTransitEventBus>();
serviceDescriptors.AddMassTransit(options =>
{
options.AddEntityFrameworkOutbox<Database>(outbox =>
{
outbox.UsePostgres();
outbox.UseBusOutbox();
outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
});
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MassTransitConfiguration>()));
} );
return serviceDescriptors;
}
public static IServiceCollection AddMassTransitConsumers(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
RegisterValidatedOptions<MassTransitConfiguration>(
serviceDescriptors,
configuration,
MassTransitConfiguration.SectionName);
RegisterValidatedOptions<WorkerConfiguration>(
serviceDescriptors,
configuration,
WorkerConfiguration.SectionName);
RegisterRoutingSlipBuilders(serviceDescriptors);
serviceDescriptors.AddMassTransit(options =>
{
options.AddEntityFrameworkOutbox<Database>(outbox =>
{
outbox.UsePostgres();
outbox.UseBusOutbox();
outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
});
RegisterConsumersAndActivities(options);
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqWorkerEndpoints(busRegistrationContext, busFactoryConfigurator));
} );
return serviceDescriptors;
}
static void ConfigureRabbitMqHost(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
MassTransitConfiguration massTransitConfiguration)
{
busFactoryConfigurator.Host(massTransitConfiguration.Address, options =>
{
options.Username(massTransitConfiguration.Username);
options.Password(massTransitConfiguration.Password);
} );
}
}
}
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit
{
internal static class MassTransitEndpointNames
{
internal static string ExecuteQueue(string endpointName) => $"{endpointName}_execute";
internal static string CompensateQueue(string endpointName) => $"{endpointName}_compensate";
}
}
@@ -0,0 +1,5 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record ConsumeUploadIntentsLog(
Guid[] IntentIds);
}
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record CopyFileInBucketLog(
string DestinationBucket,
string DestinationKey);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record DownloadFileFromBucketLog(
string Bucket,
string Key,
string DestinationFilePath);
}
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record GeneratePictureWorkflowPathsLog(string WorkingDirectory);
}
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record ResizePictureLog(string DestinationFilePath);
}
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record TranscodeAudioLog(string WorkingDirectory);
}
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys);
}
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdatePlayListPictureLog(
Guid PlayListId,
string PreviousOriginalPictureKey,
string PreviousSmallPictureKey,
string PreviousMediumPictureKey,
string PreviousLargePictureKey);
}
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdateTrackAudioLog(
Guid TrackId,
string? PreviousAudioFolderName);
}
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdateTrackPictureLog(
Guid TrackId,
string PreviousOriginalPictureKey,
string? PreviousSmallPictureKey,
string? PreviousMediumPictureKey,
string? PreviousLargePictureKey);
}
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey);
}
@@ -0,0 +1,16 @@
using MassTransit;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit
{
public class MassTransitEventBus(IPublishEndpoint publishEndpoint)
: IEventBus
{
public async Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class
{
ArgumentNullException.ThrowIfNull(message, nameof(message));
await publishEndpoint.Publish(message, cancellationToken);
}
}
}
@@ -0,0 +1,51 @@
namespace Musify.Infrastructure.MassTransit.RoutingSlip
{
internal static class ActivityNames
{
internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving";
internal const string DeleteTrackFromDb = "DeleteTrackFromDb";
internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving";
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
internal const string DownloadFile = "DownloadFile";
internal const string TranscodeAudio = "TranscodeAudio";
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 TransferFiles = "TransferFiles";
internal const string UpdateTrackPicture = "UpdateTrackPicture";
internal const string UpdatePlayListPicture = "UpdatePlayListPicture";
internal const string UpdateTrackAudio = "UpdateTrackAudio";
internal const string CopyPictureToFinal = "CopyPictureToFinal";
internal const string CopyAudioToFinal = "CopyAudioToFinal";
internal const string ConsumeUploadIntents = "ConsumeUploadIntents";
internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents";
internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent";
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";
}
}
@@ -0,0 +1,66 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
{
public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.GenerateAudioWorkflowPaths,
EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName),
new GenerateAudioWorkflowPathsArguments(
message.TrackId,
workerConfiguration.Routes.TemporaryFilesDirectory,
message.SourceKey));
routingSlipBuilder.AddActivity(
ActivityNames.DownloadFile,
EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName),
new DownloadFileFromBucketArguments(
message.SourceBucket,
message.SourceKey,
RoutingSlipVariableNames.Audio.SourceFilePath));
routingSlipBuilder.AddActivity(
ActivityNames.TranscodeAudio,
EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName),
new TranscodeAudioArguments(
message.TrackId,
RoutingSlipVariableNames.Audio.SourceFilePath,
RoutingSlipVariableNames.Workflow.TemporalDirectory));
routingSlipBuilder.AddActivity(
ActivityNames.TransferFiles,
EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName),
new TransferFilesToBucketArguments(
message.DestinationBucket,
RoutingSlipVariableNames.Audio.TranscodedDirectory,
message.DestinationFolderKey));
routingSlipBuilder.AddActivity(
ActivityNames.UpdateTrackAudio,
EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName),
new UpdateTrackAudioArguments(
message.TrackId,
message.DestinationFolderKey));
return routingSlipBuilder;
}
}
}
@@ -0,0 +1,63 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.Tracks;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class CreateTrackRoutingSlipBuilder
{
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.CopyPictureToFinal,
EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName),
new CopyFileInBucketArguments(
message.Bucket,
message.PictureSourceKey,
message.Bucket,
message.PictureDestinationKey));
routingSlipBuilder.AddActivity(
ActivityNames.CopyAudioToFinal,
EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName),
new CopyFileInBucketArguments(
message.Bucket,
message.AudioSourceKey,
message.Bucket,
message.AudioDestinationKey));
routingSlipBuilder.AddActivity(
ActivityNames.ConsumeUploadIntents,
EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName),
new ConsumeUploadIntentsArguments(
[message.PictureIntentId, message.AudioIntentId]));
routingSlipBuilder.AddActivity(
ActivityNames.PublishTrackProcessingEvents,
EndpointHelper.BuildExecuteActivityUri(PublishTrackProcessingEventsActivity.ExecuteEndpointName),
new PublishTrackProcessingEventsArguments(
message.TrackId,
message.Bucket,
message.PictureDestinationKey,
message.AudioDestinationKey,
message.AudioProcessedFolderKey,
message.Small,
message.Medium,
message.Large));
return routingSlipBuilder;
}
}
}
@@ -0,0 +1,94 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.PlayLists;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class DeletePlayListRoutingSlipBuilder(
IDatabase database,
PlayListConfiguration playListConfiguration,
ApplicationStorageConfiguration storageConfiguration)
{
public async Task<RoutingSlipBuilder> BuildAsync(
Guid playListId,
long userId,
Guid? correlationId,
CancellationToken cancellationToken)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
var playList = await database.PlayLists
.AsNoTracking()
.SingleOrDefaultAsync(p => p.Id == playListId, cancellationToken);
routingSlipBuilder.AddActivity(
ActivityNames.MarkPlayListAsRemoving,
EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName),
new MarkPlayListAsRemovingArguments(playListId));
if (playList is not null)
{
var bucket = storageConfiguration.Bucket;
var routes = playListConfiguration.Routes;
if (playList.OriginalPictureName != routes.PresetOriginalPicture)
{
routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListOriginalPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
routes.BuildOriginalPicturePath(userId, playList.OriginalPictureName)));
}
if (playList.SmallPictureName != routes.PresetSmallPicture)
{
routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListSmallPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
routes.BuildSmallPicturePath(playList.SmallPictureName)));
}
if (playList.MediumPictureName != routes.PresetMediumPicture)
{
routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListMediumPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
routes.BuildMediumPicturePath(playList.MediumPictureName)));
}
if (playList.LargePictureName != routes.PresetLargePicture)
{
routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListLargePicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
routes.BuildLargePicturePath(playList.LargePictureName)));
}
}
routingSlipBuilder.AddActivity(
ActivityNames.DeletePlayListFromDb,
EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName),
new DeletePlayListFromDbArguments(playListId));
return routingSlipBuilder;
}
}
}
@@ -0,0 +1,101 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.Tracks;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class DeleteTrackRoutingSlipBuilder(
IDatabase database,
TrackConfiguration trackConfiguration,
ApplicationStorageConfiguration storageConfiguration)
{
public async Task<RoutingSlipBuilder> BuildAsync(
Guid trackId,
long userId,
Guid? correlationId,
CancellationToken cancellationToken)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken);
routingSlipBuilder.AddActivity(
ActivityNames.MarkTrackAsRemoving,
EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName),
new MarkTrackAsRemovingArguments(trackId));
if (track is not null)
{
var bucket = storageConfiguration.Bucket;
routingSlipBuilder.AddActivity(
ActivityNames.RemoveTrackOriginalPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName)));
if (track.IsPicturesProcessed)
{
routingSlipBuilder.AddActivity(
ActivityNames.RemoveTrackSmallPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName)));
routingSlipBuilder.AddActivity(
ActivityNames.RemoveTrackMediumPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName)));
routingSlipBuilder.AddActivity(
ActivityNames.RemoveTrackLargePicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName)));
}
routingSlipBuilder.AddActivity(
ActivityNames.RemoveTrackOriginalAudio,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName)));
if (track.IsAudioProcessed)
{
routingSlipBuilder.AddActivity(
ActivityNames.RemoveTrackProcessedAudio,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(
bucket,
trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName)));
}
}
routingSlipBuilder.AddActivity(
ActivityNames.DeleteTrackFromDb,
EndpointHelper.BuildExecuteActivityUri(DeleteTrackFromDbActivity.ExecuteEndpointName),
new DeleteTrackFromDbArguments(trackId));
return routingSlipBuilder;
}
}
}
@@ -0,0 +1,144 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Activities;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
{
public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId)
{
var routingSlipBuilder = BuildPictureWorkflow(
message.Bucket,
message.SourceKey,
message.Small,
message.Medium,
message.Large,
correlationId);
routingSlipBuilder.AddActivity(
ActivityNames.UpdateTrackPicture,
EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName),
new UpdateTrackPictureArguments(
message.TrackId,
message.SourceKey,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath));
return routingSlipBuilder;
}
public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId)
{
var routingSlipBuilder = BuildPictureWorkflow(
message.Bucket,
message.SourceKey,
message.Small,
message.Medium,
message.Large,
correlationId);
routingSlipBuilder.AddActivity(
ActivityNames.UpdatePlayListPicture,
EndpointHelper.BuildExecuteActivityUri(UpdatePlayListPictureActivity.ExecuteEndpointName),
new UpdatePlayListPictureArguments(
message.PlayListId,
message.SourceKey,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath));
return routingSlipBuilder;
}
RoutingSlipBuilder BuildPictureWorkflow(
string bucket,
string sourceKey,
ImageSize small,
ImageSize medium,
ImageSize large,
Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.GeneratePictureWorkflowPaths,
EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName),
new GeneratePictureWorkflowPathsArguments(
workerConfiguration.Routes.TemporaryFilesDirectory,
sourceKey));
routingSlipBuilder.AddActivity(
ActivityNames.DownloadFile,
EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName),
new DownloadFileFromBucketArguments(
bucket,
sourceKey,
RoutingSlipVariableNames.Picture.OriginalFilePath));
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeSmall,
ActivityNames.UploadSmall,
bucket,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
small);
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeMedium,
ActivityNames.UploadMedium,
bucket,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
medium);
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeLarge,
ActivityNames.UploadLarge,
bucket,
RoutingSlipVariableNames.Picture.LargeResizedFilePath,
large);
return routingSlipBuilder;
}
static void AddResizeActivity(
RoutingSlipBuilder routingSlipBuilder,
string resizeActivityName,
string uploadActivityName,
string bucketName,
string destinationFilePathVariableName,
ImageSize imageSize)
{
routingSlipBuilder.AddActivity(
resizeActivityName,
EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName),
new ResizePictureLocalArguments(
RoutingSlipVariableNames.Picture.OriginalFilePath,
destinationFilePathVariableName,
imageSize.Width,
imageSize.Height));
routingSlipBuilder.AddActivity(
uploadActivityName,
EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName),
new UploadFileToBucketArguments(
destinationFilePathVariableName,
bucketName,
imageSize.SavePath));
}
}
}
@@ -0,0 +1,85 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.PlayLists;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class PlayListPictureSourceRoutingSlipBuilder
{
public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId)
=> BuildSlip(
message.PlayListId,
message.PictureIntentId,
message.Bucket,
message.PictureSourceKey,
message.PictureDestinationKey,
message.Small,
message.Medium,
message.Large,
correlationId);
public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId)
=> BuildSlip(
message.PlayListId,
message.PictureIntentId,
message.Bucket,
message.PictureSourceKey,
message.PictureDestinationKey,
message.Small,
message.Medium,
message.Large,
correlationId);
static RoutingSlipBuilder BuildSlip(
Guid playListId,
Guid pictureIntentId,
string bucket,
string pictureSourceKey,
string pictureDestinationKey,
ImageSize small,
ImageSize medium,
ImageSize large,
Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.CopyPictureToFinal,
EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName),
new CopyFileInBucketArguments(
bucket,
pictureSourceKey,
bucket,
pictureDestinationKey));
routingSlipBuilder.AddActivity(
ActivityNames.ConsumeUploadIntents,
EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName),
new ConsumeUploadIntentsArguments([pictureIntentId]));
routingSlipBuilder.AddActivity(
ActivityNames.PublishPlayListPictureProcessingEvent,
EndpointHelper.BuildExecuteActivityUri(PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName),
new PublishPlayListPictureProcessingEventArguments(
playListId,
bucket,
pictureDestinationKey,
small,
medium,
large));
return routingSlipBuilder;
}
}
}
@@ -0,0 +1,25 @@
namespace Musify.Infrastructure.MassTransit
{
internal static class RoutingSlipVariableNames
{
internal static class Workflow
{
internal const string TemporalDirectory = "Workflow.TemporalDirectory";
internal const string CorrelationId = "Workflow.CorrelationId";
}
internal static class Audio
{
internal const string SourceFilePath = "Audio.SourceFilePath";
internal const string TranscodedDirectory = "Audio.TranscodedDirectory";
}
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";
}
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UserSecretsId>07d419fc-9ade-471a-bc0e-aebece785b30</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.EntityFrameworkCore" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="SixLabors.ImageSharp" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Musify.Application\Musify.Application.csproj" />
<ProjectReference Include="..\Musify.Domain\Musify.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Persistence\Migrations\" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Musify.Infrastructure.Configuration;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Musify.Infrastructure.Observability
{
public static class OpenTelemetryDependencyInjection
{
public static IServiceCollection AddObservability(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<OpenTelemetryConfiguration>()
.Bind(configuration.GetRequiredSection(OpenTelemetryConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<OpenTelemetryConfiguration>>().Value);
serviceDescriptors
.AddOptions<OtlpExporterOptions>()
.Configure<OpenTelemetryConfiguration>((exporterOptions, openTelemetryConfiguration) =>
{
exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint);
} );
serviceDescriptors
.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 serviceDescriptors;
}
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Musify.Domain.Abstractions;
namespace Musify.Infrastructure.Persistence
{
public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
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 is null)
return;
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using MassTransit;
using Musify.Domain.Entities;
using Musify.Infrastructure.Configuration;
using AppIDatabase = Musify.Application.Contracts.IDatabase;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Persistence
{
public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(configuration.ConnectionString);
optionsBuilder.AddInterceptors(new AuditableEntityInterceptor());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
}
public DbSet<User> Users => Set<User>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new DatabaseTransaction(transaction);
}
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken = default) =>
inner.CommitAsync(cancellationToken);
public ValueTask DisposeAsync() => inner.DisposeAsync();
}
}
}
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Persistence
{
public static class DatabaseDependencyInjection
{
public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<DatabaseConfiguration>()
.Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddDbContext<Database>();
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
return serviceDescriptors;
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Persistence
{
public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory<Database>
{
public Database CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("DesignSettings.json")
.AddUserSecrets<DatabaseDesignTimeFactory>()
.Build();
var databaseConfiguration = configuration
.GetRequiredSection(DatabaseConfiguration.SectionName)
.Get<DatabaseConfiguration>()
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
var optionsBuilder = new DbContextOptionsBuilder<Database>();
optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString);
return new Database(databaseConfiguration);
}
}
}
@@ -0,0 +1,578 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Musify.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
[Migration("20260530020027_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("SequenceNumber");
b.HasIndex("EnqueueTime");
b.HasIndex("ExpirationTime");
b.HasIndex("OutboxId", "SequenceNumber")
.IsUnique();
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
{
b.Property<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LargePictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SmallPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedDate")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioFolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("AudioTranscodeProcessingStatus")
.HasColumnType("integer");
b.Property<DateTime>("CreatedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("LargePictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalAudioName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("PicturesProcessingStatus")
.HasColumnType("integer");
b.Property<int>("RetryCount")
.HasColumnType("integer");
b.Property<string>("SmallPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("ExpectedSizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ObjectName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Purpose")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
.WithMany()
.HasForeignKey("OutboxId");
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
.WithMany()
.HasForeignKey("InboxMessageId", "InboxConsumerId")
.HasPrincipalKey("MessageId", "ConsumerId");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("PlayLists")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.PlayList", "PlayList")
.WithMany("PlayListTracks")
.HasForeignKey("PlayListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("PlayListTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayList");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("UserTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("UserTracks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Navigation("PlayListTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Navigation("PlayListTracks");
b.Navigation("UserTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Navigation("PlayLists");
b.Navigation("UserTracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,355 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InboxState",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
ConsumerId = table.Column<Guid>(type: "uuid", nullable: false),
LockId = table.Column<Guid>(type: "uuid", nullable: false),
RowVersion = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
Received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ReceiveCount = table.Column<int>(type: "integer", nullable: false),
ExpirationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSequenceNumber = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_InboxState", x => x.Id);
table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId });
});
migrationBuilder.CreateTable(
name: "OutboxState",
columns: table => new
{
OutboxId = table.Column<Guid>(type: "uuid", nullable: false),
LockId = table.Column<Guid>(type: "uuid", nullable: false),
RowVersion = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
Created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSequenceNumber = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OutboxState", x => x.OutboxId);
});
migrationBuilder.CreateTable(
name: "Tracks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
NormalizedTitle = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
OriginalPictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SmallPictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
MediumPictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
LargePictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
OriginalAudioName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AudioFolderName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
PicturesProcessingStatus = table.Column<int>(type: "integer", nullable: false),
AudioTranscodeProcessingStatus = table.Column<int>(type: "integer", nullable: false),
LifeCycleStatus = table.Column<int>(type: "integer", nullable: false),
RetryCount = table.Column<int>(type: "integer", nullable: false),
LastRetryAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tracks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Upload",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Bucket = table.Column<string>(type: "text", nullable: false),
Key = table.Column<string>(type: "text", nullable: false),
ContentType = table.Column<string>(type: "text", nullable: false),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
State = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Upload", x => x.Id);
});
migrationBuilder.CreateTable(
name: "User",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "character varying(48)", maxLength: 48, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(48)", maxLength: 48, nullable: false),
FirstName = table.Column<string>(type: "character varying(48)", maxLength: 48, nullable: true),
SecondName = table.Column<string>(type: "character varying(48)", maxLength: 48, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_User", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OutboxMessage",
columns: table => new
{
SequenceNumber = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EnqueueTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SentTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Headers = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
InboxMessageId = table.Column<Guid>(type: "uuid", nullable: true),
InboxConsumerId = table.Column<Guid>(type: "uuid", nullable: true),
OutboxId = table.Column<Guid>(type: "uuid", nullable: true),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
ContentType = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
MessageType = table.Column<string>(type: "text", nullable: false),
Body = table.Column<string>(type: "text", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: true),
CorrelationId = table.Column<Guid>(type: "uuid", nullable: true),
InitiatorId = table.Column<Guid>(type: "uuid", nullable: true),
RequestId = table.Column<Guid>(type: "uuid", nullable: true),
SourceAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
DestinationAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ResponseAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
FaultAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ExpirationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber);
table.ForeignKey(
name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId",
columns: x => new { x.InboxMessageId, x.InboxConsumerId },
principalTable: "InboxState",
principalColumns: new[] { "MessageId", "ConsumerId" });
table.ForeignKey(
name: "FK_OutboxMessage_OutboxState_OutboxId",
column: x => x.OutboxId,
principalTable: "OutboxState",
principalColumn: "OutboxId");
});
migrationBuilder.CreateTable(
name: "PlayLists",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Description = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
OriginalPictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SmallPictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
MediumPictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
LargePictureName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
LifeCycleStatus = table.Column<int>(type: "integer", nullable: false),
CreatedDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayLists", x => x.Id);
table.ForeignKey(
name: "FK_PlayLists_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UploadIntents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Bucket = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Key = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
ObjectName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ExpectedSizeBytes = table.Column<long>(type: "bigint", nullable: true),
Purpose = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UploadIntents", x => x.Id);
table.ForeignKey(
name: "FK_UploadIntents_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserHasTrack",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserHasTrack", x => x.Id);
table.ForeignKey(
name: "FK_UserHasTrack_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserHasTrack_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PlayListHasTrack",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PlayListId = table.Column<Guid>(type: "uuid", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
AddedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayListHasTrack", x => x.Id);
table.ForeignKey(
name: "FK_PlayListHasTrack_PlayLists_PlayListId",
column: x => x.PlayListId,
principalTable: "PlayLists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayListHasTrack_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_InboxState_Delivered",
table: "InboxState",
column: "Delivered");
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_EnqueueTime",
table: "OutboxMessage",
column: "EnqueueTime");
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_ExpirationTime",
table: "OutboxMessage",
column: "ExpirationTime");
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber",
table: "OutboxMessage",
columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_OutboxId_SequenceNumber",
table: "OutboxMessage",
columns: new[] { "OutboxId", "SequenceNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OutboxState_Created",
table: "OutboxState",
column: "Created");
migrationBuilder.CreateIndex(
name: "IX_PlayListHasTrack_PlayListId",
table: "PlayListHasTrack",
column: "PlayListId");
migrationBuilder.CreateIndex(
name: "IX_PlayListHasTrack_TrackId",
table: "PlayListHasTrack",
column: "TrackId");
migrationBuilder.CreateIndex(
name: "IX_PlayLists_UserId",
table: "PlayLists",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UploadIntents_UserId",
table: "UploadIntents",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserHasTrack_TrackId",
table: "UserHasTrack",
column: "TrackId");
migrationBuilder.CreateIndex(
name: "IX_UserHasTrack_UserId",
table: "UserHasTrack",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OutboxMessage");
migrationBuilder.DropTable(
name: "PlayListHasTrack");
migrationBuilder.DropTable(
name: "Upload");
migrationBuilder.DropTable(
name: "UploadIntents");
migrationBuilder.DropTable(
name: "UserHasTrack");
migrationBuilder.DropTable(
name: "InboxState");
migrationBuilder.DropTable(
name: "OutboxState");
migrationBuilder.DropTable(
name: "PlayLists");
migrationBuilder.DropTable(
name: "Tracks");
migrationBuilder.DropTable(
name: "User");
}
}
}
@@ -0,0 +1,583 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Musify.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
[Migration("20260531234306_AddUserProfilePictureUrl")]
partial class AddUserProfilePictureUrl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("SequenceNumber");
b.HasIndex("EnqueueTime");
b.HasIndex("ExpirationTime");
b.HasIndex("OutboxId", "SequenceNumber")
.IsUnique();
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
{
b.Property<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LargePictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SmallPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedDate")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioFolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("AudioTranscodeProcessingStatus")
.HasColumnType("integer");
b.Property<DateTime>("CreatedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("LargePictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalAudioName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("PicturesProcessingStatus")
.HasColumnType("integer");
b.Property<int>("RetryCount")
.HasColumnType("integer");
b.Property<string>("SmallPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("ExpectedSizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ObjectName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Purpose")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
.WithMany()
.HasForeignKey("OutboxId");
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
.WithMany()
.HasForeignKey("InboxMessageId", "InboxConsumerId")
.HasPrincipalKey("MessageId", "ConsumerId");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("PlayLists")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.PlayList", "PlayList")
.WithMany("PlayListTracks")
.HasForeignKey("PlayListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("PlayListTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayList");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("UserTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("UserTracks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Navigation("PlayListTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Navigation("PlayListTracks");
b.Navigation("UserTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Navigation("PlayLists");
b.Navigation("UserTracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserProfilePictureUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProfilePictureUrl",
table: "User",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProfilePictureUrl",
table: "User");
}
}
}
@@ -0,0 +1,583 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Musify.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
[Migration("20260601195757_RenameTimestampsAndAudit")]
partial class RenameTimestampsAndAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("SequenceNumber");
b.HasIndex("EnqueueTime");
b.HasIndex("ExpirationTime");
b.HasIndex("OutboxId", "SequenceNumber")
.IsUnique();
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
{
b.Property<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LargePictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SmallPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioFolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("AudioTranscodeProcessingStatus")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LargePictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalAudioName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("PicturesProcessingStatus")
.HasColumnType("integer");
b.Property<int>("RetryCount")
.HasColumnType("integer");
b.Property<string>("SmallPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("ExpectedSizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ObjectName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Purpose")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
.WithMany()
.HasForeignKey("OutboxId");
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
.WithMany()
.HasForeignKey("InboxMessageId", "InboxConsumerId")
.HasPrincipalKey("MessageId", "ConsumerId");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("PlayLists")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.PlayList", "PlayList")
.WithMany("PlayListTracks")
.HasForeignKey("PlayListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("PlayListTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayList");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("UserTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("UserTracks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Navigation("PlayListTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Navigation("PlayListTracks");
b.Navigation("UserTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Navigation("PlayLists");
b.Navigation("UserTracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RenameTimestampsAndAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "UpdatedDate",
table: "Tracks",
newName: "UpdatedAt");
migrationBuilder.RenameColumn(
name: "CreatedDate",
table: "Tracks",
newName: "CreatedAt");
migrationBuilder.RenameColumn(
name: "UpdatedDate",
table: "PlayLists",
newName: "UpdatedAt");
migrationBuilder.RenameColumn(
name: "CreatedDate",
table: "PlayLists",
newName: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "UpdatedAt",
table: "Tracks",
newName: "UpdatedDate");
migrationBuilder.RenameColumn(
name: "CreatedAt",
table: "Tracks",
newName: "CreatedDate");
migrationBuilder.RenameColumn(
name: "UpdatedAt",
table: "PlayLists",
newName: "UpdatedDate");
migrationBuilder.RenameColumn(
name: "CreatedAt",
table: "PlayLists",
newName: "CreatedDate");
}
}
}
@@ -0,0 +1,580 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Musify.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
partial class DatabaseModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("SequenceNumber");
b.HasIndex("EnqueueTime");
b.HasIndex("ExpirationTime");
b.HasIndex("OutboxId", "SequenceNumber")
.IsUnique();
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
{
b.Property<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LargePictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SmallPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioFolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("AudioTranscodeProcessingStatus")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LargePictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("MediumPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalAudioName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("PicturesProcessingStatus")
.HasColumnType("integer");
b.Property<int>("RetryCount")
.HasColumnType("integer");
b.Property<string>("SmallPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("ExpectedSizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ObjectName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Purpose")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
.WithMany()
.HasForeignKey("OutboxId");
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
.WithMany()
.HasForeignKey("InboxMessageId", "InboxConsumerId")
.HasPrincipalKey("MessageId", "ConsumerId");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("PlayLists")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.PlayList", "PlayList")
.WithMany("PlayListTracks")
.HasForeignKey("PlayListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("PlayListTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayList");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("UserTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("UserTracks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Navigation("PlayListTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Navigation("PlayListTracks");
b.Navigation("UserTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Navigation("PlayLists");
b.Navigation("UserTracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using System.Diagnostics;
namespace Musify.Infrastructure.Services
{
public class AudioTranscoderService(ILogger<AudioTranscoderService> logger,
AudioTranscoderConfiguration audioTranscoderConfiguration)
: IAudioTranscoderService
{
record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError);
public async Task<int> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken)
{
try
{
Directory.CreateDirectory(destinationPath);
logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath);
var executionResult = await ExecuteFfmpegWithInputAsync(
BuildAudioFileArguments(destinationPath, audioTranscoderConfiguration.Ffmpeg),
destinationPath,
audioStream,
"TranscodeToAudioFile",
cancellationToken);
if (executionResult.ExitCode != 0)
{
logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}",
executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError);
}
else
{
logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath);
}
return executionResult.ExitCode;
}
catch (TimeoutException timeoutException)
{
logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioTranscoderConfiguration.TranscodingTimeout, destinationPath);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to transcode audio for {DestinationPath}", destinationPath);
throw;
}
}
public async Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken)
{
if (!File.Exists(filePath))
{
logger.LogWarning("Audio validation requested for non-existing file {FilePath}", filePath);
return false;
}
try
{
logger.LogDebug("Validating audio file {FilePath}", filePath);
var executionResult = await ExecuteFfmpegAsync(
BuildValidateAudioArguments(filePath),
Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory,
"ValidateAudioFile",
cancellationToken);
var isValid = executionResult.ExitCode == 0;
if (!isValid)
{
logger.LogWarning("Audio validation failed for {FilePath}: {StandardError}", filePath, executionResult.StandardError);
}
else
{
logger.LogDebug("Audio validation successful for {FilePath}", filePath);
}
return isValid;
}
catch (TimeoutException timeoutException)
{
logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath);
}
return false;
}
async Task<FfmpegExecutionResult> ExecuteFfmpegWithInputAsync(
string arguments,
string workingDirectory,
Stream inputStream,
string operationName,
CancellationToken cancellationToken)
{
using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true);
process.Start();
logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory);
if (inputStream.CanSeek)
inputStream.Position = 0;
await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken);
process.StandardInput.Close();
return await WaitForProcessResultAsync(process, operationName, cancellationToken);
}
async Task<FfmpegExecutionResult> ExecuteFfmpegAsync(
string arguments,
string workingDirectory,
string operationName,
CancellationToken cancellationToken)
{
using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false);
process.Start();
logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory);
return await WaitForProcessResultAsync(process, operationName, cancellationToken);
}
Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput)
=> new()
{
StartInfo = new ProcessStartInfo
{
FileName = audioTranscoderConfiguration.Ffmpeg.ExecutableName,
WorkingDirectory = workingDirectory,
Arguments = arguments,
RedirectStandardInput = redirectStandardInput,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
async Task<FfmpegExecutionResult> WaitForProcessResultAsync(
Process process,
string operationName,
CancellationToken cancellationToken)
{
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCancellationTokens.CancelAfter(audioTranscoderConfiguration.TranscodingTimeout);
try
{
await process.WaitForExitAsync(linkedCancellationTokens.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId})",
operationName, audioTranscoderConfiguration.TranscodingTimeout, process.Id);
TryKillProcess(process);
throw new TimeoutException($"ffmpeg execution exceeded timeout {audioTranscoderConfiguration.TranscodingTimeout}.");
}
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);
}
void TryKillProcess(Process process)
{
try
{
if (!process.HasExited)
process.Kill();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId})", process.Id);
}
}
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}\"";
}
static string BuildValidateAudioArguments(string filePath)
=> $"-v error -i \"{filePath}\" -map 0:a:0 -f null -";
}
}
@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Musify.Infrastructure.Services
{
public class PictureService(ILogger<PictureService> logger)
: IPictureService
{
public async Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken)
{
try
{
logger.LogDebug("Resizing picture to {Width}x{Height}", width, height);
if (pictureStream.CanSeek && pictureStream.Position > 0)
pictureStream.Position = 0;
using var picture = await Image.LoadAsync(pictureStream, cancellationToken);
picture.Mutate(options => options.Resize(new ResizeOptions
{
Size = new Size(width, height),
Mode = ResizeMode.Max
} ));
var memoryStream = new MemoryStream();
await picture.SaveAsWebpAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length);
return memoryStream;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height);
throw;
}
}
}
}
@@ -0,0 +1,100 @@
using Amazon.S3;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Musify.Application.Configuration;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Jobs;
using Musify.Application.Services;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Services
{
public static class ServicesDependencyInjection
{
public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<InfrastructureStorageConfiguration>()
.Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<InfrastructureStorageConfiguration>>().Value);
serviceDescriptors.AddScoped<IAmazonS3>(serviceProvider =>
{
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);
} );
serviceDescriptors.AddScoped<IStorageService, StorageService>();
return serviceDescriptors;
}
public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddScoped<IPictureService, PictureService>();
return serviceDescriptors;
}
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<UploadIntentConfiguration>()
.Bind(configuration.GetRequiredSection(UploadIntentConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<UploadIntentConfiguration>>().Value);
serviceDescriptors.AddScoped<UploadIntentValidator>();
return serviceDescriptors;
}
public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddUploadIntentConfiguration(configuration);
serviceDescriptors.AddHostedService<UploadIntentExpirationJob>();
serviceDescriptors.AddHostedService<TemporalUploadsCleanUpJob>();
return serviceDescriptors;
}
public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<StreamTicketConfiguration>()
.Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<StreamTicketConfiguration>>().Value);
serviceDescriptors.AddSingleton<IStreamTicketService, StreamTicketService>();
return serviceDescriptors;
}
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<AudioTranscoderConfiguration>()
.Bind(configuration.GetRequiredSection(AudioTranscoderConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AudioTranscoderConfiguration>>().Value);
serviceDescriptors.AddScoped<IAudioTranscoderService, AudioTranscoderService>();
return serviceDescriptors;
}
}
}
@@ -0,0 +1,299 @@
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Microsoft.Extensions.Logging;
using MimeMapping;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration;
using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData;
namespace Musify.Infrastructure.Services
{
public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<StorageService> logger,
InfrastructureStorageConfiguration storageClientConfiguration)
: IStorageService
{
public async Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new GetObjectRequest
{
BucketName = bucket,
Key = key,
};
try
{
var response = await amazonS3.GetObjectAsync(request, cancellationToken);
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
return response.ResponseStream;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
throw;
}
}
public async Task<string> GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow + expirationTime,
Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS
};
logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key);
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<string> GetUploadUrlAsync(
string bucket,
string key,
string contentType,
TimeSpan expirationTime,
CancellationToken cancellationToken,
bool preventOverwrite = true)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow + expirationTime,
Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS,
Verb = HttpVerb.PUT,
ContentType = contentType
};
if (preventOverwrite)
request.Headers["If-None-Match"] = "*";
logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key);
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken)
{
try
{
var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken);
return new ObjectMetaData(response.Headers.ContentType, response.ContentLength);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
catch (Exception exception)
{
logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key);
throw;
}
}
public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new DeleteObjectRequest
{
BucketName = bucket,
Key = key,
};
logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key);
await amazonS3.DeleteObjectAsync(request, cancellationToken);
}
public async Task TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken)
{
var trasnsferUtility = new TransferUtility(amazonS3);
var files = Directory.GetFiles(sourceDirectory, "*");
var successCount = 0;
var failedCount = 0;
foreach (var file in files)
{
string fileName = Path.GetFileName(file);
string filePath = Path.Combine(sourceDirectory, file);
var key = string.Join('/', new[] { route, fileName }
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
var contentType = MimeUtility.GetMimeMapping(filePath);
var upload = new Upload
{
Id = Guid.NewGuid(),
Bucket = bucket,
Key = key,
ContentType = contentType
};
await database.Uploads.AddAsync(upload, cancellationToken);
try
{
await trasnsferUtility.UploadAsync(
new TransferUtilityUploadRequest
{
FilePath = filePath,
BucketName = bucket,
Key = key,
ContentType = contentType
},
cancellationToken);
upload.State = UploadState.Successful;
successCount++;
logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key);
}
catch (Exception exception)
{
failedCount++;
upload.State = UploadState.Failed;
logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key);
throw;
}
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount);
}
public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken)
{
try
{
using var fileStream = File.OpenRead(filePath);
var contentType = MimeUtility.GetMimeMapping(filePath);
await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key);
throw;
}
}
public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken)
{
var request = new PutObjectRequest
{
BucketName = bucket,
ContentType = contentType,
InputStream = sourceStream,
Key = key
};
var upload = new Upload
{
Id = Guid.NewGuid(),
Bucket = bucket,
Key = key,
ContentType = contentType
};
await database.Uploads.AddAsync(upload, cancellationToken);
try
{
var response = await amazonS3.PutObjectAsync(request, cancellationToken);
logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key);
upload.State = UploadState.Failed;
await database.SaveChangesAsync(cancellationToken);
throw;
}
upload.State = UploadState.Successful;
await database.SaveChangesAsync(cancellationToken);
logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key);
}
public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken)
{
var request = new CopyObjectRequest
{
SourceBucket = sourceBucket,
SourceKey = sourceKey,
DestinationBucket = destinationBucket,
DestinationKey = destinationKey
};
try
{
var response = await amazonS3.CopyObjectAsync(request, cancellationToken);
logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}",
sourceBucket, sourceKey, destinationBucket, destinationKey);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}",
sourceBucket, sourceKey, destinationBucket, destinationKey);
throw;
}
}
public async IAsyncEnumerable<StorageObject> ListObjectsAsync(
string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var listRequest = new ListObjectsV2Request
{
BucketName = bucket,
Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/"
};
ListObjectsV2Response listResponse;
do
{
listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken);
foreach (var s3Object in listResponse.S3Objects ?? [] )
yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime());
listRequest.ContinuationToken = listResponse.NextContinuationToken;
}
while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value);
}
public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken)
{
var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/";
var listRequest = new ListObjectsV2Request
{
BucketName = bucket,
Prefix = prefix
};
ListObjectsV2Response listResponse;
do
{
listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken);
if (listResponse.S3Objects is null or { Count: 0 })
break;
var deleteRequest = new DeleteObjectsRequest
{
BucketName = bucket,
Objects = listResponse.S3Objects
.Select(o => new KeyVersion { Key = o.Key } )
.ToList()
};
var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken);
if (deleteResponse.DeleteErrors.Count > 0)
{
var errors = string.Join(", ", deleteResponse.DeleteErrors.Select(e => $"{e.Key}: {e.Message}"));
throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}");
}
listRequest.ContinuationToken = listResponse.NextContinuationToken;
} while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value);
}
}
}

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