diff --git a/Application/Contracts/Infrastructure/IAudioTranscoder.cs b/Application/Contracts/Infrastructure/IAudioTranscoder.cs index 81f8941..f302d1b 100644 --- a/Application/Contracts/Infrastructure/IAudioTranscoder.cs +++ b/Application/Contracts/Infrastructure/IAudioTranscoder.cs @@ -4,6 +4,8 @@ namespace Musify.Application.Contracts.Infrastructure { public interface IAudioTranscoder { - Task TranscodeToDashAsync(Stream audioStream, string destinationPath, TimeSpan timeOut, CancellationToken cancellationToken); + Task TranscodeToDashAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + + Task> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Infrastructure/Audio/AudioTranscoder.cs b/Infrastructure/Audio/AudioTranscoder.cs index 9ae77ac..140c88b 100644 --- a/Infrastructure/Audio/AudioTranscoder.cs +++ b/Infrastructure/Audio/AudioTranscoder.cs @@ -1,89 +1,240 @@ using Ardalis.Result; using Microsoft.Extensions.Logging; using Musify.Application.Contracts.Infrastructure; +using Musify.Infrastructure.Configuration; using System.Diagnostics; namespace Musify.Infrastructure.Audio { - public class AudioTranscoder(ILogger logger) + public class AudioTranscoder( + ILogger logger, + AudioTranscoderConfiguration audioTranscoderConfiguration) : IAudioTranscoder { - const string FfmpegProcessName = "ffmpeg"; - - public async Task TranscodeToDashAsync(Stream audioStream, string destinationPath, TimeSpan timeOut, CancellationToken cancellationToken) + public async Task TranscodeToDashAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { try { Directory.CreateDirectory(destinationPath); - using var process = new Process + logger.LogInformation( + "Starting audio transcoding to DASH. DestinationPath: {DestinationPath}, Timeout: {Timeout}, Executable: {Executable}", + destinationPath, + audioTranscoderConfiguration.TranscodingTimeout, + audioTranscoderConfiguration.Ffmpeg.ExecutableName); + + var executionResult = await ExecuteFfmpegWithInputAsync( + BuildDashArguments(destinationPath, audioTranscoderConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToDash", + cancellationToken); + + if (executionResult.ExitCode != 0) { - StartInfo = new ProcessStartInfo - { - FileName = FfmpegProcessName, - WorkingDirectory = destinationPath, - Arguments = BuildDashArguments(destinationPath), - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - } - }; - process.Start(); - - if (audioStream.CanSeek) - audioStream.Position = 0; - await audioStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); - process.StandardInput.Close(); - - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - - using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCancellationTokens.CancelAfter(timeOut); - await process.WaitForExitAsync(linkedCancellationTokens.Token); - - var standardError = await errorTask; - var standardOutput = await outputTask; - - if (process.ExitCode != 0) - { - logger.LogError("Audio transcoding to DASH format failed with exit code {ExitCode}. Standard Output: {StandardOutput}, Standard Error: {StandardError}", process.ExitCode, standardOutput, standardError); - return Result.Error($"Audio transcoding failed with exit code {process.ExitCode}. See logs for details."); + logger.LogError( + "Audio transcoding to DASH format failed with exit code {ExitCode}. Standard Output: {StandardOutput}, Standard Error: {StandardError}", + executionResult.ExitCode, + executionResult.StandardOutput, + executionResult.StandardError); + return Result.Error($"Audio transcoding failed with exit code {executionResult.ExitCode}. See logs for details."); } - logger.LogInformation("Audio transcoding to DASH format completed successfully. Uploading to storage..."); + logger.LogInformation("Audio transcoding to DASH format completed successfully. DestinationPath: {DestinationPath}", destinationPath); return Result.Success(); } + catch (TimeoutException timeoutException) + { + logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout}. DestinationPath: {DestinationPath}", audioTranscoderConfiguration.TranscodingTimeout, destinationPath); + return Result.Error("Audio transcoding timed out."); + } catch (Exception exception) { - logger.LogError(exception, "An error occurred while transcoding audio to DASH format."); + logger.LogError(exception, "An error occurred while transcoding audio to DASH format. DestinationPath: {DestinationPath}", destinationPath); return Result.Error(exception.Message); } } - static string BuildDashArguments(string outputDirectory) + public async Task> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken) { - var manifestPath = Path.Combine(outputDirectory, "manifest.mpd"); + if (!File.Exists(filePath)) + { + logger.LogWarning("Audio validation requested for a non-existing file. FilePath: {FilePath}", filePath); + return Result.Error($"Audio file not found: {filePath}"); + } + + try + { + logger.LogInformation("Starting audio file validation. FilePath: {FilePath}, Timeout: {Timeout}", filePath, audioTranscoderConfiguration.TranscodingTimeout); + + 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 file {FilePath}. Standard Error: {StandardError}", + filePath, + executionResult.StandardError); + } + else + { + logger.LogInformation("Audio validation completed successfully. FilePath: {FilePath}", filePath); + } + + return Result.Success(isValid); + } + catch (TimeoutException timeoutException) + { + logger.LogError(timeoutException, "Audio validation timed out after {Timeout} for file {FilePath}.", audioTranscoderConfiguration.TranscodingTimeout, filePath); + return Result.Error("Audio validation timed out."); + } + catch (Exception exception) + { + logger.LogError(exception, "An error occurred while validating audio file {FilePath}.", filePath); + return Result.Error(exception.Message); + } + } + + async Task ExecuteFfmpegWithInputAsync( + string arguments, + string workingDirectory, + Stream inputStream, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); + process.Start(); + + logger.LogDebug("[{Operation}] ffmpeg process started. ProcessId: {ProcessId}, WorkingDirectory: {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 ExecuteFfmpegAsync( + string arguments, + string workingDirectory, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); + process.Start(); + + logger.LogDebug("[{Operation}] ffmpeg process started. ProcessId: {ProcessId}, WorkingDirectory: {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 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}. ProcessId: {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. ProcessId: {ProcessId}, ExitCode: {ExitCode}, StdOutLength: {StdOutLength}, StdErrLength: {StdErrLength}", + 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. ProcessId: {ProcessId}", process.Id); + } + } + + static string BuildDashArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) + { + var manifestPath = Path.Combine(outputDirectory, ffmpegConfiguration.ManifestFileName); + 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 aac " + - "-b:a 128k " + - "-ac 2 " + - "-ar 48000 " + - "-profile:a aac_low " + + $"-c:a {ffmpegConfiguration.AudioCodec} " + + $"-b:a {ffmpegConfiguration.AudioBitrate} " + + $"-ac {ffmpegConfiguration.AudioChannels} " + + $"-ar {ffmpegConfiguration.AudioSampleRate} " + + $"-profile:a {ffmpegConfiguration.AudioProfile} " + "-f dash " + - "-seg_duration 4 " + + $"-seg_duration {ffmpegConfiguration.SegmentDurationSeconds} " + "-streaming 1 " + "-use_template 1 " + "-use_timeline 0 " + - "-init_seg_name \"init-stream$RepresentationID$.m4s\" " + - "-media_seg_name \"chunk-stream$RepresentationID$-$Number$.m4s\" " + - $"\"{manifestPath}\""; + $"-init_seg_name \"{ffmpegConfiguration.InitSegmentName}\" " + + $"-media_seg_name \"{ffmpegConfiguration.MediaSegmentName}\"" + + additionalArguments + + $" \"{manifestPath}\""; } + + static string BuildValidateAudioArguments(string filePath) + => $"-v error -i \"{filePath}\" -map 0:a:0 -f null -"; + + sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); } } \ No newline at end of file diff --git a/Infrastructure/Configuration/AudioTranscoderConfiguration.cs b/Infrastructure/Configuration/AudioTranscoderConfiguration.cs index affc4a3..e4ef863 100644 --- a/Infrastructure/Configuration/AudioTranscoderConfiguration.cs +++ b/Infrastructure/Configuration/AudioTranscoderConfiguration.cs @@ -8,5 +8,43 @@ namespace Musify.Infrastructure.Configuration [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 ManifestFileName { get; set; } = "manifest.mpd"; + + [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"; + + [Range(1, 60)] + public int SegmentDurationSeconds { get; set; } = 4; + + [Required] + public string InitSegmentName { get; set; } = "init-stream$RepresentationID$.m4s"; + + [Required] + public string MediaSegmentName { get; set; } = "chunk-stream$RepresentationID$-$Number$.m4s"; + + public string AdditionalArguments { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/Infrastructure/Messaging/Activities/TranscodeDashAudioActivity.cs b/Infrastructure/Messaging/Activities/TranscodeDashAudioActivity.cs index 3cd59b4..8094999 100644 --- a/Infrastructure/Messaging/Activities/TranscodeDashAudioActivity.cs +++ b/Infrastructure/Messaging/Activities/TranscodeDashAudioActivity.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts.Infrastructure; using Musify.Domain.Entities; -using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Messaging.Activities.Arguments; namespace Musify.Infrastructure.Messaging.Activities @@ -10,8 +9,7 @@ namespace Musify.Infrastructure.Messaging.Activities public class TranscodeDashAudioActivity( IAudioTranscoder audioTranscoder, ILogger logger, - IProcessTrackingStore processTrackingStore, - AudioTranscoderConfiguration audioTranscoderConfiguration) + IProcessTrackingStore processTrackingStore) : IExecuteActivity { public const string ExecuteEndpointName = "Transcode-Dash-Audio"; @@ -33,11 +31,11 @@ namespace Musify.Infrastructure.Messaging.Activities executeContext.CancellationToken); var sourceFilePath = executeContext.GetVariable("SourceFilePath"); - var destinationFolderName = executeContext.GetVariable("DestinationFolderName"); + var workingDirectory = executeContext.GetVariable("WorkingDirectory"); - if (string.IsNullOrWhiteSpace(sourceFilePath) || string.IsNullOrWhiteSpace(destinationFolderName)) + if (string.IsNullOrWhiteSpace(sourceFilePath) || string.IsNullOrWhiteSpace(workingDirectory)) { - throw new InvalidOperationException("Transcode activity requires SourceFilePath and DestinationFolderName variables."); + throw new InvalidOperationException("Transcode activity requires SourceFilePath and WorkingDirectory variables."); } try @@ -46,8 +44,7 @@ namespace Musify.Infrastructure.Messaging.Activities var result = await audioTranscoder.TranscodeToDashAsync( fileStream, - destinationFolderName, - audioTranscoderConfiguration.TranscodingTimeout, + workingDirectory, executeContext.CancellationToken); if (!result.IsSuccess)