feat(backend): auto-download YouTube tracks saved to playlists

This commit is contained in:
2026-07-16 01:06:04 +02:00
parent 11ed8b40ff
commit 73826b9d08
18 changed files with 672 additions and 1 deletions
@@ -0,0 +1,8 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record AddYouTubeTrackRequest(
string VideoId,
string Title,
string Artist,
int DurationSeconds,
string ThumbnailUrl);
@@ -93,6 +93,17 @@ public static class PlayListEndpoints
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict); .Produces(StatusCodes.Status409Conflict);
group.MapPost("/{playlistId}/youtube-tracks", AddYouTubeTrackToPlayList)
.WithName("AddYouTubeTrackToPlayList")
.WithSummary("Add A YouTube Track To A PlayList, Downloading It In The Background If Needed.")
.AddEndpointFilter<ValidationFilter<AddYouTubeTrackRequest>>()
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList") .WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.") .WithSummary("Remove A Track From A PlayList.")
@@ -232,6 +243,27 @@ public static class PlayListEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> AddYouTubeTrackToPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
AddYouTubeTrackRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddYouTubeTrackToPlayListCommand(
currentUser.RequiredId,
playlistId,
request.VideoId,
request.Title,
request.Artist,
request.DurationSeconds,
request.ThumbnailUrl),
cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
}
private static async Task<IResult> RemoveTrackFromPlayList( private static async Task<IResult> RemoveTrackFromPlayList(
IMediator mediator, IMediator mediator,
CurrentUser currentUser, CurrentUser currentUser,
@@ -0,0 +1,29 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class AddYouTubeTrackRequestValidator : AbstractValidator<AddYouTubeTrackRequest>
{
public AddYouTubeTrackRequestValidator()
{
RuleFor(x => x.VideoId)
.NotEmpty()
.Matches("^[A-Za-z0-9_-]{11}$");
RuleFor(x => x.Title)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Artist)
.MaximumLength(400);
RuleFor(x => x.DurationSeconds)
.GreaterThan(0);
RuleFor(x => x.ThumbnailUrl)
.NotEmpty()
.Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps)
.WithMessage("ThumbnailUrl must be an absolute https URL.");
}
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record DownloadYouTubeTrackEvent(
Guid TrackId,
string VideoId,
string ThumbnailUrl,
string Bucket,
string AudioProcessedFolderKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,170 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddYouTubeTrackToPlayListCommand(
long UserId,
Guid PlayListId,
string VideoId,
string Title,
string Artist,
int DurationSeconds,
string ThumbnailUrl)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class AddYouTubeTrackToPlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddYouTubeTrackToPlayListCommand, ErrorOr<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var trackResult = await GetOrCreateTrackAsync(request, cancellationToken);
if (trackResult.IsError)
return trackResult.Errors;
var track = trackResult.Value;
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var nextPosition = await database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId)
.Select(plt => (int?)plt.Position)
.MaxAsync(cancellationToken) + 1 ?? 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = track.Id,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}",
request.VideoId, track.Id, request.PlayListId, nextPosition);
var listensCount = await database.ListeningHistories.AsNoTracking()
.CountAsync(lh => lh.TrackId == track.Id, cancellationToken);
return TrackApplicationResponse.FromEntity(track, listensCount);
}
private async Task<ErrorOr<Track>> GetOrCreateTrackAsync(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var track = await database.Tracks
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken);
if (track is not null)
{
if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed)
{
track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending;
track.PicturesProcessingStatus = ProcessingStatus.Pending;
track.LifeCycleStatus = LifeCycleStatus.Active;
track.RetryCount += 1;
track.LastRetryAt = DateTime.UtcNow;
await PublishDownloadEventAsync(track.Id, request, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Re-queued failed YouTube track {VideoId} for download", request.VideoId);
}
return track;
}
var title = Truncate(request.Title, 50);
track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Artist = Truncate(request.Artist, 200),
Source = TrackSource.YouTube,
ExternalId = request.VideoId,
Duration = request.DurationSeconds,
OriginalPictureName = string.Empty,
OriginalAudioName = string.Empty,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
PicturesProcessingStatus = ProcessingStatus.Pending,
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
};
await database.Tracks.AddAsync(track, cancellationToken);
await PublishDownloadEventAsync(track.Id, request, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created YouTube track {VideoId} ({TrackId}) and queued its download", request.VideoId, track.Id);
return track;
}
catch (DbUpdateException)
{
logger.LogInformation("YouTube track {VideoId} was created concurrently", request.VideoId);
return Error.Conflict(description: "The track was just created by another request. Retry the operation.");
}
}
private async Task PublishDownloadEventAsync(Guid trackId, AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
await eventBus.PublishAsync(
new DownloadYouTubeTrackEvent(
trackId,
request.VideoId,
request.ThumbnailUrl,
storageConfiguration.Bucket,
audioProcessedFolderKey,
new ImageSize(
trackConfiguration.Routes.SmallPicturesPath,
trackConfiguration.PicturesSizes.SmallPictureWidth,
trackConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
trackConfiguration.Routes.MediumPicturesPath,
trackConfiguration.PicturesSizes.MediumPictureWidth,
trackConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
trackConfiguration.Routes.LargePicturesPath,
trackConfiguration.PicturesSizes.LargePictureWidth,
trackConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
}
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
}
@@ -0,0 +1,94 @@
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 DownloadYouTubeAudioActivity(
IDatabase database,
IYouTubeDownloaderService youTubeDownloader,
ILogger<DownloadYouTubeAudioActivity> logger)
: IActivity<DownloadYouTubeAudioArguments, DownloadYouTubeAudioLog>
{
public const string ExecuteEndpointName = "download-you-tube-audio";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadYouTubeAudioArguments> executeContext)
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
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
{
var downloadResult = await youTubeDownloader.DownloadAudioAsync(
executeContext.Arguments.VideoId,
destinationPath,
executeContext.CancellationToken);
if (downloadResult.ExitCode != 0)
throw new InvalidOperationException(
$"yt-dlp failed with exit code {downloadResult.ExitCode}: {downloadResult.StandardError}");
if (!File.Exists(destinationPath))
throw new InvalidOperationException($"yt-dlp did not produce the expected file {destinationPath}.");
logger.LogInformation("Downloaded YouTube audio for video {VideoId} to {DestinationPath}",
executeContext.Arguments.VideoId, destinationPath);
return executeContext.Completed(new DownloadYouTubeAudioLog(
executeContext.Arguments.VideoId,
destinationPath));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to download YouTube audio for video {VideoId}",
executeContext.Arguments.VideoId);
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<DownloadYouTubeAudioLog> compensateContext)
{
try
{
if (File.Exists(compensateContext.Log.DestinationFilePath))
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate downloaded YouTube audio {DestinationFilePath}",
compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
}
@@ -0,0 +1,69 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
internal class DownloadFileFromUrlActivity(
IHttpClientFactory httpClientFactory,
ILogger<DownloadFileFromUrlActivity> logger)
: IActivity<DownloadFileFromUrlArguments, DownloadFileFromUrlLog>
{
public const string ExecuteEndpointName = "download-file-from-url";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromUrlArguments> executeContext)
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
try
{
var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
executeContext.Arguments.SourceUrl,
HttpCompletionOption.ResponseHeadersRead,
executeContext.CancellationToken);
response.EnsureSuccessStatusCode();
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
await using (var destinationStream = File.Create(destinationPath))
{
await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken);
}
logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}",
executeContext.Arguments.SourceUrl, destinationPath);
return executeContext.Completed(new DownloadFileFromUrlLog(
executeContext.Arguments.SourceUrl,
destinationPath));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl);
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromUrlLog> compensateContext)
{
try
{
if (File.Exists(compensateContext.Log.DestinationFilePath))
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,6 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record DownloadFileFromUrlArguments(
string SourceUrl,
string DestinationFilePathVariable);
}
@@ -0,0 +1,7 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record DownloadYouTubeAudioArguments(
Guid TrackId,
string VideoId,
string DestinationFilePathVariable);
}
@@ -0,0 +1,26 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class DownloadYouTubeTrackConsumer(
IBus bus,
YouTubeTrackRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DownloadYouTubeTrackEvent>
{
public async Task Consume(ConsumeContext<DownloadYouTubeTrackEvent> consumeContext)
{
var audioRoutingSlip = routingSlipBuilder
.BuildAudioWorkflow(consumeContext.Message, consumeContext.CorrelationId)
.Build();
var pictureRoutingSlip = routingSlipBuilder
.BuildPictureWorkflow(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(audioRoutingSlip);
await bus.Execute(pictureRoutingSlip);
}
}
}
@@ -23,6 +23,7 @@ namespace Musify.Infrastructure.MassTransit
services.AddScoped<DeletePlayListRoutingSlipBuilder>(); services.AddScoped<DeletePlayListRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>(); services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>(); services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
services.AddScoped<YouTubeTrackRoutingSlipBuilder>();
} }
static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options)
@@ -39,6 +40,7 @@ namespace Musify.Infrastructure.MassTransit
options.AddConsumer<ProcessingSlipFaultConsumer>(); options.AddConsumer<ProcessingSlipFaultConsumer>();
options.AddConsumer<TrackProcessingFailedConsumer>(); options.AddConsumer<TrackProcessingFailedConsumer>();
options.AddConsumer<PlayListProcessingFailedConsumer>(); options.AddConsumer<PlayListProcessingFailedConsumer>();
options.AddConsumer<DownloadYouTubeTrackConsumer>();
options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>(); options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>();
options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>(); options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>();
@@ -62,6 +64,8 @@ namespace Musify.Infrastructure.MassTransit
options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>(); options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>(); options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>(); options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
options.AddActivity<DownloadYouTubeAudioActivity, DownloadYouTubeAudioArguments, DownloadYouTubeAudioLog>();
options.AddActivity<DownloadFileFromUrlActivity, DownloadFileFromUrlArguments, DownloadFileFromUrlLog>();
} }
} }
} }
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record DownloadFileFromUrlLog(
string SourceUrl,
string DestinationFilePath);
}
@@ -0,0 +1,6 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record DownloadYouTubeAudioLog(
string VideoId,
string DestinationFilePath);
}
@@ -13,6 +13,8 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
internal const string DownloadFile = "DownloadFile"; internal const string DownloadFile = "DownloadFile";
internal const string DownloadYouTubeAudio = "DownloadYouTubeAudio";
internal const string DownloadThumbnail = "DownloadThumbnail";
internal const string TranscodeAudio = "TranscodeAudio"; internal const string TranscodeAudio = "TranscodeAudio";
@@ -0,0 +1,166 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.Pictures;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class YouTubeTrackRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
{
public RoutingSlipBuilder BuildAudioWorkflow(DownloadYouTubeTrackEvent 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.VideoId}.m4a"));
routingSlipBuilder.AddActivity(
ActivityNames.DownloadYouTubeAudio,
EndpointHelper.BuildExecuteActivityUri(DownloadYouTubeAudioActivity.ExecuteEndpointName),
new DownloadYouTubeAudioArguments(
message.TrackId,
message.VideoId,
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.Bucket,
RoutingSlipVariableNames.Audio.TranscodedDirectory,
message.AudioProcessedFolderKey));
routingSlipBuilder.AddActivity(
ActivityNames.UpdateTrackAudio,
EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName),
new UpdateTrackAudioArguments(
message.TrackId,
message.AudioProcessedFolderKey,
RoutingSlipVariableNames.Audio.DurationSeconds));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName),
RoutingSlipEvents.Faulted);
return routingSlipBuilder;
}
public RoutingSlipBuilder BuildPictureWorkflow(DownloadYouTubeTrackEvent 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.GeneratePictureWorkflowPaths,
EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName),
new GeneratePictureWorkflowPathsArguments(
workerConfiguration.Routes.TemporaryFilesDirectory,
$"{message.VideoId}.jpg"));
routingSlipBuilder.AddActivity(
ActivityNames.DownloadThumbnail,
EndpointHelper.BuildExecuteActivityUri(DownloadFileFromUrlActivity.ExecuteEndpointName),
new DownloadFileFromUrlArguments(
message.ThumbnailUrl,
RoutingSlipVariableNames.Picture.OriginalFilePath));
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeSmall,
ActivityNames.UploadSmall,
message.Bucket,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
message.Small);
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeMedium,
ActivityNames.UploadMedium,
message.Bucket,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
message.Medium);
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeLarge,
ActivityNames.UploadLarge,
message.Bucket,
RoutingSlipVariableNames.Picture.LargeResizedFilePath,
message.Large);
routingSlipBuilder.AddActivity(
ActivityNames.UpdateTrackPicture,
EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName),
new UpdateTrackPictureArguments(
message.TrackId,
string.Empty,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName),
RoutingSlipEvents.Faulted);
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));
}
}
}
@@ -9,6 +9,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
public State Failed { get; private set; } = null!; public State Failed { get; private set; } = null!;
public Event<CreateTrackResourcesEvent> ProcessingStarted { get; private set; } = null!; public Event<CreateTrackResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<DownloadYouTubeTrackEvent> YouTubeDownloadStarted { get; private set; } = null!;
public Event<TrackPictureProcessed> PictureProcessed { get; private set; } = null!; public Event<TrackPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<TrackAudioProcessed> AudioProcessed { get; private set; } = null!; public Event<TrackAudioProcessed> AudioProcessed { get; private set; } = null!;
public Event<TrackPictureProcessingFailed> PictureFailed { get; private set; } = null!; public Event<TrackPictureProcessingFailed> PictureFailed { get; private set; } = null!;
@@ -19,6 +20,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
InstanceState(state => state.CurrentState); InstanceState(state => state.CurrentState);
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => YouTubeDownloadStarted, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId));
@@ -34,9 +36,20 @@ namespace Musify.Infrastructure.MassTransit.Sagas
context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.AudioKey = context.Message.AudioDestinationKey; context.Saga.AudioKey = context.Message.AudioDestinationKey;
} ) } )
.TransitionTo(Processing),
When(YouTubeDownloadStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = string.Empty;
context.Saga.AudioKey = string.Empty;
} )
.TransitionTo(Processing)); .TransitionTo(Processing));
During(Processing, During(Processing,
Ignore(YouTubeDownloadStarted),
When(PictureProcessed) When(PictureProcessed)
.Then(context => .Then(context =>
{ {
@@ -72,7 +85,18 @@ namespace Musify.Infrastructure.MassTransit.Sagas
Ignore(PictureProcessed), Ignore(PictureProcessed),
Ignore(AudioProcessed), Ignore(AudioProcessed),
Ignore(PictureFailed), Ignore(PictureFailed),
Ignore(AudioFailed)); Ignore(AudioFailed),
When(YouTubeDownloadStarted)
.Then(context =>
{
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.PictureProcessed = false;
context.Saga.AudioProcessed = false;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = string.Empty;
context.Saga.AudioKey = string.Empty;
} )
.TransitionTo(Processing));
SetCompletedWhenFinalized(); SetCompletedWhenFinalized();
} }
+6
View File
@@ -85,6 +85,12 @@
}, },
"TranscodingTimeout": "00:05:00" "TranscodingTimeout": "00:05:00"
}, },
"YtDlp": {
"ExecutableName": "yt-dlp",
"Format": "bestaudio",
"AdditionalArguments": "",
"DownloadTimeout": "00:05:00"
},
"Workers": { "Workers": {
"Routes": { "Routes": {
"TemporaryFilesDirectory": "D:\\tempsFilesDev" "TemporaryFilesDirectory": "D:\\tempsFilesDev"
+2
View File
@@ -14,6 +14,8 @@ services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfi
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddStorageService(configuration); services.AddStorageService(configuration);
services.AddAudioTranscoder(configuration); services.AddAudioTranscoder(configuration);
services.AddYouTubeDownloader(configuration);
services.AddHttpClient();
services.AddPictureService(configuration); services.AddPictureService(configuration);
services.AddDatabase(configuration); services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration); services.AddMassTransitConsumers(configuration);