diff --git a/CommandLineApp/Conversion/Converter.cs b/CommandLineApp/Conversion/Converter.cs index 75505a4..09d1b1e 100644 --- a/CommandLineApp/Conversion/Converter.cs +++ b/CommandLineApp/Conversion/Converter.cs @@ -6,61 +6,76 @@ namespace dis.CommandLineApp.Conversion; -public sealed class Converter(PathHandler pathHandler, ProcessHandler processHandler, ILogger logger) +public sealed class Converter(PathHandler pathHandler, Globals globals, ProcessHandler processHandler, ILogger logger) { /// /// Converts a video file to a specified format using FFmpeg. /// /// The path to the input video file. /// The optional date and time to set for the output file. - /// The options to use for the conversion. + /// The options to use for the conversion. /// A task that represents the asynchronous conversion operation. - public async Task ConvertVideo(string file, DateTime? dateTime, Settings o) + public async Task ConvertVideo(string file, DateTime? dateTime, Settings s) { - Console.CancelKeyPress += HandleCancellation; + while (true) + { + Console.CancelKeyPress += HandleCancellation; - var cmpPath = pathHandler.GetCompressPath(file, o.VideoCodec); - var outP = pathHandler.ConstructFilePath(o, cmpPath); + var cmpPath = pathHandler.GetCompressPath(file, s.VideoCodec); + var outP = pathHandler.ConstructFilePath(s, cmpPath); - var mediaInfo = await FFmpeg.GetMediaInfo(file); - var streams = mediaInfo.Streams; + var mediaInfo = await FFmpeg.GetMediaInfo(file); + var streams = mediaInfo.Streams.ToList(); - var conversion = processHandler.ConfigureConversion(o, streams, outP); - if (conversion is null) - { - logger.Error("Could not configure conversion"); - return; - } + var conversion = processHandler.ConfigureConversion(s, streams, outP); + if (conversion is null) + { + logger.Error("Could not configure conversion"); + return; + } - try - { - await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx => + try { - ctx.Spinner(Spinner.Known.Arrow); - - conversion.OnProgress += (_, args) => - { - var percent = (int)Math.Round(args.Duration.TotalSeconds / args.TotalLength.TotalSeconds * 100); - if (percent is 0) - return; - - ctx.Status($"[green]Conversion progress: {percent}%[/]"); - ctx.Refresh(); - }; - await conversion.Start(); - }); - if (dateTime.HasValue) - processHandler.SetTimeStamps(outP, dateTime.Value); - } - catch (Exception) - { - logger.Error("Conversion failed"); - logger.Error("FFmpeg args: {Conversion}", $"ffmpeg {conversion.Build().Trim()}"); - return; - } + await AnsiConsole.Status() + .StartAsync("Starting conversion...", async ctx => + { + ctx.Spinner(Spinner.Known.Arrow); + + conversion.OnProgress += (_, args) => + { + var percent = (int)Math.Round(args.Duration.TotalSeconds / args.TotalLength.TotalSeconds * 100); + if (percent is 0) return; + + ctx.Status($"[green]Conversion progress: {percent}%[/]"); + ctx.Refresh(); + }; + await conversion.Start(); + }); + if (dateTime.HasValue) processHandler.SetTimeStamps(outP, dateTime.Value); + } + catch (Exception) + { + logger.Error("Conversion failed"); + logger.Error("FFmpeg args: {Conversion}", $"ffmpeg {conversion.Build().Trim()}"); + return; + } - AnsiConsole.MarkupLine($"Converted video saved at: [green]{outP}[/]"); + AnsiConsole.MarkupLine($"Converted video saved at: [green]{outP}[/]"); + var (originalSize, compressedSize) = ResultsTable(file, outP); + if (compressedSize > originalSize) + { + var videoStream = streams.OfType().FirstOrDefault(); + if (videoStream is not null) + if (ShouldRetry(s, outP, streams)) continue; + } + + break; + } + } + + private static (double, double) ResultsTable(string file, string outP) + { /* * File sizes are read and compared to calculate the difference and percentage saved. * @@ -71,19 +86,18 @@ await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx => * The results are presented in a table with columns "Original", "Compressed", and "Saved". * * For example: - * ┌─────────────────────────┬─────────────────────────────┬────────────────────────────┐ - * │ Original │ Compressed │ Saved │ - * ├─────────────────────────┼─────────────────────────────┼────────────────────────────┤ - * │ Original size: 1.10 MiB │ Compressed size: 417.28 KiB │ Saved: 709.82 KiB (62.98%) │ - * └─────────────────────────┴─────────────────────────────┴────────────────────────────┘ + * ┌───────────────────────────┬─────────────────────────────┬─────────────────────────────┐ + * │ Original │ Compressed │ Saved │ + * ├───────────────────────────┼─────────────────────────────┼─────────────────────────────┤ + * │ Original size: 710.86 KiB │ Compressed size: 467.02 KiB │ Saved: -243.84 KiB (-34.3%) │ + * └───────────────────────────┴─────────────────────────────┴─────────────────────────────┘ */ - var originalSize = (double)new FileInfo(file).Length; var compressedSize = (double)new FileInfo(outP).Length; var saved = originalSize - compressedSize; var savedPercent = saved / originalSize * 100; - var savedPercentRounded = Math.Round(savedPercent, 2) + var savedPercentRounded = (savedPercent > 0 ? "-" : "+") + Math.Round(Math.Abs(savedPercent), 2) .ToString(CultureInfo.InvariantCulture); var originalMiB = originalSize / 1024.0 / 1024.0; @@ -109,7 +123,6 @@ await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx => : $"{savedSymbol}{Math.Round(savedMiB, 2):F2} MiB"; var savedString = $"{savedChange}: [{savedColor}]{savedSizeString} ({savedPercentRounded}%)[/]"; - var table = new Table(); table.AddColumn("Original"); table.AddColumn("Compressed"); @@ -117,13 +130,127 @@ await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx => table.AddRow(originalSizeString, compressedSizeString, savedString); AnsiConsole.Write(table); + return (originalSize, compressedSize); + } + + private bool ShouldRetry(Settings s, string outP, List enumerable) + { + AnsiConsole.MarkupLine("[yellow]The resulting file is larger than the original.[/]"); + + var deleteAndRetry = AskForRetry(); + if (deleteAndRetry is false) return false; + + var resolutionChanged = AskForResolutionChange(enumerable, s); + var crfChanged = AskForCrfChange(s); + + if (resolutionChanged || crfChanged) + return true; + + return DeleteConvertedVideo(outP); + } + + private static bool AskForRetry() + { + var deleteAndRetry = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Do you want to delete the converted video and try again with a better setting?") + .AddChoices(["Yes", "No"])); + + return deleteAndRetry is "Yes"; + } + + private bool AskForResolutionChange(List enumerable, Settings s) + { + var resolutionChanged = false; + var changeResolution = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Would you like to change the resolution?") + .AddChoices(["Yes", "No"])); + + if (changeResolution is "Yes") resolutionChanged = ChangeResolution(enumerable, s); + return resolutionChanged; } - private void HandleCancellation(object? sender, ConsoleCancelEventArgs e) + private bool ChangeResolution(List enumerable, Settings s) + { + var width = enumerable.OfType().First().Width; + var height = enumerable.OfType().First().Height; + + var maxDimension = Math.Max(width, height); + var currentResolution = $"{maxDimension}p"; + + var resolutionList = globals.ValidResolutions.ToList(); + var currentResolutionIndex = resolutionList.FindIndex(res => res == currentResolution); + + if (currentResolutionIndex <= 0) return false; + + var lowerResolutions = resolutionList.GetRange(0, currentResolutionIndex); + var chosenResolution = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Please select a lower resolution for the conversion.") + .AddChoices(lowerResolutions)); + + s.Resolution = chosenResolution; + return true; + } + + private static bool AskForCrfChange(Settings s) + { + var crfChanged = false; + var crfAnswer = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Would you like to enter a new crf value?") + .AddChoices(["Yes", "No"])); + + if (crfAnswer is "Yes") crfChanged = ChangeCrfValue(s); + + return crfChanged; + } + + private static bool ChangeCrfValue(Settings s) + { + bool crfChanged; + + while (true) + { + var crfAnswer = AnsiConsole.Ask("Please enter new value"); + var crfValue = int.Parse(new string(crfAnswer.Where(char.IsDigit).ToArray())); + + if (crfValue <= s.Crf) + { + AnsiConsole.WriteLine("Please enter a value higher than the current CRF."); + continue; + } + + s.Crf = crfValue; + crfChanged = true; + break; + } + + return crfChanged; + } + + private static bool DeleteConvertedVideo(string path) + { + var deleteAnswer = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Do you want to delete the converted video?") + .AddChoices(["Yes", "No"])); + if (deleteAnswer is "No") + return false; + + File.Delete(path); + AnsiConsole.MarkupLine("[green]Deleted the converted video.[/]"); + + return false; + } + + + private static void HandleCancellation(object? sender, ConsoleCancelEventArgs e) { if (e.SpecialKey is not ConsoleSpecialKey.ControlC) return; - AnsiConsole.WriteLine(); + AnsiConsole.WriteLine("Canceled"); } } diff --git a/CommandLineApp/Conversion/ProcessHandler.cs b/CommandLineApp/Conversion/ProcessHandler.cs index c8924e6..0862278 100644 --- a/CommandLineApp/Conversion/ProcessHandler.cs +++ b/CommandLineApp/Conversion/ProcessHandler.cs @@ -15,11 +15,10 @@ public void SetTimeStamps(string path, DateTime date) File.SetLastAccessTime(path, date); } - public IConversion? ConfigureConversion(Settings o, IEnumerable streams, string outP) + public IConversion? ConfigureConversion(Settings o, IList streams, string outP) { - var listOfStreams = streams.ToList(); - var videoStream = listOfStreams.OfType().FirstOrDefault(); - var audioStream = listOfStreams.OfType().FirstOrDefault(); + var videoStream = streams.OfType().FirstOrDefault(); + var audioStream = streams.OfType().FirstOrDefault(); if (videoStream is null && audioStream is null) { @@ -29,11 +28,9 @@ public void SetTimeStamps(string path, DateTime date) var parameters = $"-crf {o.Crf}"; var conversion = FFmpeg.Conversions.New() - .SetPreset(ConversionPreset.VerySlow) + .AddParameter(parameters) .SetPixelFormat(PixelFormat.yuv420p) - .SetOutput(outP) - .UseMultiThread(o.MultiThread) - .AddParameter(parameters); + .SetPreset(ConversionPreset.VerySlow); var videoCodec = codecParser.GetCodec(o.VideoCodec); @@ -44,17 +41,31 @@ public void SetTimeStamps(string path, DateTime date) switch (videoCodec) { case VideoCodec.vp9: - configurator.SetVp9Args(conversion); - break; + { + configurator.SetVp9Args(conversion); + outP = Path.ChangeExtension(outP, "webm"); + break; + } case VideoCodec.av1: - configurator.SetCpuForAv1(conversion, videoStream.Framerate); - break; + { + configurator.SetCpuForAv1(conversion, videoStream.Framerate); + outP = Path.ChangeExtension(outP, "webm"); + conversion.SetPixelFormat(PixelFormat.yuv420p10le); + break; + } + default: + { + conversion.UseMultiThread(o.MultiThread); + break; + } } if (string.IsNullOrEmpty(o.Resolution) is false) configurator.SetResolution(videoStream, o.Resolution); } + conversion.SetOutput(outP); + if (audioStream is null) return conversion; diff --git a/CommandLineApp/Conversion/StreamConfigurator.cs b/CommandLineApp/Conversion/StreamConfigurator.cs index f0992ba..3172e5a 100644 --- a/CommandLineApp/Conversion/StreamConfigurator.cs +++ b/CommandLineApp/Conversion/StreamConfigurator.cs @@ -61,4 +61,4 @@ public void SetCpuForAv1(IConversion conversion, double framerate) public void SetVp9Args(IConversion conversion) => conversion.AddParameter(string.Join(" ", _vp9Args)); -} \ No newline at end of file +} diff --git a/CommandLineApp/Downloaders/VideoDownloaderBase.cs b/CommandLineApp/Downloaders/VideoDownloaderBase.cs index ad63c97..b847b50 100644 --- a/CommandLineApp/Downloaders/VideoDownloaderBase.cs +++ b/CommandLineApp/Downloaders/VideoDownloaderBase.cs @@ -111,7 +111,8 @@ private bool ValidTimeRange(RunResult fetch) { // check if the time range is beyond the video length var split = Query.OptionSet.DownloadSections!.Values[0]; - var (start, end) = ParseStartAndEndTime(split); + var start = float.Parse(split.Split('-')[0].TrimStart('*')); + var end = float.Parse(split.Split('-')[1]); var duration = fetch.Data.Duration; @@ -132,30 +133,4 @@ private bool ValidTimeRange(RunResult fetch) _logger.Error(TrimTimeError); return false; } - - /// - /// Extracts the start and end times from a given download section string. - /// - /// The string containing the download section details. - /// A tuple containing the start and end times as floats. - private static (float, float) ParseStartAndEndTime(string downloadSection) - { - /* - * The download section string is split into two parts using '-' as a separator. - * The '*' character, which is used as a regex symbol for "yt-dlp", - * it is not relevant for our parsing and is therefore removed. - * The start time is always on the left side of the split, and the end time is always on the right side. - */ - - var span = downloadSection.AsSpan(); - var separatorIndex = span.IndexOf('-'); - - var startSpan = span[..separatorIndex].Trim('*'); - var endSpan = span[(separatorIndex + 1)..]; - - var start = float.Parse(startSpan); - var end = float.Parse(endSpan); - - return (start, end); - } } diff --git a/CommandLineApp/Models/DownloadResult.cs b/CommandLineApp/Models/DownloadResult.cs index c66355c..0454ba9 100644 --- a/CommandLineApp/Models/DownloadResult.cs +++ b/CommandLineApp/Models/DownloadResult.cs @@ -1,3 +1,3 @@ namespace dis.CommandLineApp.Models; -public sealed record DownloadResult(string? OutPath, DateTime? Date); \ No newline at end of file +public sealed record DownloadResult(string? OutPath, DateTime? Date); diff --git a/CommandLineApp/Models/Settings.cs b/CommandLineApp/Models/Settings.cs index f0e4453..1ca2a20 100644 --- a/CommandLineApp/Models/Settings.cs +++ b/CommandLineApp/Models/Settings.cs @@ -23,10 +23,15 @@ You should use a sane value between 22 and 38 """)] [CommandOption("-c|--crf")] [DefaultValue(25)] - public int Crf { get; init; } + public int Crf { get; set; } + [Description(""" + Resolution to be used for when compressing the video. + Available resolutions: + 144p, 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p + """)] [CommandOption("-r|--resolution")] - public string? Resolution { get; init; } + public string? Resolution { get; set; } [Description(""" Trim input @@ -36,7 +41,12 @@ Trim input [CommandOption("-t|--trim")] public string? Trim { get; init; } - [Description("Video codec")] + [Description(""" + Avaliable codecs: + h264, libx264 - default + vp9, libvpx-vp9 - VP9 (Webm) is recommended for 1080p + av1, libaom-av1 - AV1 is recommended for 4K + """)] [CommandOption("--video-codec")] public string? VideoCodec { get; init; } diff --git a/CommandLineApp/RootCommand.cs b/CommandLineApp/RootCommand.cs index 153213f..69c3a41 100644 --- a/CommandLineApp/RootCommand.cs +++ b/CommandLineApp/RootCommand.cs @@ -142,20 +142,9 @@ private void ValidateTrim(string? trim) private void ValidateResolution(string? resolution) { var hasResolution = resolution is not null; - if (!hasResolution) return; + if (hasResolution is false) return; - var validResolution = resolution switch - { - "144p" => true, - "240p" => true, - "360p" => true, - "480p" => true, - "720p" => true, - "1080p" => true, - "1440p" => true, - "2160p" => true, - _ => false - }; + var validResolution = globals.ValidResolutions.Contains(resolution); if (validResolution is false) ValidationResult.Error("Invalid resolution"); } @@ -165,14 +154,10 @@ private void ValidateVideoCodec(string? videoCodec) var hasVideoCodec = videoCodec is not null; if (!hasVideoCodec) return; - var validVideoCodec = videoCodec switch - { - "h264" => true, - "h265" => true, - "vp8" => true, - "vp9" => true, - _ => false - }; + var validVideoCodec = globals.VideoCodecs.Values + .Any(codec => codec.ToString() + .Equals(videoCodec, + StringComparison.InvariantCultureIgnoreCase)); if (validVideoCodec is false) ValidationResult.Error("Invalid video codec"); } @@ -218,7 +203,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se private async Task Download(IEnumerable links, Dictionary videos, Settings options) { var list = links.ToList(); - if (list.Count == 0) + if (list.Count is 0) return; foreach (var downloadOptions in list.Select(link => new DownloadOptions(link, options))) diff --git a/Globals.cs b/Globals.cs index cd477f0..7701de4 100644 --- a/Globals.cs +++ b/Globals.cs @@ -13,6 +13,20 @@ public sealed class Globals OverwriteFiles = false }; + /// + /// Lists of valid video resolutions. + /// + public readonly IEnumerable ValidResolutions = [ + "144p", + "240p", + "360p", + "480p", + "720p", + "1080p", + "1440p", + "2160p" + ]; + /// /// This list maintains the paths to videos that have been temporarily downloaded. /// These paths are stored for future deletion of the corresponding files. @@ -27,4 +41,4 @@ public sealed class Globals { ["vp9", "libvpx-vp9"], VideoCodec.vp9 }, { ["av1", "libaom-av1"], VideoCodec.av1 } }; -} \ No newline at end of file +} diff --git a/default.nix b/default.nix index 82cf33c..dd0ede1 100644 --- a/default.nix +++ b/default.nix @@ -9,7 +9,7 @@ }: buildDotnetModule { pname = "dis"; - version = "9.0.1"; + version = "9.1.0"; src = ./.; diff --git a/dis.csproj b/dis.csproj index d958a7c..c28315d 100644 --- a/dis.csproj +++ b/dis.csproj @@ -5,7 +5,7 @@ net8.0 enable enable - 9.0.1 + 9.1.0