From 9923c6112fb83a2d94c1ad5d1bfda4f7ab76d08f Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:47:46 +0900 Subject: [PATCH 001/247] Upgrade projects to .NET 10 --- src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj | 2 +- src/CCVTAC.Console/CCVTAC.Console.csproj | 2 +- src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj | 2 +- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 2 +- src/CCVTAC.FSharp/Settings.fs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj b/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj index 92b30502..6190aa8a 100644 --- a/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj +++ b/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 disable enable false diff --git a/src/CCVTAC.Console/CCVTAC.Console.csproj b/src/CCVTAC.Console/CCVTAC.Console.csproj index 37247ef8..fd815bfa 100644 --- a/src/CCVTAC.Console/CCVTAC.Console.csproj +++ b/src/CCVTAC.Console/CCVTAC.Console.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 disable enable true diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj index 1552d610..acbaab51 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 false false true diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 32e33279..210dd2b4 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 true true diff --git a/src/CCVTAC.FSharp/Settings.fs b/src/CCVTAC.FSharp/Settings.fs index 4308a7de..71c5a35f 100644 --- a/src/CCVTAC.FSharp/Settings.fs +++ b/src/CCVTAC.FSharp/Settings.fs @@ -171,7 +171,7 @@ module Settings = let confirmedPath = match filePath with | Some p -> p - | None -> FilePath <| Path.Combine(AppContext.BaseDirectory, defaultFileName); + | None -> FilePath (Path.Combine(AppContext.BaseDirectory, defaultFileName)) let defaultSettings = { From 18c4ae03f79b37daf6ddd60f43875560ad445f71 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:57:34 +0900 Subject: [PATCH 002/247] Update NuGet packages, fix bad ForEach() calls --- src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj | 6 +++--- src/CCVTAC.Console/CCVTAC.Console.csproj | 4 ++-- src/CCVTAC.Console/Downloading/Downloader.cs | 2 +- src/CCVTAC.Console/Downloading/Updater.cs | 2 +- src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj b/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj index 6190aa8a..3ef4d8b6 100644 --- a/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj +++ b/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj @@ -10,13 +10,13 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/CCVTAC.Console/CCVTAC.Console.csproj b/src/CCVTAC.Console/CCVTAC.Console.csproj index fd815bfa..691ccc88 100644 --- a/src/CCVTAC.Console/CCVTAC.Console.csproj +++ b/src/CCVTAC.Console/CCVTAC.Console.csproj @@ -7,8 +7,8 @@ true - - + + diff --git a/src/CCVTAC.Console/Downloading/Downloader.cs b/src/CCVTAC.Console/Downloading/Downloader.cs index f4a70113..5fcccc3c 100644 --- a/src/CCVTAC.Console/Downloading/Downloader.cs +++ b/src/CCVTAC.Console/Downloading/Downloader.cs @@ -78,7 +78,7 @@ Printer printer if (errors.Count != 0) { - downloadResult.Errors.ForEach(e => printer.Error(e.Message)); + downloadResult.Errors.ToList().ForEach(e => printer.Error(e.Message)); printer.Info("Post-processing will still be attempted."); // For any partial downloads } else if (urls.Supplementary is not null) diff --git a/src/CCVTAC.Console/Downloading/Updater.cs b/src/CCVTAC.Console/Downloading/Updater.cs index 1bb8800a..e66049af 100644 --- a/src/CCVTAC.Console/Downloading/Updater.cs +++ b/src/CCVTAC.Console/Downloading/Updater.cs @@ -46,7 +46,7 @@ private record Urls(string Primary, string? Supplementary); if (errors.Count != 0) { - result.Errors.ForEach(e => printer.Error(e.Message)); + result.Errors.ToList().ForEach(e => printer.Error(e.Message)); } return errors.Count > 0 diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj index acbaab51..bb09d563 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj @@ -11,13 +11,13 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From a32a4174ad4d36a2482f02e6ec1b1b2b07c69007 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:09:18 +0900 Subject: [PATCH 003/247] Various code fixes and tweaks --- README.md | 2 +- src/CCVTAC.Console/Downloading/Downloader.cs | 2 +- src/CCVTAC.Console/ExtensionMethods.cs | 92 ++++++------ .../ExternalTools/ToolSettings.cs | 3 - src/CCVTAC.Console/Help.cs | 2 +- src/CCVTAC.Console/Orchestrator.cs | 12 +- .../PostProcessing/Tagging/Tagger.cs | 6 +- .../YouTubeMetadataExtensionMethods.cs | 131 +++++++++--------- src/CCVTAC.Console/Program.cs | 2 +- 9 files changed, 126 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index b53c6227..6dd712c8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ While I maintain it for my own use, feel free to use it yourself! However, pleas ## Prerequisites -- [.NET 9 runtime](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) +- [.NET 10 runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) - [yt-dlp](https://github.com/yt-dlp/yt-dlp) - [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) - Optional: [mogrify](https://imagemagick.org/script/mogrify.php) (for auto-trimming album art) diff --git a/src/CCVTAC.Console/Downloading/Downloader.cs b/src/CCVTAC.Console/Downloading/Downloader.cs index 5fcccc3c..2972e284 100644 --- a/src/CCVTAC.Console/Downloading/Downloader.cs +++ b/src/CCVTAC.Console/Downloading/Downloader.cs @@ -6,7 +6,7 @@ namespace CCVTAC.Console.Downloading; internal static class Downloader { - internal static readonly string ProgramName = "yt-dlp"; + private static readonly string ProgramName = "yt-dlp"; private record Urls(string Primary, string? Supplementary); diff --git a/src/CCVTAC.Console/ExtensionMethods.cs b/src/CCVTAC.Console/ExtensionMethods.cs index 8c2474e0..1e44e5f4 100644 --- a/src/CCVTAC.Console/ExtensionMethods.cs +++ b/src/CCVTAC.Console/ExtensionMethods.cs @@ -18,59 +18,63 @@ public static bool HasText(this string? maybeText, bool allowWhiteSpace = false) : !string.IsNullOrWhiteSpace(maybeText); } - /// - /// Determines whether a collection is empty. - /// - public static bool None(this IEnumerable collection) => !collection.Any(); + extension(IEnumerable collection) + { + /// + /// Determines whether a collection is empty. + /// + public bool None() => !collection.Any(); - /// - /// Determines whether no elements of a sequence satisfy a given condition. - /// - public static bool None(this IEnumerable collection, Func predicate) => - !collection.Any(predicate); + /// + /// Determines whether no elements of a sequence satisfy a given condition. + /// + public bool None(Func predicate) => + !collection.Any(predicate); + } public static bool CaseInsensitiveContains(this IEnumerable collection, string text) => collection.Contains(text, new Comparers.CaseInsensitiveStringComparer()); - /// - /// Returns a new string in which all invalid path characters for the current OS - /// have been replaced by specified replacement character. - /// Throws if the replacement character is an invalid path character. - /// /// - /// - /// Optional additional characters to consider invalid. - public static string ReplaceInvalidPathChars( - this string sourceText, - char replaceWith = '_', - char[]? customInvalidChars = null - ) + extension(string sourceText) { - var invalidChars = Path.GetInvalidFileNameChars() - .Concat(Path.GetInvalidPathChars()) - .Concat( - [ - Path.PathSeparator, - Path.DirectorySeparatorChar, - Path.AltDirectorySeparatorChar, - Path.VolumeSeparatorChar, - ] - ) - .Concat(customInvalidChars ?? Enumerable.Empty()) - .ToFrozenSet(); + /// + /// Returns a new string in which all invalid path characters for the current OS + /// have been replaced by specified replacement character. + /// Throws if the replacement character is an invalid path character. + /// + /// + /// Optional additional characters to consider invalid. + public string ReplaceInvalidPathChars(char replaceWith = '_', + char[]? customInvalidChars = null + ) + { + var invalidChars = Path.GetInvalidFileNameChars() + .Concat(Path.GetInvalidPathChars()) + .Concat( + [ + Path.PathSeparator, + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar, + Path.VolumeSeparatorChar, + ] + ) + .Concat(customInvalidChars ?? Enumerable.Empty()) + .ToFrozenSet(); - if (invalidChars.Contains(replaceWith)) - throw new ArgumentException( - $"The replacement char ('{replaceWith}') must be a valid path character." + if (invalidChars.Contains(replaceWith)) + throw new ArgumentException( + $"The replacement char ('{replaceWith}') must be a valid path character." + ); + + return invalidChars.Aggregate( + new StringBuilder(sourceText), + (workingText, ch) => workingText.Replace(ch, replaceWith), + workingText => workingText.ToString() ); + } - return invalidChars.Aggregate( - new StringBuilder(sourceText), - (workingText, ch) => workingText.Replace(ch, replaceWith), - workingText => workingText.ToString() - ); + public string TrimTerminalLineBreak() => + sourceText.HasText() ? sourceText.TrimEnd(Environment.NewLine.ToCharArray()) : sourceText; } - - public static string TrimTerminalLineBreak(this string text) => - text.HasText() ? text.TrimEnd(Environment.NewLine.ToCharArray()) : text; } diff --git a/src/CCVTAC.Console/ExternalTools/ToolSettings.cs b/src/CCVTAC.Console/ExternalTools/ToolSettings.cs index df98165e..f02244ca 100644 --- a/src/CCVTAC.Console/ExternalTools/ToolSettings.cs +++ b/src/CCVTAC.Console/ExternalTools/ToolSettings.cs @@ -3,7 +3,4 @@ namespace CCVTAC.Console.ExternalTools; /// /// Settings to govern the behavior of an external program. /// -/// The external utility to be executed. -/// All arguments to be passed to the external utility. -/// The directory in which context the utility should be run. internal sealed record ToolSettings(string CommandWithArgs, string WorkingDirectory); diff --git a/src/CCVTAC.Console/Help.cs b/src/CCVTAC.Console/Help.cs index 69122b35..dd7fd327 100644 --- a/src/CCVTAC.Console/Help.cs +++ b/src/CCVTAC.Console/Help.cs @@ -26,7 +26,7 @@ No warranties or guarantees are provided. PREREQUISITES - • .NET 9 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/9.0) + • .NET 10 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/10.0) • yt-dlp (https://github.com/yt-dlp/yt-dlp) • [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) • Optional: mogrify https://imagemagick.org/script/mogrify.php diff --git a/src/CCVTAC.Console/Orchestrator.cs b/src/CCVTAC.Console/Orchestrator.cs index 16ffe114..21d8cf73 100644 --- a/src/CCVTAC.Console/Orchestrator.cs +++ b/src/CCVTAC.Console/Orchestrator.cs @@ -20,7 +20,7 @@ internal class Orchestrator internal static void Start(UserSettings settings, Printer printer) { // The working directory should start empty. Give the user a chance to empty it. - var emptyDirResult = IoUtilities.Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); + var emptyDirResult = Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); if (emptyDirResult.IsFailed) { printer.FirstError(emptyDirResult); @@ -147,7 +147,7 @@ private static Result ProcessUrl( Printer printer ) { - var emptyDirResult = IoUtilities.Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); + var emptyDirResult = Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); if (emptyDirResult.IsFailed) { printer.FirstError(emptyDirResult); @@ -238,10 +238,10 @@ Printer printer } if (Commands.SettingsSummary.CaseInsensitiveContains(command)) - { - SettingsAdapter.PrintSummary(settings, printer); - return Result.Ok(NextAction.Continue); - } + { + SettingsAdapter.PrintSummary(settings, printer); + return Result.Ok(NextAction.Continue); + } static string SummarizeToggle(string settingName, bool setting) => $"{settingName} was toggled to {(setting ? "ON" : "OFF")} for this session."; diff --git a/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs b/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs index 3ccbc72f..288028af 100644 --- a/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs +++ b/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs @@ -186,10 +186,8 @@ Printer printer printer.Debug($"Wrote tags to \"{audioFileName}\"."); return; - /// - /// If the supplied video uploader is specified in the settings, returns the video's upload year. - /// Otherwise, returns null. - /// + // If the supplied video uploader is specified in the settings, returns the video's upload year. + // Otherwise, returns null. static ushort? GetAppropriateReleaseDateIfAny( UserSettings settings, VideoMetadata videoData diff --git a/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs b/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs index ea750a31..2b915aff 100644 --- a/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs +++ b/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs @@ -2,82 +2,83 @@ namespace CCVTAC.Console.PostProcessing; public static class YouTubeMetadataExtensionMethods { - /// - /// Returns a string summarizing video uploader information. - /// - private static string UploaderSummary(this VideoMetadata videoData) + extension(VideoMetadata videoData) { - string uploaderLinkOrIdOrEmpty = - videoData.UploaderUrl.HasText() ? videoData.UploaderUrl - : videoData.UploaderId.HasText() ? videoData.UploaderId - : string.Empty; - - return videoData.Uploader - + (uploaderLinkOrIdOrEmpty.HasText() ? $" ({uploaderLinkOrIdOrEmpty})" : string.Empty); - } - - /// - /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") from the - /// plain YYYYMMDD version (e.g., "20230827") within the parsed JSON file data. - /// - private static string FormattedUploadDate(this VideoMetadata videoData) - { - return $"{videoData.UploadDate[4..6]}/{videoData.UploadDate[6..8]}/{videoData.UploadDate[..4]}"; - } - - /// - /// Returns a formatted comment using data parsed from the JSON file. - /// - public static string GenerateComment( - this VideoMetadata videoData, - CollectionMetadata? maybeCollectionData - ) - { - System.Text.StringBuilder sb = new(); - - sb.AppendLine("CCVTAC SOURCE DATA:"); - sb.AppendLine($"■ Downloaded: {DateTime.Now}"); - // sb.AppendLine($"■ Service: {videoData.ExtractorKey}"); // "Youtube" - sb.AppendLine($"■ URL: {videoData.WebpageUrl}"); - sb.AppendLine($"■ Title: {videoData.Fulltitle}"); - sb.AppendLine($"■ Uploader: {videoData.UploaderSummary()}"); - if (videoData.Creator != videoData.Uploader && videoData.Creator.HasText()) - { - sb.AppendLine($"■ Creator: {videoData.Creator}"); - } - if (videoData.Artist.HasText()) - { - sb.AppendLine($"■ Artist: {videoData.Artist}"); - } - if (videoData.Album.HasText()) + /// + /// Returns a string summarizing video uploader information. + /// + private string UploaderSummary() { - sb.AppendLine($"■ Album: {videoData.Album}"); + string uploaderLinkOrIdOrEmpty = + videoData.UploaderUrl.HasText() ? videoData.UploaderUrl + : videoData.UploaderId.HasText() ? videoData.UploaderId + : string.Empty; + + return videoData.Uploader + + (uploaderLinkOrIdOrEmpty.HasText() ? $" ({uploaderLinkOrIdOrEmpty})" : string.Empty); } - if (videoData.Title.HasText() && videoData.Title != videoData.Fulltitle) + + /// + /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") from the + /// plain YYYYMMDD version (e.g., "20230827") within the parsed JSON file data. + /// + private string FormattedUploadDate() { - sb.AppendLine($"■ Track Title: {videoData.Title}"); + return $"{videoData.UploadDate[4..6]}/{videoData.UploadDate[6..8]}/{videoData.UploadDate[..4]}"; } - sb.AppendLine($"■ Uploaded: {videoData.FormattedUploadDate()}"); - var description = string.IsNullOrWhiteSpace(videoData.Description) - ? "None." - : videoData.Description; - sb.AppendLine($"■ Video description: {description}"); - if (maybeCollectionData is { } collectionData) + /// + /// Returns a formatted comment using data parsed from the JSON file. + /// + public string GenerateComment(CollectionMetadata? maybeCollectionData + ) { - sb.AppendLine(); - sb.AppendLine($"■ Playlist name: {collectionData.Title}"); - sb.AppendLine($"■ Playlist URL: {collectionData.WebpageUrl}"); - if (videoData.PlaylistIndex is { } index) + System.Text.StringBuilder sb = new(); + + sb.AppendLine("CCVTAC SOURCE DATA:"); + sb.AppendLine($"■ Downloaded: {DateTime.Now}"); + // sb.AppendLine($"■ Service: {videoData.ExtractorKey}"); // "Youtube" + sb.AppendLine($"■ URL: {videoData.WebpageUrl}"); + sb.AppendLine($"■ Title: {videoData.Fulltitle}"); + sb.AppendLine($"■ Uploader: {videoData.UploaderSummary()}"); + if (videoData.Creator != videoData.Uploader && videoData.Creator.HasText()) { - sb.AppendLine($"■ Playlist index: {index}"); + sb.AppendLine($"■ Creator: {videoData.Creator}"); } - if (collectionData.Description.HasText()) + if (videoData.Artist.HasText()) { - sb.AppendLine($"■ Playlist description: {collectionData.Description}"); + sb.AppendLine($"■ Artist: {videoData.Artist}"); + } + if (videoData.Album.HasText()) + { + sb.AppendLine($"■ Album: {videoData.Album}"); + } + if (videoData.Title.HasText() && videoData.Title != videoData.Fulltitle) + { + sb.AppendLine($"■ Track Title: {videoData.Title}"); + } + sb.AppendLine($"■ Uploaded: {videoData.FormattedUploadDate()}"); + var description = string.IsNullOrWhiteSpace(videoData.Description) + ? "None." + : videoData.Description; + sb.AppendLine($"■ Video description: {description}"); + + if (maybeCollectionData is { } collectionData) + { + sb.AppendLine(); + sb.AppendLine($"■ Playlist name: {collectionData.Title}"); + sb.AppendLine($"■ Playlist URL: {collectionData.WebpageUrl}"); + if (videoData.PlaylistIndex is { } index) + { + sb.AppendLine($"■ Playlist index: {index}"); + } + if (collectionData.Description.HasText()) + { + sb.AppendLine($"■ Playlist description: {collectionData.Description}"); + } } - } - return sb.ToString(); + return sb.ToString(); + } } } diff --git a/src/CCVTAC.Console/Program.cs b/src/CCVTAC.Console/Program.cs index 2a1f017f..d7bfb1e1 100644 --- a/src/CCVTAC.Console/Program.cs +++ b/src/CCVTAC.Console/Program.cs @@ -46,7 +46,7 @@ private static void Main(string[] args) { printer.Warning("\nQuitting at user's request."); - var warnResult = IoUtilities.Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); + var warnResult = Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); if (warnResult.IsSuccess) { From da3d1c3ea30ec38297289009ce9cdef5d85ab680 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:33:25 +0900 Subject: [PATCH 004/247] LLM translations --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 32 +- src/CCVTAC.FSharp/Commands.fs | 52 +++ src/CCVTAC.FSharp/Downloading/Downloader.fs | 54 +++ .../{ => Downloading}/Downloading.fs | 94 ++--- src/CCVTAC.FSharp/Downloading/Uploader.fs | 56 +++ src/CCVTAC.FSharp/ExtensionMethods.fs | 76 +++++ .../ExternalTools/ExternalTool.fs | 52 +++ src/CCVTAC.FSharp/ExternalTools/Runner.fs | 68 ++++ .../ExternalTools/ToolSettings.fs | 19 ++ src/CCVTAC.FSharp/Help.fs | 88 +++++ src/CCVTAC.FSharp/History.fs | 55 +++ src/CCVTAC.FSharp/InputHelper.fs | 76 +++++ src/CCVTAC.FSharp/IoUtilities/Directories.fs | 112 ++++++ src/CCVTAC.FSharp/Orchestrator.fs | 322 ++++++++++++++++++ .../PostProcessing/CollectionMetadata.fs | 27 ++ src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 68 ++++ .../PostProcessing/ImageProcessor.fs | 11 + src/CCVTAC.FSharp/PostProcessing/Mover.fs | 156 +++++++++ .../PostProcessing/PostProcessing.fs | 109 ++++++ src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 96 ++++++ .../PostProcessing/Tagging/Detectors.fs | 86 +++++ .../PostProcessing/Tagging/TagDetector.fs | 52 +++ .../PostProcessing/Tagging/Tagger.fs | 209 ++++++++++++ .../PostProcessing/Tagging/TaggingSet.fs | 81 +++++ .../PostProcessing/VideoMetadata.fs | 80 +++++ .../YouTubeMetadataExtensionMethods.fs | 65 ++++ src/CCVTAC.FSharp/Printer.fs | 145 ++++++++ src/CCVTAC.FSharp/Program.fs | 65 ++++ src/CCVTAC.FSharp/ResultTracker.fs | 45 +++ src/CCVTAC.FSharp/Settings/Id3Version.fs | 16 + src/CCVTAC.FSharp/{ => Settings}/Settings.fs | 0 31 files changed, 2418 insertions(+), 49 deletions(-) create mode 100644 src/CCVTAC.FSharp/Commands.fs create mode 100644 src/CCVTAC.FSharp/Downloading/Downloader.fs rename src/CCVTAC.FSharp/{ => Downloading}/Downloading.fs (97%) create mode 100644 src/CCVTAC.FSharp/Downloading/Uploader.fs create mode 100644 src/CCVTAC.FSharp/ExtensionMethods.fs create mode 100644 src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs create mode 100644 src/CCVTAC.FSharp/ExternalTools/Runner.fs create mode 100644 src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs create mode 100644 src/CCVTAC.FSharp/Help.fs create mode 100644 src/CCVTAC.FSharp/History.fs create mode 100644 src/CCVTAC.FSharp/InputHelper.fs create mode 100644 src/CCVTAC.FSharp/IoUtilities/Directories.fs create mode 100644 src/CCVTAC.FSharp/Orchestrator.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Deleter.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Mover.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Renamer.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs create mode 100644 src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs create mode 100644 src/CCVTAC.FSharp/Printer.fs create mode 100644 src/CCVTAC.FSharp/Program.fs create mode 100644 src/CCVTAC.FSharp/ResultTracker.fs create mode 100644 src/CCVTAC.FSharp/Settings/Id3Version.fs rename src/CCVTAC.FSharp/{ => Settings}/Settings.fs (100%) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 210dd2b4..961476f4 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -5,7 +5,35 @@ true - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs new file mode 100644 index 00000000..be41d88f --- /dev/null +++ b/src/CCVTAC.FSharp/Commands.fs @@ -0,0 +1,52 @@ +namespace CCVTAC.Console + +open System +open System.Collections.Generic + +module internal Commands = + + let Prefix : char = '\\' + + let private MakeCommand (text: string) : string = + if String.IsNullOrWhiteSpace(text) then + raise (ArgumentException("The text cannot be null or white space.", "text")) + if text.Contains(' ') then + raise (ArgumentException("The text should not contain any white space.", "text")) + sprintf "%c%s" Prefix text + + let QuitCommands : string[] = + [| MakeCommand "quit"; MakeCommand "q"; MakeCommand "exit" |] + + let HelpCommand : string = MakeCommand "help" + + let SettingsSummary : string[] = [| MakeCommand "settings" |] + + let History : string[] = [| MakeCommand "history" |] + + let UpdateDownloader : string[] = + [| MakeCommand "update-downloader"; MakeCommand "update-dl" |] + + let SplitChapterToggles : string[] = [| MakeCommand "split"; MakeCommand "toggle-split" |] + + let EmbedImagesToggles : string[] = [| MakeCommand "images"; MakeCommand "toggle-images" |] + + let QuietModeToggles : string[] = [| MakeCommand "quiet"; MakeCommand "toggle-quiet" |] + + let UpdateAudioFormatPrefix : string = MakeCommand "format-" + + let UpdateAudioQualityPrefix : string = MakeCommand "quality-" + + let Summary : Dictionary = + let d = Dictionary() + d.Add(String.Join(" or ", History), "See the most recently entered URLs") + d.Add(String.Join(" or ", SplitChapterToggles), "Toggles chapter splitting for the current session only") + d.Add(String.Join(" or ", EmbedImagesToggles), "Toggles image embedding for the current session only") + d.Add(String.Join(" or ", QuietModeToggles), "Toggles quiet mode for the current session only") + d.Add(String.Join(" or ", UpdateDownloader), "Updates the downloader using the command specified in the settings") + d.Add(UpdateAudioFormatPrefix, + sprintf "Followed by a supported audio format (e.g., %sm4a), changes the audio format for the current session only" UpdateAudioFormatPrefix) + d.Add(UpdateAudioQualityPrefix, + sprintf "Followed by a supported audio quality (e.g., %s0), changes the audio quality for the current session only" UpdateAudioQualityPrefix) + d.Add(String.Join(" or ", QuitCommands), "Quit the application") + d.Add(HelpCommand, "See this help message") + d diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs new file mode 100644 index 00000000..a183ec19 --- /dev/null +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -0,0 +1,54 @@ +namespace CCVTAC.Console.Downloading + +open CCVTAC.Console.ExternalTools +open CCVTAC.FSharp.Settings + +/// Manages downloader updates +module Updater = + /// Represents download URLs + type private Urls = { + Primary: string + Supplementary: string option + } + + /// Completes the actual download process. + /// A `Result` that, if successful, contains the name of the successfully downloaded format. + let internal run (settings: UserSettings) (printer: Printer) = + // Check if update command is provided + if System.String.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand) then + printer.Info("No downloader update command provided, so will skip.") + Ok() + else + // Prepare tool settings + let args = ToolSettings( + settings.DownloaderUpdateCommand, + settings.WorkingDirectory + ) + + // Run the update process + match Runner.Run(args, [||], printer) with + | Ok (exitCode, warnings) -> + // Handle successful run with potential warnings + if exitCode <> 0 then + printer.Warning("Update completed with minor issues.") + + if not (System.String.IsNullOrEmpty(warnings)) then + printer.Warning(warnings) + + Ok() + + | Error errors -> + // Handle errors + printer.Error("Failure updating...") + + // Print and process errors + errors + |> Array.iter (fun e -> printer.Error(e.Message)) + + // Return failure result if errors exist + if errors.Length > 0 then + Error (System.String.Join(" / ", errors |> Array.map (fun e -> e.Message))) + else + Ok() + CCVTAC.FSharp.Downloading.Downloader + diff --git a/src/CCVTAC.FSharp/Downloading.fs b/src/CCVTAC.FSharp/Downloading/Downloading.fs similarity index 97% rename from src/CCVTAC.FSharp/Downloading.fs rename to src/CCVTAC.FSharp/Downloading/Downloading.fs index 90fd242c..b11412d0 100644 --- a/src/CCVTAC.FSharp/Downloading.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloading.fs @@ -1,47 +1,47 @@ -namespace CCVTAC.FSharp - -module public Downloading = - open System.Text.RegularExpressions - - type MediaType = - | Video of Id: string - | PlaylistVideo of VideoId: string * PlaylistId: string - | StandardPlaylist of Id: string - | ReleasePlaylist of Id: string - | Channel of Id: string - - let private (|Regex|_|) pattern input = - match Regex.Match(input, pattern) with - | m when m.Success -> Some (List.tail [for g in m.Groups -> g.Value]) - | _ -> None - - [] - let mediaTypeWithIds url = - match url with - | Regex @"(?<=v=|v\=)([\w-]{11})(?:&list=([\w_-]+))" [videoId; playlistId] -> - Ok (PlaylistVideo (videoId, playlistId)) - | Regex @"^([\w-]{11})$" [id] - | Regex @"(?<=v=|v\\=)([\w-]{11})" [id] - | Regex @"(?<=youtu\.be/)(.{11})" [id] -> - Ok (Video id) - | Regex @"(?<=list=)(P[\w\-]+)" [id] -> - Ok (StandardPlaylist id) - | Regex @"(?<=list=)(O[\w\-]+)" [id] -> - Ok (ReleasePlaylist id) - | Regex @"((?:www\.)?youtube\.com\/(?:channel\/|c\/|user\/|@)(?:[A-Za-z0-9\-@%\/]+))" [ id ] -> - Ok (Channel id) - | _ -> - Error $"Unable to determine media type of URL \"{url}\". (Might it contain invalid characters?)" - - [] - let extractDownloadUrls mediaType = - let fullUrl urlBase id = urlBase + id - let videoUrl = fullUrl "https://www.youtube.com/watch?v=" - let playlistUrl = fullUrl "https://www.youtube.com/playlist?list=" - let channelUrl = fullUrl "https://" // For channels, the domain portion is also matched. - - match mediaType with - | Video id -> [videoUrl id] - | PlaylistVideo (vId, pId) -> [videoUrl vId; playlistUrl pId] - | StandardPlaylist id | ReleasePlaylist id -> [playlistUrl id] - | Channel id -> [channelUrl id] +namespace CCVTAC.FSharp + +module public Downloading = + open System.Text.RegularExpressions + + type MediaType = + | Video of Id: string + | PlaylistVideo of VideoId: string * PlaylistId: string + | StandardPlaylist of Id: string + | ReleasePlaylist of Id: string + | Channel of Id: string + + let private (|Regex|_|) pattern input = + match Regex.Match(input, pattern) with + | m when m.Success -> Some (List.tail [for g in m.Groups -> g.Value]) + | _ -> None + + [] + let mediaTypeWithIds url = + match url with + | Regex @"(?<=v=|v\=)([\w-]{11})(?:&list=([\w_-]+))" [videoId; playlistId] -> + Ok (PlaylistVideo (videoId, playlistId)) + | Regex @"^([\w-]{11})$" [id] + | Regex @"(?<=v=|v\\=)([\w-]{11})" [id] + | Regex @"(?<=youtu\.be/)(.{11})" [id] -> + Ok (Video id) + | Regex @"(?<=list=)(P[\w\-]+)" [id] -> + Ok (StandardPlaylist id) + | Regex @"(?<=list=)(O[\w\-]+)" [id] -> + Ok (ReleasePlaylist id) + | Regex @"((?:www\.)?youtube\.com\/(?:channel\/|c\/|user\/|@)(?:[A-Za-z0-9\-@%\/]+))" [ id ] -> + Ok (Channel id) + | _ -> + Error $"Unable to determine media type of URL \"{url}\". (Might it contain invalid characters?)" + + [] + let extractDownloadUrls mediaType = + let fullUrl urlBase id = urlBase + id + let videoUrl = fullUrl "https://www.youtube.com/watch?v=" + let playlistUrl = fullUrl "https://www.youtube.com/playlist?list=" + let channelUrl = fullUrl "https://" // For channels, the domain portion is also matched. + + match mediaType with + | Video id -> [videoUrl id] + | PlaylistVideo (vId, pId) -> [videoUrl vId; playlistUrl pId] + | StandardPlaylist id | ReleasePlaylist id -> [playlistUrl id] + | Channel id -> [channelUrl id] diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs new file mode 100644 index 00000000..f03646dc --- /dev/null +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -0,0 +1,56 @@ +module CCVTAC.FSharp.Downloading.Uploader + +open CCVTAC.Console.ExternalTools +open CCVTAC.FSharp.Settings + +module Updater = + /// Represents download URLs + type private Urls = { + Primary: string + Supplementary: string option + } + + /// Completes the actual download process. + /// A `Result` that, if successful, contains the name of the successfully downloaded format. + let internal run (settings: UserSettings) (printer: Printer) = + // Early return if no update command + if System.String.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand) then + printer.Info("No downloader update command provided, so will skip.") + Ok() + else + // Prepare tool settings + let args = ToolSettings( + settings.DownloaderUpdateCommand, + settings.WorkingDirectory + ) + + // Run the update process + match Runner.Run(args, [||], printer) with + | Ok (exitCode, warnings) -> + // Handle non-zero exit code with potential warnings + if exitCode <> 0 then + printer.Warning("Update completed with minor issues.") + + if not (System.String.IsNullOrEmpty(warnings)) then + printer.Warning(warnings) + + Ok() + + | Error errors -> + // Handle and log errors + printer.Error("Failure updating...") + + // Collect error messages + let errorMessages = + errors + |> Array.map (fun e -> e.Message) + + // Print individual error messages + errorMessages + |> Array.iter printer.Error + + // Return result based on error messages + if errorMessages.Length > 0 then + Error (System.String.Join(" / ", errorMessages)) + else + Ok() diff --git a/src/CCVTAC.FSharp/ExtensionMethods.fs b/src/CCVTAC.FSharp/ExtensionMethods.fs new file mode 100644 index 00000000..836a3adc --- /dev/null +++ b/src/CCVTAC.FSharp/ExtensionMethods.fs @@ -0,0 +1,76 @@ +namespace CCVTAC.Console + +open System +open System.IO +open System.Text +open System.Collections.Generic +open System.Linq + +module ExtensionMethods = + + /// Determines whether a string contains any text. + /// allowWhiteSpace = true allows whitespace to count as text. + let HasText (maybeText: string) (allowWhiteSpace: bool) = + if allowWhiteSpace then + not (String.IsNullOrEmpty maybeText) + else + not (String.IsNullOrWhiteSpace maybeText) + + /// Overload with default parameter for F# callers. + let HasTextDefault (maybeText: string) = HasText maybeText false + + /// Collection helpers (similar to the original extension members). + module SeqEx = + /// Determines whether a sequence is empty. + let None (collection: seq<'T>) : bool = + Seq.isEmpty collection + + /// Determines whether no elements of a sequence satisfy a given condition. + let NoneBy (predicate: 'T -> bool) (collection: seq<'T>) : bool = + not (Seq.exists predicate collection) + + /// Case-insensitive contains for a sequence of strings. + let CaseInsensitiveContains (collection: seq) (text: string) : bool = + collection + |> Seq.exists (fun s -> String.Equals(s, text, StringComparison.OrdinalIgnoreCase)) + + /// String instance helpers as an F# type extension for System.String. + type System.String with + + /// Returns a new string in which all invalid path characters for the current OS + /// have been replaced by the specified replacement character. + /// Throws if the replacement character is an invalid path character. + member this.ReplaceInvalidPathChars(?replaceWith: char, ?customInvalidChars: char[]) : string = + let replaceWith = defaultArg replaceWith '_' + let custom = defaultArg customInvalidChars [||] + + // Collect invalid characters + let invalidCharsSeq = + seq { + yield! Path.GetInvalidFileNameChars() + yield! Path.GetInvalidPathChars() + yield Path.PathSeparator + yield Path.DirectorySeparatorChar + yield Path.AltDirectorySeparatorChar + yield Path.VolumeSeparatorChar + yield! custom + } + |> Seq.distinct + + let invalidSet = HashSet(invalidCharsSeq) + + if invalidSet.Contains(replaceWith) then + invalidArg "replaceWith" (sprintf "The replacement char ('%c') must be a valid path character." replaceWith) + + // Replace each invalid char in the string using StringBuilder for efficiency + let sb = StringBuilder(this) + for ch in invalidSet do + sb.Replace(ch, replaceWith) |> ignore + sb.ToString() + + /// Trims trailing newline characters (Environment.NewLine) from the end of the string. + member this.TrimTerminalLineBreak() : string = + if HasTextDefault this then + this.TrimEnd(Environment.NewLine.ToCharArray()) + else + this diff --git a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs new file mode 100644 index 00000000..cb69e8a2 --- /dev/null +++ b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs @@ -0,0 +1,52 @@ +namespace CCVTAC.Console.ExternalTools + +open System.Diagnostics + +type ExternalTool = { + /// The name of the program. This should be the exact text used to call it + /// on the command line, excluding any arguments. + Name: string + + /// The URL of the program, from which users should install it if needed. + Url: string + + /// A brief summary of the purpose of the program within the context of this program. + /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). + Purpose: string +} with + /// Creates a new ExternalTool instance + /// The name of the program. This should be the exact text used to call it + /// on the command line, excluding any arguments. + /// The URL of the program, from which users should install it if needed. + /// A brief summary of the purpose of the program within the context of this program. + /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). + static member Create(name: string, url: string, purpose: string) = + { + Name = name.Trim() + Url = url.Trim() + Purpose = purpose.Trim() + } + + /// Attempts a dry run of the program to determine if it is installed and available on this system. + /// A Result indicating whether the program is available or not. + member this.ProgramExists() = + let processStartInfo = ProcessStartInfo( + FileName = this.Name, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + ) + + try + use process' = Process.Start(processStartInfo) + + match process' with + | null -> + Error $"The program \"{this.Name}\" was not found. (The process was null.)" + | _ -> + process'.WaitForExit() + Ok() + with + | _ -> + Error $"The program \"{this.Name}\" was not found." diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs new file mode 100644 index 00000000..0659488a --- /dev/null +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -0,0 +1,68 @@ +namespace CCVTAC.Console.ExternalTools + +open System.Diagnostics + +module Runner = + /// Authentic success exit code + [] + let private AuthenticSuccessExitCode = 0 + + /// Determines if the exit code is considered successful + let private isSuccessExitCode (otherSuccessExitCodes: int[]) (exitCode: int) = + Array.contains exitCode (Array.append otherSuccessExitCodes [|AuthenticSuccessExitCode|]) + + /// Calls an external application. + /// Tool settings for execution + /// Additional exit codes, other than 0, that can be treated as non-failures + /// Printer for logging + /// A `Result` containing the exit code, if successful, or else an error message + let internal run + (settings: ToolSettings) + (otherSuccessExitCodes: int[]) + (printer: Printer) = + + let watch = Watch() + + // Log start of execution + printer.Info($"Running {settings.CommandWithArgs}...") + + // Split command and arguments + let splitCommandWithArgs = + settings.CommandWithArgs.Split([|' '|], 2) + + // Prepare process start info + let processStartInfo = ProcessStartInfo( + FileName = splitCommandWithArgs.[0], + Arguments = if splitCommandWithArgs.Length > 1 then splitCommandWithArgs.[1] else "", + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = settings.WorkingDirectory + ) + + // Start the process + match Process.Start(processStartInfo) with + | null -> + // Process failed to start + Error $"Could not locate {splitCommandWithArgs.[0]}." + | process -> + // Read errors before waiting for exit + let errors = process.StandardError.ReadToEnd() + + // Wait for process to complete + process.WaitForExit() + + // Log completion time + printer.Info($"{splitCommandWithArgs.[0]} finished in {watch.ElapsedFriendly}.") + + // Trim terminal line break from errors + let trimmedErrors = errors.TrimTerminalLineBreak() + + // Determine result based on exit code + if isSuccessExitCode otherSuccessExitCodes process.ExitCode then + // Successful execution (with potential warnings) + Ok (process.ExitCode, trimmedErrors) + else + // Failed execution + Error $"{splitCommandWithArgs.[0]} exited with code {process.ExitCode}: {trimmedErrors}." diff --git a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs b/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs new file mode 100644 index 00000000..29adc045 --- /dev/null +++ b/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs @@ -0,0 +1,19 @@ +namespace CCVTAC.Console.ExternalTools + +/// Settings to govern the behavior of an external program. +type ToolSettings = { + /// The full command with its arguments + CommandWithArgs: string + + /// The working directory for the tool's execution + WorkingDirectory: string +} + +[] +module ToolSettings = + /// Creates a new ToolSettings instance + let create (commandWithArgs: string) (workingDirectory: string) = + { + CommandWithArgs = commandWithArgs + WorkingDirectory = workingDirectory + } diff --git a/src/CCVTAC.FSharp/Help.fs b/src/CCVTAC.FSharp/Help.fs new file mode 100644 index 00000000..bf796a7f --- /dev/null +++ b/src/CCVTAC.FSharp/Help.fs @@ -0,0 +1,88 @@ +namespace CCVTAC.Console + +module Help = + + let internal Print (printer: Printer) : unit = + let helpText = """ + CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI + tool that acts as a wrapper around yt-dlp (https://github.com/yt-dlp/yt-dlp) + to enable easier downloads of audio from YouTube videos, playlists, and + channels, plus do some automatic post-processing (tagging, renaming, and + moving) too. + + While I maintain it primarily for my own use, feel free to use it yourself. + No warranties or guarantees are provided. + + FEATURES + + - Converts YouTube videos, playlists, and channels to local audio files (via yt-dlp) + - Writes ID3 tags to files where possible using available or regex-detected metadata + - Adds video metadata (channel name and URL, video URL, etc.) to files' Comment tags + - Auto-renames files via custom regex patterns (to remove media IDs, etc.) + - Optionally writes video thumbnails to files as artwork (if mogrify is installed) + - Customized behavior via a user settings file -- e.g., chapter splitting, image embedding, directories + - Saves entered URLs to a local history file + + PREREQUISITES + + • .NET 10 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/10.0) + • yt-dlp (https://github.com/yt-dlp/yt-dlp) + • [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) + • Optional: mogrify https://imagemagick.org/script/mogrify.php + (for auto-trimming album art) + + RUNNING IT + + Settings: + + A valid settings file is mandatory to use this application. + + The application will look for a file named `settings.json` in its directory. + However, you can manually specify an existing file path using the `-s` + option, such as `dotnet run -- -s `. + + If your `settings.json` file does not exist, a default file will be created in the + application directory with default settings. At minimum, you will need to + enter (1) an existing directory for temporary working files, (2) an existing + directory to which the final audio files should be moved, and (3) a path to + your history file. The other settings have sensible defaults. + + I added the `sleepSecondsBetweenDownloads` and `sleepSecondsBetweenURLs` + settings to help reduce concentrated loads on YouTube servers. Please avoid + lowering these values too much and slamming their servers with enormous, + long-running downloads (even if you feel their servers can take it). Such behavior + might get you rate-limited by YouTube. + + See the README file on the GitHub repo for more about settings. + + Using the application: + + Once your settings are ready, run the application with `dotnet run`. + Alternatively, pass `-h` or `--help` for instructions (e.g., + `dotnet run -- --help`). + + When the application is running, enter at least one YouTube media URL (video, + playlist, or channel) at the prompt and press Enter. No spaces between + items are necessary. + + You can also enter the following commands: + - "\help" to see this list of commands + - "\quit" or "\q" to quit + - "\history" to see the last few URLs you entered + - "\update-downloader" or "\update-dl" to update yt-dlp using the command in your settings + (If you start experiencing constant download errors, try this command) + - Modify the current session only (without updating the settings file): + - `\split` toggles chapter splitting + - `\images` toggles image embedding + - `\quiet` toggles quiet mode + - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the format + - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality + + Enter `\commands` to see this summary in the application. + + Reporting issues: + + If you run into any issues, feel free to create an issue on GitHub. Please provide as much + information as possible (e.g., entered URLs, system information, yt-dlp version). + """ + printer.Info(helpText, processMarkup = false) diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs new file mode 100644 index 00000000..396efa44 --- /dev/null +++ b/src/CCVTAC.FSharp/History.fs @@ -0,0 +1,55 @@ +namespace CCVTAC.Console + +open System +open System.IO +open System.Linq +open System.Text.Json +open Spectre.Console + +type History(filePath: string, displayCount: byte) = + + let separator = ';' + member private _.FilePath = filePath + member private _.DisplayCount = displayCount + + /// Add a URL and related data to the history file. + member this.Append(url: string, entryTime: DateTime, printer: Printer) : unit = + try + let serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") + File.AppendAllText(this.FilePath, serializedEntryTime + string separator + url + Environment.NewLine) + printer.Debug (sprintf "Added \"%s\" to the history log." url) + with ex -> + printer.Error ("Could not append URL(s) to history log: " + ex.Message) + + member this.ShowRecent(printer: Printer) : unit = + try + // Read lines and take the last N lines in the original order + let max = int this.DisplayCount + let lines = + File.ReadAllLines(this.FilePath) + |> Seq.rev + |> Seq.truncate max + |> Seq.rev + |> Seq.toList + + let historyData = + lines + |> Seq.map (fun line -> line.Split(separator)) + |> Seq.filter (fun parts -> parts.Length = 2) + |> Seq.map (fun parts -> DateTime.Parse(parts.[0]), parts.[1]) + |> Seq.groupBy fst + |> Seq.map (fun (dt, pairs) -> dt, pairs |> Seq.map snd |> Seq.toList) + + let table = Table() + table.Border <- TableBorder.None + table.AddColumns("Time", "URL") |> ignore + table.Columns.[0].PadRight <- 3 + + for (dateTime, urls) in historyData do + let formattedTime = sprintf "%s" (dateTime.ToString("yyyy-MM-dd HH:mm:ss")) + let joinedUrls = String.Join(Environment.NewLine, urls) + table.AddRow(formattedTime, joinedUrls) |> ignore + + Printer.PrintTable(table) + with ex -> + printer.Error (sprintf "Could not display recent history: %s" ex.Message) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs new file mode 100644 index 00000000..10be1275 --- /dev/null +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -0,0 +1,76 @@ +namespace CCVTAC.Console + +open System +open System.Linq +open System.Text.RegularExpressions +open System.Collections.Generic +open System.Collections.Immutable + +module InputHelper = + + let internal Prompt = + sprintf "Enter one or more YouTube media URLs or commands (or \"%s\"):\n▶︎" Commands.HelpCommand + + /// A regular expression that detects where commands and URLs begin in input strings. + let private userInputRegex = Regex(@"(?:https:|\\)", RegexOptions.Compiled) + + type private IndexPair = { Start: int; End: int } + + /// Takes a user input string and splits it into a collection of inputs + /// based upon substrings detected by the class's regular expression pattern. + let SplitInput (input: string) : ImmutableArray = + let matches = userInputRegex.Matches(input) |> Seq.cast |> Seq.toArray + + if matches.Length = 0 then + ImmutableArray.Empty + elif matches.Length = 1 then + ImmutableArray.Create(input) + else + let startIndices = matches |> Array.map (fun m -> m.Index) + + let indexPairs = + startIndices + |> Array.mapi (fun i startIndex -> + let endIndex = + if i = startIndices.Length - 1 then input.Length else startIndices.[i + 1] + { Start = startIndex; End = endIndex }) + + let splitInputs = + indexPairs + |> Array.map (fun p -> input.[p.Start..(p.End - 1)].Trim()) + |> Array.distinct + + ImmutableArray.CreateRange(splitInputs) + + type InputCategory = + | Url + | Command + + type CategorizedInput = { Text: string; Category: InputCategory } + + let CategorizeInputs (inputs: ICollection) : ImmutableArray = + inputs + |> Seq.cast + |> Seq.map (fun input -> + let category = if input.StartsWith(string Commands.Prefix) then InputCategory.Command else InputCategory.Url + { Text = input; Category = category }) + |> ImmutableArray.CreateRange + + type CategoryCounts(counts: Dictionary) = + member _.Item + with get (category: InputCategory) = + match counts.TryGetValue(category) with + | true, v -> v + | _ -> 0 + + let CountCategories (inputs: ICollection) : CategoryCounts = + let counts = + inputs + |> Seq.cast + |> Seq.groupBy (fun i -> i.Category) + |> Seq.map (fun (k, grp) -> k, grp |> Seq.length) + |> dict + :?> IDictionary + |> fun d -> Dictionary(d) + + CategoryCounts(counts) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs new file mode 100644 index 00000000..d0b5d0e0 --- /dev/null +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -0,0 +1,112 @@ +namespace CCVTAC.Console.IoUtilities + +open System +open System.IO +open System.Text +open CCVTAC.Console.PostProcessing + +module Directories = + [] + let private AllFilesSearchPattern = "*" + + let private enumerationOptions = EnumerationOptions() + + /// Counts the number of audio files in a directory + let internal audioFileCount (directory: string) = + Directory.GetFiles(directory) + |> Array.filter (fun f -> + PostProcessor.AudioExtensions + |> Array.exists (fun ext -> + Path.GetExtension(f).Equals(ext, StringComparison.OrdinalIgnoreCase) + ) + ) + |> Array.length + + /// Warns if any files are present in the directory + let internal warnIfAnyFiles (directory: string) (showMax: int) = + let fileNames = + Directory.GetFiles(directory, AllFilesSearchPattern, enumerationOptions) + |> Array.map Path.GetFileName + + if fileNames.Length = 0 then + Ok() + else + let fileLabel = if fileNames.Length = 1 then "file" else "files" + let report = StringBuilder() + + report.AppendLine( + $"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{directory}\":" + ) |> ignore + + fileNames + |> Array.truncate showMax + |> Array.iter (fun fileName -> + report.AppendLine($"• {fileName}") |> ignore + ) + + if fileNames.Length > showMax then + report.AppendLine($"... plus {fileNames.Length - showMax} more.") |> ignore + + report.AppendLine("This generally occurs due to the same video appearing twice in playlists.") |> ignore + + Error (report.ToString()) + + /// Deletes all files in the working directory + let internal deleteAllFiles (workingDirectory: string) (showMaxErrors: int) = + let fileNames = + Directory.GetFiles(workingDirectory, AllFilesSearchPattern, enumerationOptions) + + let mutable successCount = 0 + let errors = ResizeArray() + + fileNames |> Array.iter (fun fileName -> + try + File.Delete(fileName) + successCount <- successCount + 1 + with + | ex -> errors.Add(ex.Message) + ) + + if errors.Count = 0 then + Ok successCount + else + let output = StringBuilder( + $"While {successCount} files were deleted successfully, some files could not be deleted:" + ) + + errors + |> Seq.truncate showMaxErrors + |> Seq.iter (fun error -> + output.AppendLine($"• {error}") |> ignore + ) + + if errors.Count > showMaxErrors then + output.AppendLine($"... plus {errors.Count - showMaxErrors} more.") |> ignore + + Error (output.ToString()) + + /// Asks user if they want to delete all files + let internal askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = + let doDelete = printer.AskToBool("Delete all temporary files?", "Yes", "No") + + if doDelete then + deleteAllFiles workingDirectory 10 + else + Error "Will not delete the files." + + /// Returns the filenames in a given directory, optionally ignoring specific filenames + let private getDirectoryFileNames + (directoryName: string) + (customIgnoreFiles: string seq option) = + + let ignoreFiles = + customIgnoreFiles + |> Option.defaultValue Seq.empty + |> Seq.distinct + |> Seq.toArray + + Directory.GetFiles(directoryName, AllFilesSearchPattern, enumerationOptions) + |> Array.filter (fun filePath -> + not (ignoreFiles |> Array.exists (fun ignore -> filePath.EndsWith(ignore))) + ) + |> Array diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs new file mode 100644 index 00000000..7cbd0546 --- /dev/null +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -0,0 +1,322 @@ +namespace CCVTAC.Console + +open System +open CCVTAC.Console.Downloading +open CCVTAC.Console.IoUtilities +open CCVTAC.Console.PostProcessing +open CCVTAC.Console.Settings +open Spectre.Console +open CCVTAC.Console.InputHelper +open CCVTAC.FSharp.Settings + +type Orchestrator() = + + /// Ensures the download environment is ready, then initiates the UI input and download process. + static member Start (settings: UserSettings) (printer: Printer) : unit = + // The working directory should start empty. Give the user a chance to empty it. + match Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10) with + | Error firstErr -> + printer.FirstError(firstErr) + + match Directories.AskToDeleteAllFiles(settings.WorkingDirectory, printer) with + | Ok deletedCount -> + printer.Info (sprintf "%d file(s) deleted." deletedCount) + | Error err -> + printer.FirstError(err) + printer.Info "Aborting..." + // abort Start by returning + () + | Ok () -> + // proceed + + let results = ResultTracker(printer) + let history = History(settings.HistoryFile, settings.HistoryDisplayCount) + let mutable nextAction = NextAction.Continue + let mutable settingsRef = settings + + while nextAction = NextAction.Continue do + let input = printer.GetInput InputHelper.Prompt + let splitInputs = InputHelper.SplitInput input + + if splitInputs.IsEmpty then + printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands.Prefix) + else + let categorizedInputs = InputHelper.CategorizeInputs(splitInputs) + let categoryCounts = InputHelper.CountCategories(categorizedInputs) + + SummarizeInput(categorizedInputs, categoryCounts, printer) + + // ProcessBatch may modify settings; reflect that by using a mutable reference + nextAction <- ProcessBatch(categorizedInputs, categoryCounts, &settingsRef, results, history, printer) + + results.PrintSessionSummary() + +/// TODO: Redo? + + /// Processes a single user request, from input to downloading and file post-processing. + /// Returns the next action the application should take (e.g., continue or quit). + let private ProcessBatch + (categorizedInputs: ImmutableArray) + (categoryCounts: CategoryCounts) + (settings: byref) + (resultTracker: ResultTracker) + (history: History) + (printer: Printer) + : NextAction = + let inputTime = DateTime.Now + let mutable nextAction = NextAction.Continue + let watch = Watch() + let batchResults = ResultTracker(printer) + let mutable inputIndex = 0 + + for input in categorizedInputs do + // increment input index before passing to ProcessUrl to mirror ++inputIndex + inputIndex <- inputIndex + 1 + + let result = + match input.Category with + | InputCategory.Command -> + ProcessCommand(input.Text, &settings, history, printer) + | InputCategory.Url -> + ProcessUrl( + input.Text, + settings, + resultTracker, + history, + inputTime, + categoryCounts.[InputCategory.Url], + inputIndex, + printer + ) + + batchResults.RegisterResult(input.Text, result) + + if result.IsFailed then + printer.Error(result.Errors.First().Message) + else + nextAction <- result.Value + if nextAction <> NextAction.Continue then + // break out early + break + + if categoryCounts.[InputCategory.Url] > 1 then + printer.Info(sprintf "%sFinished with batch of %d URLs in %s." + Environment.NewLine + categoryCounts.[InputCategory.Url] + watch.ElapsedFriendly) + batchResults.PrintBatchFailures() + + nextAction + +open System + + let private ProcessUrl + (url: string) + (settings: UserSettings) + (resultTracker: ResultTracker) + (history: History) + (urlInputTime: DateTime) + (batchSize: int) + (urlIndex: int) + (printer: Printer) + : Result = + + match Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10) with + | Error firstErr -> + printer.FirstError(firstErr) + Ok NextAction.QuitDueToErrors + | Ok () -> + // Don't sleep for the very first URL. + if urlIndex > 1 then + Threading.Thread.Sleep(settings.SleepSecondsBetweenURLs * 1000) + printer.Info(sprintf "Slept for %d second(s)." settings.SleepSecondsBetweenURLs, appendLines = 1) + + if batchSize > 1 then + printer.Info(sprintf "Processing group %d of %d..." urlIndex batchSize) + + let jobWatch = Watch() + + match Downloader.WrapUrlInMediaType(url) with + | Error e -> + let errorMsg = sprintf "URL parse error: %s" (e |> Seq.map (fun er -> er.Message) |> Seq.head) + printer.Error(errorMsg) + Error errorMsg + | Ok mediaType -> + printer.Info(sprintf "%s URL '%s' detected." (mediaType.GetType().Name) url) + history.Append(url, urlInputTime, printer) + + let downloadResult = Downloader.Run(mediaType, settings, printer) + resultTracker.RegisterResult(url, downloadResult) + + if downloadResult.IsFailed then + let errorMsg = sprintf "Download error: %s" (downloadResult.Errors |> Seq.map (fun er -> er.Message) |> Seq.head) + printer.Error(errorMsg) + Error errorMsg + else + printer.Debug(sprintf "Successfully downloaded \"%s\" format." downloadResult.Value) + PostProcessor.Run(settings, mediaType, printer) + + let groupClause = if batchSize > 1 then sprintf " (group %d of %d)" urlIndex batchSize else String.Empty + printer.Info(sprintf "Processed '%s'%s in %s." url groupClause jobWatch.ElapsedFriendly) + Ok NextAction.Continue + + let private ProcessCommand + + let private equalsIgnoreCase (a: string) (b: string) = + String.Equals(a, b, StringComparison.InvariantCultureIgnoreCase) + + let private seqContainsIgnoreCase (seq: seq) (value: string) = + seq |> Seq.exists (fun s -> equalsIgnoreCase s value) + + let private startsWithIgnoreCase (text: string) (prefix: string) = + text.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) + + let private summarizeToggle settingName setting = + sprintf "%s was toggled to %s for this session." settingName (if setting then "ON" else "OFF") + + let private summarizeUpdate settingName setting = + sprintf "%s was updated to \"%s\" for this session." settingName setting + + let private ProcessCommand + (command: string) + (settings: byref) + (history: History) + (printer: Printer) + : Result = + + // Help + if equalsIgnoreCase Commands.HelpCommand command then + for kvp in Commands.Summary do + printer.Info(kvp.Key) + printer.Info(sprintf " %s" kvp.Value) + Ok NextAction.Continue + + // Quit + elif seqContainsIgnoreCase Commands.QuitCommands command then + Ok NextAction.QuitAtUserRequest + + // History + elif seqContainsIgnoreCase Commands.History command then + history.ShowRecent(printer) + Ok NextAction.Continue + + // Update downloader + elif seqContainsIgnoreCase Commands.UpdateDownloader command then + Updater.Run(settings, printer) |> ignore + Ok NextAction.Continue + + // Settings summary + elif seqContainsIgnoreCase Commands.SettingsSummary command then + SettingsAdapter.PrintSummary(settings, printer) + Ok NextAction.Continue + + // Toggle split chapters + elif seqContainsIgnoreCase Commands.SplitChapterToggles command then + settings <- SettingsAdapter.ToggleSplitChapters(settings) + printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) + Ok NextAction.Continue + + // Toggle embed images + elif seqContainsIgnoreCase Commands.EmbedImagesToggles command then + settings <- SettingsAdapter.ToggleEmbedImages(settings) + printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) + Ok NextAction.Continue + + // Toggle quiet mode + elif seqContainsIgnoreCase Commands.QuietModeToggles command then + settings <- SettingsAdapter.ToggleQuietMode(settings) + printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) + printer.ShowDebug(not settings.QuietMode) + Ok NextAction.Continue + + // Update audio formats prefix + elif startsWithIgnoreCase command Commands.UpdateAudioFormatPrefix then + let format = command.Replace(Commands.UpdateAudioFormatPrefix, "").ToLowerInvariant() + if String.IsNullOrEmpty format then + Error "You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." + else + let updateResult = SettingsAdapter.UpdateAudioFormat(settings, format) + if updateResult.IsError then Error updateResult.ErrorValue + else + settings <- updateResult.ResultValue + printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) + Ok NextAction.Continue + + // Update audio quality prefix + elif startsWithIgnoreCase command Commands.UpdateAudioQualityPrefix then + let inputQuality = command.Replace(Commands.UpdateAudioQualityPrefix, "") + if String.IsNullOrEmpty inputQuality then + Error "You must enter a number representing an audio quality." + else + match Byte.TryParse(inputQuality) with + | (true, quality) -> + let updateResult = SettingsAdapter.UpdateAudioQuality(settings, quality) + if updateResult.IsError then Error updateResult.ErrorValue + else + settings <- updateResult.ResultValue + printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) + Ok NextAction.Continue + | _ -> + Error (sprintf "\"%s\" is an invalid quality value." inputQuality) + + // Unknown command + else + Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." command (string Commands.Prefix)) + + + + let summarizeInput + + type NextAction = + | Continue = 0uy + | QuitAtUserRequest = 1uy + | QuitDueToErrors = 2uy + + let private SummarizeInput + (categorizedInputs: ImmutableArray) + (counts: CategoryCounts) + (printer: Printer) + : unit = + if categorizedInputs.Length > 1 then + let urlCount = counts.[InputCategory.Url] + let cmdCount = counts.[InputCategory.Command] + + let urlSummary = + match urlCount with + | 1 -> "1 URL" + | n when n > 1 -> sprintf "%d URLs" n + | _ -> String.Empty + + let commandSummary = + match cmdCount with + | 1 -> "1 command" + | n when n > 1 -> sprintf "%d commands" n + | _ -> String.Empty + + let connector = + if urlSummary.HasText() && commandSummary.HasText() then " and " else String.Empty + + printer.Info(sprintf "Batch of %s%s%s entered." urlSummary connector commandSummary) + + for input in categorizedInputs do + printer.Info(sprintf " • %s" input.Text) + + Printer.EmptyLines(1) + + let private Sleep (sleepSeconds: uint16) : unit = + // Use a mutable remainingSeconds to mirror the C# behavior + let mutable remainingSeconds = sleepSeconds + + AnsiConsole + .Status() + .Start(sprintf "Sleeping for %d seconds..." sleepSeconds, + fun ctx -> + ctx.Spinner(Spinner.Known.Star) + ctx.SpinnerStyle(Style.Parse("blue")) + + while remainingSeconds > 0us do + ctx.Status(sprintf "Sleeping for %d seconds..." remainingSeconds) + remainingSeconds <- remainingSeconds - 1us + Thread.Sleep(1000) + ) + |> ignore diff --git a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs new file mode 100644 index 00000000..15de16e8 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs @@ -0,0 +1,27 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.Collections.Generic +open System.Text.Json.Serialization + +[] +type CollectionMetadata = + { [] Id: string + [] Title: string + [] Availability: string + [] Description: string + [] Tags: IReadOnlyList + [] ModifiedDate: string + [] ViewCount: Nullable + [] PlaylistCount: Nullable + [] Channel: string + [] ChannelId: string + [] UploaderId: string + [] Uploader: string + [] ChannelUrl: string + [] UploaderUrl: string + [] Type: string + [] WebpageUrl: string + [] WebpageUrlBasename: string + [] WebpageUrlDomain: string + [] Epoch: Nullable } diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs new file mode 100644 index 00000000..256d6a55 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -0,0 +1,68 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.IO + +module Deleter = + /// Runs the deletion process for temporary files + let internal run + (taggingSetFileNames: string seq) + (collectionMetadata: CollectionMetadata option) + (workingDirectory: string) + (printer: Printer) + : unit = + + // Get collection files + let collectionFileNames = + match getCollectionFiles collectionMetadata workingDirectory with + | Ok files -> + printer.Debug($"Found {files.Length} collection files.") + files + | Error err -> + printer.Warning(err) + [||] + + // Combine all file names + let allFileNames = + Seq.concat [taggingSetFileNames; collectionFileNames] + |> Seq.toArray + + // Check if any files to delete + if allFileNames.Length = 0 then + printer.Warning("No files to delete were found.") + else + printer.Debug($"Deleting {allFileNames.Length} temporary files...") + deleteAll allFileNames printer + printer.Info("Deleted temporary files.") + + /// Retrieves collection files based on collection metadata + and private getCollectionFiles + (collectionMetadata: CollectionMetadata option) + (workingDirectory: string) + : Result = + + match collectionMetadata with + | None -> Ok [||] + | Some metadata -> + try + let files = + Directory.GetFiles(workingDirectory, $"*{metadata.Id}*") + + Ok files + with + | ex -> Error $"Error collecting filenames: {ex.Message}" + + /// Deletes all specified files + and private deleteAll + (fileNames: string[]) + (printer: Printer) + : unit = + + fileNames + |> Array.iter (fun fileName -> + try + File.Delete(fileName) + printer.Debug($"• Deleted \"{fileName}\"") + with + | ex -> printer.Error($"• Deletion error: {ex.Message}") + ) diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs new file mode 100644 index 00000000..9cd09ed8 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -0,0 +1,11 @@ +namespace CCVTAC.Console.PostProcessing + +open CCVTAC.Console.ExternalTools + +module ImageProcessor = + + let internal ProgramName = "mogrify" + + let internal Run (workingDirectory: string) (printer: Printer) : unit = + let imageEditToolSettings = ToolSettings($"{ProgramName} -trim -fuzz 10% *.jpg", workingDirectory) + Runner.Run(imageEditToolSettings, [||], printer) |> ignore diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs new file mode 100644 index 00000000..3b786433 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -0,0 +1,156 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.IO +open System.Linq +open System.Text.Json +open System.Text.RegularExpressions +open System.Collections.Generic +open System.Collections.Immutable +open CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.FSharp.Settings + +module Mover = + + let private PlaylistImageRegex = Regex(@" + +\[[OP]L[\w\d_-]{12,}\] + +", RegexOptions.Compiled) + let private ImageFileWildcard = "*.jp*" + + let Run + (taggingSets: seq) + (maybeCollectionData: CollectionMetadata option) + (settings: UserSettings) + (overwrite: bool) + (printer: Printer) + : unit = + printer.Debug "Starting move..." + let watch = Watch() // assumes Watch type with ElapsedFriendly exists + + let workingDirInfo = DirectoryInfo(settings.WorkingDirectory) + + let firstTaggingSet = + taggingSets + |> Seq.tryHead + |> Option.defaultWith (fun () -> failwith "No tagging sets provided") + + let subFolderName = GetSafeSubDirectoryName(maybeCollectionData, firstTaggingSet) + let collectionName = maybeCollectionData |> Option.map (fun c -> c.Title) |> Option.defaultValue String.Empty + let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) + + match EnsureDirectoryExists(fullMoveToDir, printer) with + | Error _ -> () // error already printed + | Ok () -> + let audioFileNames = + workingDirInfo.EnumerateFiles() + |> Seq.filter (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)) + |> Seq.toImmutableList + + if audioFileNames.IsEmpty then + printer.Error "No audio filenames to move found." + else + printer.Debug (sprintf "Moving %d audio file(s) to \"%s\"..." audioFileNames.Count fullMoveToDir) + + let (successCount, failureCount) = + MoveAudioFiles(audioFileNames, fullMoveToDir, overwrite, printer) + + MoveImageFile(collectionName, subFolderName, workingDirInfo, fullMoveToDir, audioFileNames.Count, overwrite, printer) + + let fileLabel = if successCount = 1u then "file" else "files" + printer.Info (sprintf "Moved %d audio %s in %s." successCount fileLabel watch.ElapsedFriendly) + + if failureCount > 0u then + let fileLabel' = if failureCount = 1u then "file" else "files" + printer.Warning (sprintf "However, %d audio %s could not be moved." failureCount fileLabel') + + let private IsPlaylistImage (fileName: string) = + PlaylistImageRegex.IsMatch(fileName) + + let private GetCoverImage (workingDirInfo: DirectoryInfo) (audioFileCount: int) : FileInfo option = + let images = workingDirInfo.EnumerateFiles(ImageFileWildcard) |> Seq.toArray + if images.Length = 0 then None + else + let playlistImages = images |> Seq.filter (fun i -> IsPlaylistImage(i.FullName)) |> Seq.toList + if playlistImages.Any() then Some (playlistImages.First()) + else if audioFileCount > 1 && images.Length = 1 then Some images.[0] + else None + + let private EnsureDirectoryExists (moveToDir: string) (printer: Printer) : Result = + try + if Directory.Exists(moveToDir) then + printer.Debug (sprintf "Found move-to directory \"%s\"." moveToDir) + Ok () + else + printer.Debug (sprintf "Creating move-to directory \"%s\" (based on playlist metadata)... " moveToDir, appendLineBreak = false) + Directory.CreateDirectory(moveToDir) |> ignore + printer.Debug "OK." + Ok () + with ex -> + printer.Error (sprintf "Error creating move-to directory \"%s\": %s" moveToDir ex.Message) + Error String.Empty + + let private MoveAudioFiles (audioFiles: ImmutableList) (moveToDir: string) (overwrite: bool) (printer: Printer) : uint32 * uint32 = + let mutable successCount = 0u + let mutable failureCount = 0u + for file in audioFiles do + try + File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) + successCount <- successCount + 1u + printer.Debug (sprintf "• Moved \"%s\"" file.Name) + with ex -> + failureCount <- failureCount + 1u + printer.Error (sprintf "• Error moving file \"%s\": %s" file.Name ex.Message) + (successCount, failureCount) + + let private MoveImageFile + (maybeCollectionName: string) + (subFolderName: string) + (workingDirInfo: DirectoryInfo) + (moveToDir: string) + (audioFileCount: int) + (overwrite: bool) + (printer: Printer) + : unit = + try + let baseFileName = + if String.IsNullOrWhiteSpace maybeCollectionName then + subFolderName + else + sprintf "%s - %s" subFolderName (maybeCollectionName.ReplaceInvalidPathChars()) + + match GetCoverImage workingDirInfo audioFileCount with + | None -> () + | Some image -> + let dest = Path.Combine(moveToDir, sprintf "%s.jpg" (baseFileName.Trim())) + image.MoveTo(dest, overwrite = overwrite) + printer.Info "Moved image file." + with ex -> + printer.Warning (sprintf "Error copying the image file: %s" ex.Message) + + let private GetSafeSubDirectoryName (collectionData: CollectionMetadata option) (taggingSet: TaggingSet) : string = + let workingName = + match collectionData with + | Some metadata when metadata.Uploader.HasText() && metadata.Title.HasText() -> metadata.Uploader + | _ -> + match GetParsedVideoJson taggingSet with + | Ok v -> v.Uploader + | Error _ -> String.Empty + + let safeName = workingName.ReplaceInvalidPathChars().Trim() + let topicSuffix = " - Topic" + if safeName.EndsWith(topicSuffix) then safeName.Replace(topicSuffix, String.Empty) + else safeName + + let private GetParsedVideoJson (taggingSet: TaggingSet) : Result = + try + let json = File.ReadAllText(taggingSet.JsonFilePath) + try + let videoData = JsonSerializer.Deserialize(json) + if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) + else Ok videoData + with :? JsonException as ex -> + Error (sprintf "Error deserializing JSON from file \"%s\": %s" taggingSet.JsonFilePath ex.Message) + with ex -> + Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs new file mode 100644 index 00000000..9f6cc0c6 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -0,0 +1,109 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.IO +open System.Linq +open System.Text.Json +open System.Text.RegularExpressions +open System.Collections.Immutable +open CCVTAC.Console.IoUtilities +open CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.FSharp.Settings + +module PostProcessor = + + let internal AudioExtensions = + [| ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" |] + + let private collectionMetadataRegex = + Regex(@"(?<= + +\[)[\w\-]{17,}(?=\] + +\.info.json)", RegexOptions.Compiled) + + let private getCollectionMetadataMatches (path: string) = + collectionMetadataRegex.IsMatch(path) + + let Run (settings: UserSettings) (mediaType: MediaType) (printer: Printer) : unit = + let watch = Watch() + let workingDirectory = settings.WorkingDirectory + + printer.Info "Starting post-processing..." + + match GenerateTaggingSets workingDirectory with + | Error err -> + printer.Error "No tagging sets were generated, so tagging cannot be done." + | Ok taggingSets -> + let collectionJsonResult = GetCollectionJson workingDirectory + + let collectionJsonOpt = + match collectionJsonResult with + | Error e -> + printer.Debug (sprintf "No playlist or channel metadata found: %s" e) + None + | Ok cm -> + printer.Debug "Found playlist/channel metadata." + Some cm + + if settings.EmbedImages then + ImageProcessor.Run(workingDirectory, printer) + + match Tagger.Run(settings, taggingSets, collectionJsonOpt, mediaType, printer) with + | Ok msg -> + printer.Info msg + Renamer.Run(settings, workingDirectory, printer) + Mover.Run(taggingSets, collectionJsonOpt, settings, true, printer) + + let taggingSetFileNames = + taggingSets + |> Seq.collect (fun s -> s.AllFiles :?> seq) + |> Seq.toList + + Deleter.Run(taggingSetFileNames, collectionJsonOpt, workingDirectory, printer) + + match Directories.WarnIfAnyFiles(workingDirectory, 20) with + | Error firstErr -> + printer.FirstError(firstErr) + printer.Info "Will delete the remaining files..." + match Directories.DeleteAllFiles(workingDirectory, 20) with + | Ok deletedCount -> printer.Info (sprintf "%d file(s) deleted." deletedCount) + | Error e -> printer.FirstError(e) + | Ok _ -> () + | Error errs -> + printer.Errors("Tagging error(s) preventing further post-processing: ", Error errs) + + printer.Info (sprintf "Post-processing done in %s." watch.ElapsedFriendly) + + let private GetCollectionJson (workingDirectory: string) : Result = + try + let fileNames = + Directory.GetFiles(workingDirectory) + |> Seq.filter getCollectionMetadataMatches + |> Seq.toImmutableHashSet + + if fileNames.Count = 0 then + Error "No relevant files found." + elif fileNames.Count > 1 then + Error "Unexpectedly found more than one relevant file, so none will be processed." + else + let fileName = fileNames.Single() + let json = File.ReadAllText(fileName) + let collectionData = JsonSerializer.Deserialize(json) + if isNull (box collectionData) then + Error "Deserialized collection metadata was null." + else + Ok collectionData + with ex -> + Error ex.Message + + let private GenerateTaggingSets (directory: string) : Result, string> = + try + let files = Directory.GetFiles(directory) + let taggingSets = TaggingSet.CreateSets(files) + if taggingSets.Any() then Ok taggingSets + else Error (sprintf "No tagging sets were created using working directory \"%s\"." directory) + with :? DirectoryNotFoundException -> + Error (sprintf "Directory \"%s\" does not exist." directory) + with ex -> + Error (sprintf "Error reading working directory files: %s" ex.Message) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs new file mode 100644 index 00000000..7cd3a9f8 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -0,0 +1,96 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.IO +open System.Text +open System.Text.RegularExpressions +open System.Linq +open System.Collections.Immutable +open CCVTAC.FSharp.Settings + +module Renamer = + + let private getNormalizationForm (form: string) = + match form.Trim().ToUpperInvariant() with + | "D" -> NormalizationForm.FormD + | "KD" -> NormalizationForm.FormKD + | "KC" -> NormalizationForm.FormKC + | _ -> NormalizationForm.FormC + + let Run (settings: UserSettings) (workingDirectory: string) (printer: Printer) : unit = + let watch = Watch() + + let workingDirInfo = DirectoryInfo(workingDirectory) + + let audioFiles = + workingDirInfo.EnumerateFiles() + |> Seq.filter (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)) + |> Seq.toImmutableList + + if audioFiles.None() then + printer.Warning "No audio files to rename were found." + else + printer.Debug (sprintf "Renaming %d audio file(s)..." audioFiles.Count) + + for file in audioFiles do + let newFileName = + // Fold over rename patterns, starting with StringBuilder(file.Name) + settings.RenamePatterns + |> Seq.fold + (fun (sb: StringBuilder) (renamePattern) -> + let regex = Regex(renamePattern.RegexPattern) + let matches = + regex.Matches(sb.ToString()) + |> Seq.cast + |> Seq.filter (fun m -> m.Success) + |> Seq.rev + |> Seq.toList + + if matches.Count = 0 then sb + else + if not settings.QuietMode then + let matchedPatternSummary = + if isNull renamePattern.Summary then + sprintf "`%s` (no description)" renamePattern.RegexPattern + else + sprintf "\"%s\"" renamePattern.Summary + + printer.Debug (sprintf "Rename pattern %s matched × %d." matchedPatternSummary matches.Count) + + for m in matches do + // remove matched substring + sb.Remove(m.Index, m.Length) |> ignore + + // build replacement text by replacing %s placeholders with group captures + let replacementText = + m.Groups + |> Seq.cast + |> Seq.mapi (fun i g -> + let searchFor = sprintf "%%<%d>s" (i + 1) + let replaceWith = + // group 0 is the whole match; we want groups starting at 1 + if i + 1 < m.Groups.Count then m.Groups.[i + 1].Value.Trim() else String.Empty + (searchFor, replaceWith)) + |> Seq.fold (fun (sbRep: StringBuilder) (searchFor, replaceWith) -> + sbRep.Replace(searchFor, replaceWith)) + (StringBuilder(renamePattern.ReplaceWithPattern)) + |> _.ToString() + + sb.Insert(m.Index, replacementText) |> ignore + + sb) + (StringBuilder(file.Name)) + |> _.ToString() + + try + let dest = + Path.Combine(workingDirectory, newFileName) + |> fun p -> p.Normalize(getNormalizationForm settings.NormalizationForm) + + File.Move(file.FullName, dest) + printer.Debug (sprintf "• From: \"%s\"" file.Name) + printer.Debug (sprintf " To: \"%s\"" newFileName) + with ex -> + printer.Error (sprintf "• Error renaming \"%s\": %s" file.Name ex.Message) + + printer.Info (sprintf "Renaming done in %s." watch.ElapsedFriendly) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs new file mode 100644 index 00000000..f11fc769 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -0,0 +1,86 @@ +namespace CCVTAC.Console.PostProcessing.Tagging + +open System +open System.Text.RegularExpressions +open CCVTAC.FSharp.Settings + +module Detectors = + /// Finds and returns the first instance of text matching a given detection scheme pattern, + /// parsing into T if necessary. + /// A match of type T if there was a match; otherwise, the default value provided. + let internal detectSingle<'T> + (videoMetadata: VideoMetadata) + (patterns: TagDetectionPattern seq) + (defaultValue: 'T option) = + + patterns + |> Seq.tryPick (fun pattern -> + let fieldText = extractMetadataText videoMetadata pattern.SearchField + + let match' = Regex(pattern.RegexPattern).Match(fieldText) + + if not match'.Success then + None + else + let matchedText = + match'.Groups.[pattern.MatchGroup].Value.Trim() + + cast<'T> matchedText defaultValue + ) + |> Option.defaultValue defaultValue + + /// Finds and returns all instances of text matching a given detection scheme pattern, + /// concatenating them into a single string (using a custom separator), then casting + /// to type T if necessary. + /// A match of type T if there were any matches; otherwise, the default value provided. + let internal detectMultiple<'T> + (data: VideoMetadata) + (patterns: TagDetectionPattern seq) + (defaultValue: 'T option) + (separator: string) = + + let matchedValues = + patterns + |> Seq.collect (fun pattern -> + let fieldText = extractMetadataText data pattern.SearchField + + Regex(pattern.RegexPattern).Matches(fieldText) + |> Seq.filter (fun m -> m.Success) + |> Seq.map (fun m -> m.Groups.[pattern.MatchGroup].Value.Trim()) + ) + |> Seq.distinct + |> Seq.toArray + + if matchedValues.Length = 0 then + defaultValue + else + let joinedMatchedText = String.Join(separator, matchedValues) + cast<'T> joinedMatchedText defaultValue + + /// Attempts casting the input text to type T and returning it. + /// If casting fails, the default value is returned instead. + let private cast<'T> (text: string option) (defaultValue: 'T option) = + match text with + | None -> defaultValue + | Some textValue -> + try + // If T is string, return the text directly + if typeof<'T> = typeof then + Some(box textValue :?> 'T) + else + // Try to convert to the target type + Some(Convert.ChangeType(textValue, typeof<'T>) :?> 'T) + with + | _ -> defaultValue + + /// Extracts the value of the specified tag field from the given data. + /// Video metadata + /// The name of the field within the video metadata to read + /// The text content of the requested field of the video metadata + let private extractMetadataText (metadata: VideoMetadata) (fieldName: string) = + match fieldName with + | "title" -> metadata.Title + | "description" -> metadata.Description + | _ -> + // TODO: It would be best to check for invalid entries upon settings deserialization. + raise (ArgumentException($"\"{fieldName}\" is an invalid video metadata field name.")) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs new file mode 100644 index 00000000..ba442c92 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -0,0 +1,52 @@ +namespace CCVTAC.Console.PostProcessing.Tagging + +open CCVTAC.FSharp.Settings + +/// Provides methods to search for specific tag field data (artist, album, etc.) within video metadata. +type TagDetector(tagDetectionPatterns: TagDetectionPatterns) = + /// Detection patterns for various metadata fields + member private _.Patterns = tagDetectionPatterns + + /// Detects the title from video metadata + member this.DetectTitle(videoData: VideoMetadata, ?defaultTitle: string) : string option = + let detectedTitle = + Detectors.detectSingle videoData this.Patterns.Title None + + match detectedTitle, defaultTitle with + | Some title, _ -> Some title + | None, Some defaultVal -> Some defaultVal + | None, None -> None + + /// Detects the artist from video metadata + member this.DetectArtist(videoData: VideoMetadata, ?defaultArtist: string) : string option = + let detectedArtist = + Detectors.detectSingle videoData this.Patterns.Artist None + + match detectedArtist, defaultArtist with + | Some artist, _ -> Some artist + | None, Some defaultVal -> Some defaultVal + | None, None -> None + + /// Detects the album from video metadata + member this.DetectAlbum(videoData: VideoMetadata, ?defaultAlbum: string) : string option = + let detectedAlbum = + Detectors.detectSingle videoData this.Patterns.Album None + + match detectedAlbum, defaultAlbum with + | Some album, _ -> Some album + | None, Some defaultVal -> Some defaultVal + | None, None -> None + + /// Detects composers from video metadata + member this.DetectComposers(videoData: VideoMetadata) : string option = + Detectors.detectMultiple videoData this.Patterns.Composer None "; " + + /// Detects the release year from video metadata + member this.DetectReleaseYear(videoData: VideoMetadata, ?defaultYear: uint16) : uint16 option = + let detectedYear = + Detectors.detectSingle videoData this.Patterns.Year None + + match detectedYear, defaultYear with + | Some year, _ -> Some year + | None, Some defaultVal -> Some defaultVal + | None, None -> None diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs new file mode 100644 index 00000000..7bba2b9e --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -0,0 +1,209 @@ +namespace CCVTAC.Console.PostProcessing.Tagging + +open System +open System.IO +open System.Text.Json +open System.Linq +open CCVTAC.FSharp.Downloading +open CCVTAC.FSharp.Settings +open CCVTAC.Console.ExternalTools +open TagLib + +type TaggedFile = TagLib.File + +module Tagger = + + let private watchFriendly (watch: System.Diagnostics.Stopwatch) = + if watch.IsRunning then watch.Stop() + let ts = watch.Elapsed + if ts.TotalHours >= 1.0 then sprintf "%d:%02d:%02d" (int ts.TotalHours) ts.Minutes ts.Seconds + elif ts.TotalMinutes >= 1.0 then sprintf "%d:%02d" ts.Minutes ts.Seconds + else sprintf "%d ms" ts.TotalMilliseconds + + let Run + (settings: UserSettings) + (taggingSets: seq) + (collectionJson: CollectionMetadata option) + (mediaType: MediaType) + (printer: Printer) + : Result = + printer.Debug "Adding file tags..." + let watch = System.Diagnostics.Stopwatch.StartNew() + let embedImages = settings.EmbedImages && (mediaType.IsVideo || mediaType.IsPlaylistVideo) + + for taggingSet in taggingSets do + ProcessSingleTaggingSet settings taggingSet collectionJson embedImages printer + + Ok (sprintf "Tagging done in %s." (watchFriendly watch)) + + and private ProcessSingleTaggingSet + (settings: UserSettings) + (taggingSet: TaggingSet) + (collectionJson: CollectionMetadata option) + (embedImages: bool) + (printer: Printer) + = + printer.Debug (sprintf "%d audio file(s) with resource ID \"%s\"" taggingSet.AudioFilePaths.Count taggingSet.ResourceId) + + match ParseVideoJson taggingSet with + | Error err -> + printer.Errors (sprintf "Error deserializing video metadata from \"%s\":" taggingSet.JsonFilePath) (Error err) + () + | Ok videoData -> + let finalTaggingSet = DeleteSourceFile taggingSet printer + + let maybeImagePath = + if embedImages && finalTaggingSet.AudioFilePaths.Count = 1 then + finalTaggingSet.ImageFilePath + else + null + + for audioPath in finalTaggingSet.AudioFilePaths do + try + TagSingleFile(settings, videoData, audioPath, maybeImagePath, collectionJson, printer) + with ex -> + printer.Error (sprintf "Error tagging file: %s" ex.Message) + + and private TagSingleFile + (settings: UserSettings) + (videoData: VideoMetadata) + (audioFilePath: string) + (imageFilePath: string) + (collectionData: CollectionMetadata option) + (printer: Printer) + = + let audioFileName = Path.GetFileName audioFilePath + printer.Debug (sprintf "Current audio file: \"%s\"" audioFileName) + + use taggedFile = TaggedFile.Create(audioFilePath) + let tagDetector = TagDetector(settings.TagDetectionPatterns) + + // Title + match videoData.Track with + | null -> + let title = tagDetector.DetectTitle(videoData, videoData.Title) + printer.Debug (sprintf "• Found title \"%s\"" title) + taggedFile.Tag.Title <- title + | metadataTitle -> + printer.Debug (sprintf "• Using metadata title \"%s\"" metadataTitle) + taggedFile.Tag.Title <- metadataTitle + + // Artist / Performers + if not (String.IsNullOrWhiteSpace(videoData.Artist)) then + let metadataArtists = videoData.Artist + let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None).[0] + let diffSummary = if firstArtist = metadataArtists then "" else sprintf " (extracted from \"%s\")" metadataArtists + taggedFile.Tag.Performers <- [| firstArtist |] + printer.Debug (sprintf "• Using metadata artist \"%s\"%s" firstArtist diffSummary) + else + match tagDetector.DetectArtist(videoData) with + | null -> () + | artist -> + printer.Debug (sprintf "• Found artist \"%s\"" artist) + taggedFile.Tag.Performers <- [| artist |] + + // Album + if not (String.IsNullOrWhiteSpace(videoData.Album)) then + printer.Debug (sprintf "• Using metadata album \"%s\"" videoData.Album) + taggedFile.Tag.Album <- videoData.Album + else + match tagDetector.DetectAlbum(videoData, collectionData |> Option.map (fun c -> c.Title) |> Option.toObj) with + | null -> () + | album -> + printer.Debug (sprintf "• Found album \"%s\"" album) + taggedFile.Tag.Album <- album + + // Composers + match tagDetector.DetectComposers(videoData) with + | null -> () + | composers -> + printer.Debug (sprintf "• Found composer(s) \"%s\"" composers) + taggedFile.Tag.Composers <- [| composers |] + + // Track number + match videoData.PlaylistIndex with + | null -> () + | trackNo -> + printer.Debug (sprintf "• Using playlist index of %d for track number" trackNo) + taggedFile.Tag.Track <- uint32 trackNo + + // Year + if videoData.ReleaseYear <> null then + printer.Debug (sprintf "• Using metadata release year \"%d\"" videoData.ReleaseYear) + taggedFile.Tag.Year <- videoData.ReleaseYear + else + let maybeDefaultYear = + let rec GetAppropriateReleaseDateIfAny (settings: UserSettings) (videoData: VideoMetadata) = + if settings.IgnoreUploadYearUploaders |> Option.isSome && + settings.IgnoreUploadYearUploaders.Value.Contains(videoData.Uploader, StringComparer.OrdinalIgnoreCase) then + None + else + if String.IsNullOrEmpty videoData.UploadDate then None + else + let prefix = if videoData.UploadDate.Length >= 4 then videoData.UploadDate.Substring(0,4) else "" + match UInt16.TryParse prefix with + | true, parsed -> Some parsed + | _ -> None + GetAppropriateReleaseDateIfAny settings videoData + + match tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) with + | null -> () + | year -> + printer.Debug (sprintf "• Found year \"%d\"" year) + taggedFile.Tag.Year <- year + + // Comment + taggedFile.Tag.Comment <- videoData.GenerateComment(collectionData) + + // Artwork embedding + if settings.EmbedImages && + (not (settings.DoNotEmbedImageUploaders.Contains(videoData.Uploader))) && + (not (String.IsNullOrWhiteSpace imageFilePath)) then + printer.Info "Embedding artwork." + WriteImage(taggedFile, imageFilePath, printer) + else + printer.Debug "Skipping artwork embedding." + + taggedFile.Save() + printer.Debug (sprintf "Wrote tags to \"%s\"." audioFileName) + + and private ParseVideoJson (taggingSet: TaggingSet) : Result = + try + let json = File.ReadAllText taggingSet.JsonFilePath + try + let videoData = JsonSerializer.Deserialize(json) + if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) + else Ok videoData + with + | :? JsonException as ex -> Error (sprintf "%s%s%s" ex.Message Environment.NewLine ex.StackTrace) + with ex -> + Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) + + and private DeleteSourceFile (taggingSet: TaggingSet) (printer: Printer) : TaggingSet = + if taggingSet.AudioFilePaths.Count <= 1 then taggingSet + else + let largestFileInfo = + taggingSet.AudioFilePaths + |> Seq.map (fun fn -> FileInfo(fn)) + |> Seq.sortByDescending (fun fi -> fi.Length) + |> Seq.head + + try + File.Delete largestFileInfo.FullName + printer.Debug (sprintf "Deleted pre-split source file \"%s\"" largestFileInfo.Name) + { taggingSet with AudioFilePaths = taggingSet.AudioFilePaths.Remove(largestFileInfo.FullName) } + with ex -> + printer.Error (sprintf "Error deleting pre-split source file \"%s\": %s" largestFileInfo.Name ex.Message) + taggingSet + + and private WriteImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = + if String.IsNullOrWhiteSpace imageFilePath then + printer.Error "No image file path was provided, so cannot add an image to the file." + else + try + let pics = Array.zeroCreate 1 + pics.[0] <- TagLib.Picture(imageFilePath) + taggedFile.Tag.Pictures <- pics + printer.Debug "Image written to file tags OK." + with ex -> + printer.Error (sprintf "Error writing image to the audio file: %s" ex.Message) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs new file mode 100644 index 00000000..54425503 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -0,0 +1,81 @@ +namespace CCVTAC.Console.PostProcessing.Tagging + +open System +open System.IO +open System.Text.RegularExpressions +open System.Collections.Generic +open System.Collections.Immutable + +/// Contains all the data necessary for tagging a related set of files. +[] +type TaggingSet = + { ResourceId: string + AudioFilePaths: ImmutableHashSet + JsonFilePath: string + ImageFilePath: string } + /// Expose all related files as a read-only list + member this.AllFiles : IReadOnlyList = + // combine the immutable hash set with json and image paths preserving as a list + let audio = this.AudioFilePaths |> Seq.toList + List.concat [ audio; [ this.JsonFilePath; this.ImageFilePath ] ] :> IReadOnlyList + + // Private constructor helper to perform validation (not directly callable from outside) + static member private CreateValidated(resourceId: string, audioFilePaths: ICollection, jsonFilePath: string, imageFilePath: string) = + if String.IsNullOrWhiteSpace resourceId then + invalidArg "resourceId" "The resource ID must be provided." + if String.IsNullOrWhiteSpace jsonFilePath then + invalidArg "jsonFilePath" "The JSON file path must be provided." + if String.IsNullOrWhiteSpace imageFilePath then + invalidArg "imageFilePath" "The image file path must be provided." + if audioFilePaths.Count = 0 then + invalidArg "audioFilePaths" "At least one audio file path must be provided." + + let resourceIdTrimmed = resourceId.Trim() + let jsonTrimmed = jsonFilePath.Trim() + let imageTrimmed = imageFilePath.Trim() + let audioSet = ImmutableHashSet.CreateRange(StringComparer.OrdinalIgnoreCase, audioFilePaths) + { ResourceId = resourceIdTrimmed + AudioFilePaths = audioSet + JsonFilePath = jsonTrimmed + ImageFilePath = imageTrimmed } + + /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. + /// Files that don't match the requirements will be ignored. + static member CreateSets (filePaths: ICollection) : ImmutableList = + if isNull filePaths || filePaths.Count = 0 then + ImmutableList.Empty + else + let jsonFileExt = ".json" + let imageFileExt = ".jpg" + + // Regex: group 1 holds the video id; group 0 is the full filename + let fileNamesWithVideoIdsRegex = + Regex(@".+\[([\w_\-]{11})\](?:.*)?\.(\w+)", RegexOptions.Compiled) + + filePaths + |> Seq.map (fun f -> fileNamesWithVideoIdsRegex.Match(f)) + |> Seq.filter (fun m -> m.Success) + |> Seq.map (fun m -> m.Groups.[0].Value, m.Groups.[1].Value) // (fullFilename, videoId) + |> Seq.groupBy snd // group by videoId -> seq of (fullFilename, videoId) + |> Seq.map (fun (videoId, seqFiles) -> videoId, seqFiles |> Seq.map fst) + |> Seq.filter (fun (_videoId, files) -> + let filesList = files |> Seq.toList + // contains at least one audio file + filesList + |> Seq.exists (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(Path.GetExtension(f))) + // exactly one json and exactly one image + && (filesList |> Seq.filter (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length = 1) + && (filesList |> Seq.filter (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length = 1) + ) + |> Seq.map (fun (videoId, files) -> + let filesList = files |> Seq.toList + let audioFiles = + filesList + |> Seq.filter (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(Path.GetExtension(f))) + |> Seq.toList + :> ICollection + let jsonFile = filesList |> Seq.find (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) + let imageFile = filesList |> Seq.find (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) + TaggingSet.CreateValidated(videoId, audioFiles, jsonFile, imageFile) + ) + |> ImmutableList.CreateRange diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs new file mode 100644 index 00000000..954cd765 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -0,0 +1,80 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.Collections.Generic +open System.Text.Json.Serialization + +[] +type VideoMetadata = + { [] Id: string + [] Title: string + [] Thumbnail: string + [] Description: string + [] ChannelId: string + [] ChannelUrl: string + [] Duration: Nullable + [] ViewCount: Nullable + [] AgeLimit: Nullable + [] WebpageUrl: string + [] Categories: IReadOnlyList + [] Tags: IReadOnlyList + [] PlayableInEmbed: Nullable + [] LiveStatus: string + [] ReleaseTimestamp: Nullable + [] FormatSortFields: IReadOnlyList + [] Album: string + [] Artist: string + [] Track: string + [] CommentCount: Nullable + [] LikeCount: Nullable + [] Channel: string + [] ChannelFollowerCount: Nullable + [] ChannelIsVerified: Nullable + [] Uploader: string + [] UploaderId: string + [] UploaderUrl: string + [] UploadDate: string + [] Creator: string + [] AltTitle: string + [] Availability: string + [] WebpageUrlBasename: string + [] WebpageUrlDomain: string + [] Extractor: string + [] ExtractorKey: string + [] PlaylistCount: Nullable + [] Playlist: string + [] PlaylistId: string + [] PlaylistTitle: string + [] NEntries: Nullable + [] PlaylistIndex: Nullable + [] DisplayId: string + [] Fulltitle: string + [] DurationString: string + [] ReleaseDate: string + [] ReleaseYear: Nullable + [] IsLive: Nullable + [] WasLive: Nullable + [] Epoch: Nullable + [] Asr: Nullable + [] Filesize: Nullable + [] FormatId: string + [] FormatNote: string + [] SourcePreference: Nullable + [] AudioChannels: Nullable + [] Quality: Nullable + [] HasDrm: Nullable + [] Tbr: Nullable + [] Url: string + [] LanguagePreference: Nullable + [] Ext: string + [] Vcodec: string + [] Acodec: string + [] Container: string + [] Protocol: string + [] Resolution: string + [] AudioExt: string + [] VideoExt: string + [] Vbr: Nullable + [] Abr: Nullable + [] Format: string + [] Type: string } diff --git a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs new file mode 100644 index 00000000..463233b6 --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs @@ -0,0 +1,65 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.Text + +type VideoMetadata with + + /// Returns a string summarizing video uploader information. + member private this.UploaderSummary() : string = + let uploaderLinkOrIdOrEmpty = + if not (String.IsNullOrWhiteSpace this.UploaderUrl) then this.UploaderUrl + elif not (String.IsNullOrWhiteSpace this.UploaderId) then this.UploaderId + else String.Empty + + let suffix = if not (String.IsNullOrWhiteSpace uploaderLinkOrIdOrEmpty) then sprintf " (%s)" uploaderLinkOrIdOrEmpty else String.Empty + this.Uploader + suffix + + /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") + /// from the plain YYYYMMDD version (e.g., "20230827"). + member private this.FormattedUploadDate() : string = + // Assumes UploadDate has at least 8 characters (YYYYMMDD) + let y = if String.IsNullOrEmpty this.UploadDate then "" else this.UploadDate.[0..3] + let m = if this.UploadDate.Length >= 6 then this.UploadDate.[4..5] else "" + let d = if this.UploadDate.Length >= 8 then this.UploadDate.[6..7] else "" + sprintf "%s/%s/%s" m d y + + /// Returns a formatted comment using data parsed from the JSON file. + member this.GenerateComment(maybeCollectionData: CollectionMetadata option) : string = + let sb = StringBuilder() + sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore + sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore + sb.AppendLine(sprintf "■ URL: %s" this.WebpageUrl) |> ignore + sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore + sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore + + if not (String.IsNullOrWhiteSpace this.Creator) && this.Creator <> this.Uploader then + sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore + + if not (String.IsNullOrWhiteSpace this.Artist) then + sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore + + if not (String.IsNullOrWhiteSpace this.Album) then + sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore + + if not (String.IsNullOrWhiteSpace this.Title) && this.Title <> this.Fulltitle then + sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore + + sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore + + let description = + if String.IsNullOrWhiteSpace this.Description then "None." else this.Description + + sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore + + match maybeCollectionData with + | Some collectionData -> + sb.AppendLine() |> ignore + sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore + sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore + match this.PlaylistIndex with + | Nullable index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if String.IsNullOrWhiteSpace collectionData.Description then String.Empty else collectionData.Description)) |> ignore + | None -> () + + sb.ToString() diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs new file mode 100644 index 00000000..be836afc --- /dev/null +++ b/src/CCVTAC.FSharp/Printer.fs @@ -0,0 +1,145 @@ +namespace CCVTAC.Console + +open System +open System.Collections.Generic +open System.Linq +open Spectre.Console + +type private Level = + | Critical = 0 + | Error = 1 + | Warning = 2 + | Info = 3 + | Debug = 4 + +type private ColorFormat = + { Foreground: string option + Background: string option + Bold: bool } + +type Printer(showDebug: bool) = + + static let colors : Dictionary = + Dictionary() + |> fun d -> + d.Add(Level.Critical, { Foreground = Some "white"; Background = Some "red3"; Bold = true }) + d.Add(Level.Error, { Foreground = Some "red"; Background = None; Bold = false }) + d.Add(Level.Warning, { Foreground = Some "yellow"; Background = None; Bold = false }) + d.Add(Level.Info, { Foreground = None; Background = None; Bold = false }) + d.Add(Level.Debug, { Foreground = Some "grey70"; Background = None; Bold = false }) + d + + let mutable minimumLogLevel = + if showDebug then Level.Debug else Level.Info + + /// Show or hide debug messages. + member this.ShowDebug(show: bool) = + minimumLogLevel <- (if show then Level.Debug else Level.Info) + + /// Escape text so Spectre markup and format strings are safe. + static member private EscapeText(text: string) : string = + text.Replace("{", "{{"}).Replace("}", "}}").Replace("[", "[[").Replace("]", "]]") + + static member private AddMarkup(message: string, colors: ColorFormat) : string = + match colors.Foreground, colors.Background, colors.Bold with + | None, None, false -> message + | fg, bg, bold -> + let boldPart = if bold then "bold " else String.Empty + let fgPart = defaultArg fg "default" + let bgPart = match bg with Some b -> $" on {b}" | None -> String.Empty + let markUp = $"{boldPart}{fgPart}{bgPart}" + $"[{markUp}]{message}[/]" + + member private this.Print + ( + logLevel: Level, + message: string, + ?appendLineBreak: bool, + ?prependLines: byte, + ?appendLines: byte, + ?processMarkup: bool + ) : unit = + + let appendLineBreak = defaultArg appendLineBreak true + let prependLines = defaultArg prependLines 0uy + let appendLines = defaultArg appendLines 0uy + let processMarkup = defaultArg processMarkup true + + if int logLevel > int minimumLogLevel then + () // nothing to do + else + if String.IsNullOrWhiteSpace message then + raise (ArgumentNullException("message", "Message cannot be empty.")) + + Printer.EmptyLines(prependLines) + + let escapedMessage = Printer.EscapeText(message) + + if processMarkup then + let markedUp = Printer.AddMarkup(escapedMessage, colors.[logLevel]) + AnsiConsole.Markup(markedUp) + else + // AnsiConsole.Write uses format strings internally; escapedMessage already duplicates braces + AnsiConsole.Write(escapedMessage) + + if appendLineBreak then AnsiConsole.WriteLine() + + Printer.EmptyLines(appendLines) + + static member PrintTable(table: Table) = + AnsiConsole.Write(table) + + member this.Critical(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = + this.Print(Level.Critical, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + + member this.Error(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = + this.Print(Level.Error, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + + member this.Errors(errors: ICollection, ?appendLines: byte) = + if errors.Count = 0 then raise (ArgumentException("No errors were provided!", "errors")) + for err in errors.Where(fun e -> e.HasText()) do + this.Error(err) + Printer.EmptyLines(defaultArg appendLines 0uy) + + member private this.Errors(headerMessage: string, errors: IEnumerable) = + // Create an array with headerMessage followed by the items in errors + let items = seq { yield headerMessage; yield! errors } |> Seq.toArray + this.Errors(items, 0uy) + + member this.Errors<'T>(failResult: Result<'T>, ?appendLines: byte) = + this.Errors(failResult.Errors.Select(fun e -> e.Message).ToList(), ?appendLines = appendLines) + + member this.Errors<'T>(headerMessage: string, failingResult: Result<'T>) = + this.Errors(headerMessage, failingResult.Errors.Select(fun e -> e.Message)) + + member this.FirstError(failResult: IResultBase, ?prepend: string) = + let pre = defaultArg prepend null + let prefix = if isNull pre then String.Empty else $"{pre} " + let message = (if isNull failResult.Errors then String.Empty else (failResult.Errors.FirstOrDefault()?.Message ?? String.Empty)) + this.Error($"{prefix}{message}") + + member this.Warning(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = + this.Print(Level.Warning, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + + member this.Info(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = + this.Print(Level.Info, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + + member this.Debug(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = + this.Print(Level.Debug, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + + /// Prints the requested number of blank lines. + static member EmptyLines(count: byte) = + if count = 0uy then () else + // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. + let repeats = int count - 1 + if repeats <= 0 then AnsiConsole.WriteLine() else AnsiConsole.WriteLine(String.Concat(Enumerable.Repeat(Environment.NewLine, repeats))) + + member this.GetInput(prompt: string) : string = + Printer.EmptyLines(1uy) + AnsiConsole.Ask($"[skyblue1]{prompt}[/]") + + static member private Ask(title: string, options: string[]) : string = + AnsiConsole.Prompt(SelectionPrompt().Title(title).AddChoices(options)) + + member this.AskToBool(title: string, trueAnswer: string, falseAnswer: string) : bool = + Printer.Ask(title, [| trueAnswer; falseAnswer |]) = trueAnswer diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs new file mode 100644 index 00000000..fcae0bdd --- /dev/null +++ b/src/CCVTAC.FSharp/Program.fs @@ -0,0 +1,65 @@ +namespace CCVTAC.Console + +open System +open System.Linq +open Spectre.Console +open CCVTAC.Console.IoUtilities +open CCVTAC.Console.Settings + +module Program = + + let private helpFlags = [| "-h"; "--help" |] + let private settingsFileFlags = [| "-s"; "--settings" |] + let private defaultSettingsFileName = "settings.json" + + [] + let main (args: string[]) : int = + let printer = Printer(showDebug = true) + + if args.Length > 0 && ExtensionMethods.CaseInsensitiveContains(helpFlags, args.[0]) then + Help.Print(printer) + 0 + else + let maybeSettingsPath = + if args.Length >= 2 && ExtensionMethods.CaseInsensitiveContains(settingsFileFlags, args.[0]) then + args.[1] // expected to be a settings file path + else + defaultSettingsFileName + + match SettingsAdapter.ProcessSettings(maybeSettingsPath, printer) with + | Error errs -> + // Errors prints the messages and exits + printer.Errors(errs.Select(fun e -> e.Message).ToList()) + 1 + | Ok None -> + // A new settings file was created; nothing more to do + 0 + | Ok (Some settings) -> + SettingsAdapter.PrintSummary(settings, printer, header = "Settings loaded OK.") + printer.ShowDebug(not settings.QuietMode) + + // Catch Ctrl-C (SIGINT) + Console.CancelKeyPress.Add(fun args -> + printer.Warning("\nQuitting at user's request.") + + match Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10) with + | Ok () -> () + | Error warnResult -> + printer.FirstError(warnResult) + match Directories.AskToDeleteAllFiles(settings.WorkingDirectory, printer) with + | Ok deletedCount -> printer.Info(sprintf "%d file(s) deleted." deletedCount) + | Error delErr -> printer.FirstError(delErr) + // Do not set args.Cancel here; let default behavior terminate the process + ) + + // Top-level try to catch unexpected exceptions and report them + try + Orchestrator.Start(settings, printer) + 0 + with ex -> + printer.Critical(sprintf "Fatal error: %s" ex.Message) + AnsiConsole.WriteException(ex) + printer.Info( + "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." + ) + 1 diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs new file mode 100644 index 00000000..9d1385ab --- /dev/null +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -0,0 +1,45 @@ +namespace CCVTAC.Console + +open System +open System.Collections.Generic +open System.Linq + +type ResultTracker<'T>(printer: Printer) = + + let mutable successCount : uint64 = 0UL + let failures = Dictionary() + let _printer = + if isNull (box printer) then nullArg "printer" else printer + + static let combineErrors (result: Result<'T>) = + String.Join(" / ", result.Errors.Select(fun e -> e.Message)) + + /// Logs the result for a specific corresponding input. + member _.RegisterResult(input: string, result: Result<'T>) : unit = + if result.IsSuccess then + successCount <- successCount + 1UL + else + let errors = combineErrors result + if not (failures.TryAdd(input, errors)) then + // Keep only the latest error for each input. + failures.[input] <- errors + + /// Prints any failures for the current batch. + member _.PrintBatchFailures() : unit = + if failures.Count = 0 then + _printer.Debug("No failures in batch.") + else + let failureLabel = if failures.Count = 1 then "failure" else "failures" + _printer.Info(sprintf "%d %s in this batch:" failures.Count failureLabel) + for kvp in failures do + _printer.Warning(sprintf "- %s: %s" kvp.Key kvp.Value) + + /// Prints the output for the current application session. + member _.PrintSessionSummary() : unit = + let successLabel = if successCount = 1UL then "success" else "successes" + let failureLabel = if failures.Count = 1 then "failure" else "failures" + + _printer.Info(sprintf "Quitting with %d %s and %d %s." successCount successLabel failures.Count failureLabel) + + for kvp in failures do + _printer.Warning(sprintf "- %s: %s" kvp.Key kvp.Value) diff --git a/src/CCVTAC.FSharp/Settings/Id3Version.fs b/src/CCVTAC.FSharp/Settings/Id3Version.fs new file mode 100644 index 00000000..f433d91c --- /dev/null +++ b/src/CCVTAC.FSharp/Settings/Id3Version.fs @@ -0,0 +1,16 @@ +namespace CCVTAC.Console.Settings + +open System + +module TagFormat = + + /// Point versions of ID3 version 2 (such as 2.3 or 2.4). + type Id3V2Version = + | TwoPoint2 = 2 + | TwoPoint3 = 3 + | TwoPoint4 = 4 + + /// Locks the ID3v2.x version to a valid one and optionally forces that version. + let SetId3V2Version (version: Id3V2Version) (forceAsDefault: bool) : unit = + TagLib.Id3v2.Tag.DefaultVersion <- byte version + TagLib.Id3v2.Tag.ForceDefaultVersion <- forceAsDefault diff --git a/src/CCVTAC.FSharp/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs similarity index 100% rename from src/CCVTAC.FSharp/Settings.fs rename to src/CCVTAC.FSharp/Settings/Settings.fs From 3397ec852c80047d371ead3c20f81e7d5e4b2f35 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:39:24 +0900 Subject: [PATCH 005/247] Add NuGet packages to F# project --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 961476f4..47852341 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -36,4 +36,9 @@ + + + + + From 9f4b3cb5e0bcf11fbeccae465d92da6b0e1abedf Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 23 Nov 2025 09:24:02 +0900 Subject: [PATCH 006/247] Clean up Printer --- src/CCVTAC.FSharp/Printer.fs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index be836afc..f258c42b 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -4,6 +4,7 @@ open System open System.Collections.Generic open System.Linq open Spectre.Console +open ExtensionMethods type private Level = | Critical = 0 @@ -32,13 +33,19 @@ type Printer(showDebug: bool) = let mutable minimumLogLevel = if showDebug then Level.Debug else Level.Info + let extractedErrors result = + match result with + | Ok _ -> [||] + | Error errors -> errors + :> ICollection + /// Show or hide debug messages. member this.ShowDebug(show: bool) = minimumLogLevel <- (if show then Level.Debug else Level.Info) /// Escape text so Spectre markup and format strings are safe. static member private EscapeText(text: string) : string = - text.Replace("{", "{{"}).Replace("}", "}}").Replace("[", "[[").Replace("]", "]]") + text.Replace("{", "{{").Replace("}", "}}").Replace("[", "[[").Replace("]", "]]") static member private AddMarkup(message: string, colors: ColorFormat) : string = match colors.Foreground, colors.Background, colors.Bold with @@ -66,7 +73,7 @@ type Printer(showDebug: bool) = let processMarkup = defaultArg processMarkup true if int logLevel > int minimumLogLevel then - () // nothing to do + () // TODO: Can we remove altogether? else if String.IsNullOrWhiteSpace message then raise (ArgumentNullException("message", "Message cannot be empty.")) @@ -76,7 +83,7 @@ type Printer(showDebug: bool) = let escapedMessage = Printer.EscapeText(message) if processMarkup then - let markedUp = Printer.AddMarkup(escapedMessage, colors.[logLevel]) + let markedUp = Printer.AddMarkup(escapedMessage, colors[logLevel]) AnsiConsole.Markup(markedUp) else // AnsiConsole.Write uses format strings internally; escapedMessage already duplicates braces @@ -97,7 +104,7 @@ type Printer(showDebug: bool) = member this.Errors(errors: ICollection, ?appendLines: byte) = if errors.Count = 0 then raise (ArgumentException("No errors were provided!", "errors")) - for err in errors.Where(fun e -> e.HasText()) do + for err in (errors |> Seq.filter (fun x -> hasText x false)) do this.Error(err) Printer.EmptyLines(defaultArg appendLines 0uy) @@ -106,16 +113,17 @@ type Printer(showDebug: bool) = let items = seq { yield headerMessage; yield! errors } |> Seq.toArray this.Errors(items, 0uy) - member this.Errors<'T>(failResult: Result<'T>, ?appendLines: byte) = - this.Errors(failResult.Errors.Select(fun e -> e.Message).ToList(), ?appendLines = appendLines) + member this.Errors<'a>(failResult: Result<'a, string[]>, ?appendLines: byte) = + this.Errors(extractedErrors failResult, ?appendLines = appendLines) - member this.Errors<'T>(headerMessage: string, failingResult: Result<'T>) = - this.Errors(headerMessage, failingResult.Errors.Select(fun e -> e.Message)) + member this.Errors<'a>(headerMessage: string, failingResult: Result<'a, string[]>) = + this.Errors(headerMessage, extractedErrors failingResult) - member this.FirstError(failResult: IResultBase, ?prepend: string) = + member this.FirstError(failResult: Result<'a, string[]>, ?prepend: string) = let pre = defaultArg prepend null let prefix = if isNull pre then String.Empty else $"{pre} " - let message = (if isNull failResult.Errors then String.Empty else (failResult.Errors.FirstOrDefault()?.Message ?? String.Empty)) + // let message = (if isNull failResult.Errors then String.Empty else (failResult.Errors.FirstOrDefault()?.Message ?? String.Empty)) + let message = extractedErrors failResult |> Seq.head this.Error($"{prefix}{message}") member this.Warning(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = @@ -132,7 +140,8 @@ type Printer(showDebug: bool) = if count = 0uy then () else // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. let repeats = int count - 1 - if repeats <= 0 then AnsiConsole.WriteLine() else AnsiConsole.WriteLine(String.Concat(Enumerable.Repeat(Environment.NewLine, repeats))) + if repeats <= 0 + then AnsiConsole.WriteLine() else AnsiConsole.WriteLine(String.Concat(Enumerable.Repeat(Environment.NewLine, repeats))) member this.GetInput(prompt: string) : string = Printer.EmptyLines(1uy) From 2a09de5e020f52733834c6d30a4391521ae12ab3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:30:27 +0900 Subject: [PATCH 007/247] A ton of F# fixes --- .../CCVTAC.Console.Tests.csproj | 31 -- .../ExtensionMethodTests.cs | 214 -------- src/CCVTAC.Console.Tests/InputHelperTests.cs | 77 --- src/CCVTAC.Console.Tests/Usings.cs | 1 - src/CCVTAC.Console/CCVTAC.Console.csproj | 18 - src/CCVTAC.Console/Commands.cs | 63 --- src/CCVTAC.Console/Comparers.cs | 22 - src/CCVTAC.Console/Downloading/Downloader.cs | 193 ------- src/CCVTAC.Console/Downloading/Updater.cs | 56 -- src/CCVTAC.Console/ExtensionMethods.cs | 80 --- .../ExternalTools/ExternalTool.cs | 73 --- src/CCVTAC.Console/ExternalTools/Runner.cs | 60 -- .../ExternalTools/ToolSettings.cs | 6 - src/CCVTAC.Console/Help.cs | 92 ---- src/CCVTAC.Console/History.cs | 73 --- src/CCVTAC.Console/InputHelper.cs | 92 ---- src/CCVTAC.Console/IoUtilities/Directories.cs | 118 ---- src/CCVTAC.Console/Orchestrator.cs | 418 -------------- .../PostProcessing/CollectionMetadata.cs | 30 - src/CCVTAC.Console/PostProcessing/Deleter.cs | 79 --- .../PostProcessing/ImageProcessor.cs | 15 - src/CCVTAC.Console/PostProcessing/Mover.cs | 249 --------- .../PostProcessing/PostProcessing.cs | 159 ------ src/CCVTAC.Console/PostProcessing/Renamer.cs | 130 ----- .../PostProcessing/Tagging/Detectors.cs | 118 ---- .../PostProcessing/Tagging/TagDetector.cs | 45 -- .../PostProcessing/Tagging/Tagger.cs | 302 ---------- .../PostProcessing/Tagging/TaggingSet.cs | 129 ----- .../PostProcessing/VideoMetadata.cs | 81 --- .../YouTubeMetadataExtensionMethods.cs | 84 --- src/CCVTAC.Console/Printer.cs | 227 -------- src/CCVTAC.Console/Program.cs | 83 --- src/CCVTAC.Console/ResultTracker.cs | 79 --- src/CCVTAC.Console/Settings/Id3Version.cs | 28 - .../Settings/SettingsAdapter.cs | 138 ----- src/CCVTAC.Console/Usings.cs | 7 - src/CCVTAC.Console/settings.default.json | 25 - .../DownloadEntityTests.fs | 3 +- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 33 +- src/CCVTAC.FSharp/Downloading/Downloader.fs | 201 +++++-- src/CCVTAC.FSharp/Downloading/Downloading.fs | 2 +- src/CCVTAC.FSharp/Downloading/Updater.fs | 45 ++ src/CCVTAC.FSharp/Downloading/Uploader.fs | 34 +- src/CCVTAC.FSharp/ExtensionMethods.fs | 46 +- .../ExternalTools/ExternalTool.fs | 4 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 42 +- src/CCVTAC.FSharp/History.fs | 8 +- src/CCVTAC.FSharp/InputHelper.fs | 20 +- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 22 +- src/CCVTAC.FSharp/Orchestrator.fs | 516 +++++++++--------- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 65 +-- .../PostProcessing/ImageProcessor.fs | 9 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 139 ++--- .../PostProcessing/PostProcessing.fs | 117 ++-- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 28 +- .../PostProcessing/Tagging/Detectors.fs | 88 +-- .../PostProcessing/Tagging/TagDetector.fs | 13 +- .../PostProcessing/Tagging/Tagger.fs | 314 ++++++----- .../PostProcessing/Tagging/TaggingSet.fs | 68 ++- .../PostProcessing/VideoMetadata.fs | 61 +++ .../YouTubeMetadataExtensionMethods.fs | 96 ++-- src/CCVTAC.FSharp/Printer.fs | 8 +- src/CCVTAC.FSharp/Program.fs | 40 +- src/CCVTAC.FSharp/ResultTracker.fs | 36 +- src/CCVTAC.FSharp/Settings/Id3Version.fs | 2 - src/CCVTAC.FSharp/Settings/Settings.fs | 49 +- src/CCVTAC.FSharp/Shared.fs | 7 + src/CCVTAC.sln | 12 - 68 files changed, 1257 insertions(+), 4566 deletions(-) delete mode 100644 src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj delete mode 100644 src/CCVTAC.Console.Tests/ExtensionMethodTests.cs delete mode 100644 src/CCVTAC.Console.Tests/InputHelperTests.cs delete mode 100644 src/CCVTAC.Console.Tests/Usings.cs delete mode 100644 src/CCVTAC.Console/CCVTAC.Console.csproj delete mode 100644 src/CCVTAC.Console/Commands.cs delete mode 100644 src/CCVTAC.Console/Comparers.cs delete mode 100644 src/CCVTAC.Console/Downloading/Downloader.cs delete mode 100644 src/CCVTAC.Console/Downloading/Updater.cs delete mode 100644 src/CCVTAC.Console/ExtensionMethods.cs delete mode 100644 src/CCVTAC.Console/ExternalTools/ExternalTool.cs delete mode 100644 src/CCVTAC.Console/ExternalTools/Runner.cs delete mode 100644 src/CCVTAC.Console/ExternalTools/ToolSettings.cs delete mode 100644 src/CCVTAC.Console/Help.cs delete mode 100644 src/CCVTAC.Console/History.cs delete mode 100644 src/CCVTAC.Console/InputHelper.cs delete mode 100644 src/CCVTAC.Console/IoUtilities/Directories.cs delete mode 100644 src/CCVTAC.Console/Orchestrator.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/CollectionMetadata.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Deleter.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/ImageProcessor.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Mover.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/PostProcessing.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Renamer.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Tagging/Detectors.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Tagging/TagDetector.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/Tagging/TaggingSet.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/VideoMetadata.cs delete mode 100644 src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs delete mode 100644 src/CCVTAC.Console/Printer.cs delete mode 100644 src/CCVTAC.Console/Program.cs delete mode 100644 src/CCVTAC.Console/ResultTracker.cs delete mode 100644 src/CCVTAC.Console/Settings/Id3Version.cs delete mode 100644 src/CCVTAC.Console/Settings/SettingsAdapter.cs delete mode 100644 src/CCVTAC.Console/Usings.cs delete mode 100644 src/CCVTAC.Console/settings.default.json create mode 100644 src/CCVTAC.FSharp/Downloading/Updater.fs create mode 100644 src/CCVTAC.FSharp/Shared.fs diff --git a/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj b/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj deleted file mode 100644 index 3ef4d8b6..00000000 --- a/src/CCVTAC.Console.Tests/CCVTAC.Console.Tests.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net10.0 - disable - enable - false - true - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - diff --git a/src/CCVTAC.Console.Tests/ExtensionMethodTests.cs b/src/CCVTAC.Console.Tests/ExtensionMethodTests.cs deleted file mode 100644 index ef955dbf..00000000 --- a/src/CCVTAC.Console.Tests/ExtensionMethodTests.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; - -namespace CCVTAC.Console.Tests; - -public sealed class ExtensionMethodTests -{ - public sealed class ReplaceInvalidPathCharsTests - { - private const string ValidBaseFileName = "filename123あいうえお漢字!@#$%^()_+ "; - private const char DefaultReplaceWithChar = '_'; - - private static readonly char[] PathInvalidChars = [ - Path.PathSeparator, - Path.DirectorySeparatorChar, - Path.AltDirectorySeparatorChar, - Path.VolumeSeparatorChar - ]; - - [Fact] - public void ReplaceInvalidPathChars_StringContainsInvalidPathChars_Fixes() - { - string badFileName = ValidBaseFileName + new string(PathInvalidChars); - string fixedPathName = badFileName.ReplaceInvalidPathChars(DefaultReplaceWithChar); - string expected = ValidBaseFileName + new string(DefaultReplaceWithChar, PathInvalidChars.Length); - Assert.Equal(expected, fixedPathName); - } - - [Fact] - public void ReplaceInvalidPathChars_StringContainsNoInvalidPathChars_DoesNotChange() - { - string result = ValidBaseFileName.ReplaceInvalidPathChars(DefaultReplaceWithChar); - Assert.Equal(ValidBaseFileName, result); - } - - [Fact] - public void ReplaceInvalidPathCharsIncludingCustom_StringContainsInvalidPathChars_Fixes() - { - char[] customInvalidChars = ['&', '&']; - var badFileName = ValidBaseFileName + new string(customInvalidChars); - var fixedPathName = badFileName.ReplaceInvalidPathChars(DefaultReplaceWithChar, customInvalidChars); - var expectedName = ValidBaseFileName + new string(DefaultReplaceWithChar, customInvalidChars.Length); - Assert.Equal(expectedName, fixedPathName); - } - - [Fact] - public void ReplaceInvalidPathCharsIncludingCustom_StringContainsNoInvalidPathChars_DoesNotChange() - { - char[] customInvalidChars = ['&', '&']; - const string goodFileName = ValidBaseFileName + "++"; - string result = goodFileName.ReplaceInvalidPathChars(DefaultReplaceWithChar, customInvalidChars); - Assert.Equal(goodFileName, result); - } - - [Fact] - public void ReplaceInvalidPathChars_InvalidReplaceChar_ThrowsException() - { - const char knownInvalidChar = '/'; - Assert.Throws(() => ValidBaseFileName.ReplaceInvalidPathChars(knownInvalidChar)); - } - } - - public sealed class NoneTests - { - [Fact] - public void None_WithEmptyCollection_ReturnsTrue() - { - List numbers = []; - Assert.True(numbers.None()); - } - - [Fact] - public void None_WithPopulatedCollectionAndMatchingPredicate_ReturnsFalse() - { - List numbers = [2, 4, 6]; - static bool IsEven(byte s) => s % 2 == 0; - Assert.False(numbers.None(IsEven)); - } - - [Fact] - public void None_WithPopulatedCollectionAndNonMatchingPredicate_ReturnsTrue() - { - List numbers = [1, 3, 5]; - static bool IsEven(byte s) => s % 2 == 0; - Assert.True(numbers.None(IsEven)); - } - - [Fact] - public void None_WithEmptyCollectionAndPredicate_ReturnsTrue() - { - List numbers = []; - static bool IsEven(byte s) => s % 2 == 0; - Assert.True(numbers.None(IsEven)); - } - } - - public sealed class HasTextTests - { - [Fact] - public void HasText_Null_ReturnsFalse() - { - string? noText = null; - Assert.False(noText.HasText(false)); - Assert.False(noText.HasText(true)); - } - - [Fact] - public void HasText_EmptyString_ReturnsFalse() - { - var emptyText = string.Empty; - Assert.False(emptyText.HasText(false)); - Assert.False(emptyText.HasText(true)); - } - - [Fact] - public void HasText_SingleByteWhiteSpaceOnlyWhenDisallowed_ReturnsFalse() - { - const string whiteSpace = " "; - Assert.False(whiteSpace.HasText(false)); - } - - [Fact] - public void HasText_SingleByteWhiteSpaceOnlyWhenAllowed_ReturnsTrue() - { - const string whiteSpace = " "; - Assert.True(whiteSpace.HasText(true)); - } - - [Fact] - public void HasText_DoubleByteWhiteSpaceOnlyWhenDisallowed_ReturnsFalse() - { - const string whiteSpace = "   "; - Assert.False(whiteSpace.HasText(false)); - } - - [Fact] - public void HasText_DoubleByteWhiteSpaceOnlyWhenAllowed_ReturnsTrue() - { - const string whiteSpace = "   "; - Assert.True(whiteSpace.HasText(true)); - } - - [Fact] - public void HasText_WithText_ReturnsTrue() - { - const string text = "こんにちは!"; - Assert.True(text.HasText(false)); - Assert.True(text.HasText(true)); - } - } - - public sealed class CaseInsensitiveContainsTests - { - private static readonly List CelestialBodies = - ["Moon", "Mercury", "Mars", "Jupiter", "Venus"]; - - [Fact] - public void CaseInsensitiveContains_EmptyCollection_ReturnsFalse() - { - List collection = []; - var actual = collection.CaseInsensitiveContains("text"); - Assert.False(actual); - } - - [Fact] - public void CaseInsensitiveContains_SearchAllCapsInPopulatedCollection_ReturnsTrue() - { - List collection = CelestialBodies; - var actual = collection.CaseInsensitiveContains("MOON"); - Assert.True(actual); - } - - [Fact] - public void CaseInsensitiveContains_SearchAllLowercaseInPopulatedCollection_ReturnsTrue() - { - List collection = CelestialBodies; - var actual = collection.CaseInsensitiveContains("moon"); - Assert.True(actual); - } - - [Fact] - public void CaseInsensitiveContains_SearchExactInPopulatedCollection_ReturnsTrue() - { - List collection = CelestialBodies; - var actual = collection.CaseInsensitiveContains("Moon"); - Assert.True(actual); - } - - [Fact] - public void CaseInsensitiveContains_SearchPartialInPopulatedCollection_ReturnsFalse() - { - List collection = CelestialBodies; - var actual = collection.CaseInsensitiveContains("Mo"); - Assert.False(actual); - } - - [Fact] - public void CaseInsensitiveContains_SearchExactButDoubleWidthInPopulatedCollection_ReturnsFalse() - { - List collection = CelestialBodies; - var actual = collection.CaseInsensitiveContains("Moon"); - Assert.False(actual); - } - - [Fact] - public void CaseInsensitiveContains_SearchTextInEmptyCollection_ReturnsFalse() - { - List collection = []; - var actual = collection.CaseInsensitiveContains("text"); - Assert.False(actual); - } - } -} diff --git a/src/CCVTAC.Console.Tests/InputHelperTests.cs b/src/CCVTAC.Console.Tests/InputHelperTests.cs deleted file mode 100644 index 3906faf3..00000000 --- a/src/CCVTAC.Console.Tests/InputHelperTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Collections.Generic; - -namespace CCVTAC.Console.Tests; - -public sealed class InputHelperTests -{ - [Fact] - public void MultipleUrlsEntered_CorrectlyParsed() - { - const string combinedInput = "https://youtu.be/5OpuZHsPBhQhttps://youtu.be/NT22EGxTuNw"; - List expected = ["https://youtu.be/5OpuZHsPBhQ", "https://youtu.be/NT22EGxTuNw"]; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Equal(expected.Count, actual.Length); - Assert.Equal(expected[0], actual[0]); - Assert.Equal(expected[1], actual[1]); - } - - [Fact] - public void MultipleUrlsEnteredWithSpaces_CorrectlyParsed() - { - const string combinedInput = " https://youtu.be/5OpuZHsPBhQ https://youtu.be/NT22EGxTuNw "; - List expected = ["https://youtu.be/5OpuZHsPBhQ", "https://youtu.be/NT22EGxTuNw"]; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Equal(expected.Count, actual.Length); - Assert.Equal(expected[0], actual[0]); - Assert.Equal(expected[1], actual[1]); - } - - [Fact] - public void MultipleDuplicateUrlsEntered_CorrectlyParsed() - { - const string combinedInput = "https://youtu.be/5OpuZHsPBhQhttps://youtu.be/NT22EGxTuNwhttps://youtu.be/5OpuZHsPBhQ"; - List expected = ["https://youtu.be/5OpuZHsPBhQ", "https://youtu.be/NT22EGxTuNw"]; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Equal(expected.Count, actual.Length); - Assert.Equal(expected[0], actual[0]); - Assert.Equal(expected[1], actual[1]); - } - - [Fact] - public void SingleCommandEntered_CorrectlyParsed() - { - const string combinedInput = "\\images"; - List expected = ["\\images"]; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Equal(expected.Count, actual.Length); - Assert.Equal(expected[0], actual[0]); - } - - [Fact] - public void MultipleDuplicateCommandsAndUrlsEntered_CorrectlyParsed() - { - const string combinedInput = @"\imageshttps://youtu.be/5OpuZHsPBhQ https://youtu.be/NT22EGxTuNw\images https://youtu.be/5OpuZHsPBhQ"; - List expected = ["\\images", "https://youtu.be/5OpuZHsPBhQ", "https://youtu.be/NT22EGxTuNw"]; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Equal(expected.Count, actual.Length); - Assert.Equal(expected[0], actual[0]); - Assert.Equal(expected[1], actual[1]); - Assert.Equal(expected[2], actual[2]); - } - - [Fact] - public void EmptyInput_CorrectlyParsed() - { - var combinedInput = string.Empty; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Empty(actual); - } - - [Fact] - public void InvalidInput_CorrectlyParsed() - { - const string combinedInput = "invalid"; - var actual = InputHelper.SplitInput(combinedInput); - Assert.Empty(actual); - } -} diff --git a/src/CCVTAC.Console.Tests/Usings.cs b/src/CCVTAC.Console.Tests/Usings.cs deleted file mode 100644 index 9df1d421..00000000 --- a/src/CCVTAC.Console.Tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; diff --git a/src/CCVTAC.Console/CCVTAC.Console.csproj b/src/CCVTAC.Console/CCVTAC.Console.csproj deleted file mode 100644 index 691ccc88..00000000 --- a/src/CCVTAC.Console/CCVTAC.Console.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - Exe - net10.0 - disable - enable - true - - - - - - - - - - - diff --git a/src/CCVTAC.Console/Commands.cs b/src/CCVTAC.Console/Commands.cs deleted file mode 100644 index 2df524c8..00000000 --- a/src/CCVTAC.Console/Commands.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace CCVTAC.Console; - -internal static class Commands -{ - internal const char Prefix = '\\'; - - internal static string[] QuitCommands { get; } = - [MakeCommand("quit"), MakeCommand("q"), MakeCommand("exit")]; - - internal static string HelpCommand { get; } = MakeCommand("help"); - - internal static string[] SettingsSummary { get; } = [MakeCommand("settings")]; - - internal static string[] History { get; } = [MakeCommand("history")]; - internal static string[] UpdateDownloader { get; } = [MakeCommand("update-downloader"), MakeCommand("update-dl")]; - - internal static string[] SplitChapterToggles { get; } = - [MakeCommand("split"), MakeCommand("toggle-split")]; - - internal static string[] EmbedImagesToggles { get; } = - [MakeCommand("images"), MakeCommand("toggle-images")]; - - internal static string[] QuietModeToggles { get; } = - [MakeCommand("quiet"), MakeCommand("toggle-quiet")]; - - internal static string UpdateAudioFormatPrefix { get; } = MakeCommand("format-"); - - internal static string UpdateAudioQualityPrefix { get; } = MakeCommand("quality-"); - - internal static Dictionary Summary { get; } = - new() - { - { string.Join(" or ", History), "See the most recently entered URLs" }, - { string.Join(" or ", SplitChapterToggles), "Toggles chapter splitting for the current session only" }, - { string.Join(" or ", EmbedImagesToggles), "Toggles image embedding for the current session only" }, - { string.Join(" or ", QuietModeToggles), "Toggles quiet mode for the current session only" }, - { string.Join(" or ", UpdateDownloader), "Updates the downloader using the command specified in the settings" }, - { - UpdateAudioFormatPrefix, - $"Followed by a supported audio format (e.g., {UpdateAudioFormatPrefix}m4a), changes the audio format for the current session only" - }, - { - UpdateAudioQualityPrefix, - $"Followed by a supported audio quality (e.g., {UpdateAudioQualityPrefix}0), changes the audio quality for the current session only" - }, - { string.Join(" or ", QuitCommands), "Quit the application" }, - { string.Join(" or ", HelpCommand), "See this help message" }, - }; - - private static string MakeCommand(string text) - { - if (string.IsNullOrWhiteSpace(text)) - throw new ArgumentException("The text cannot be null or white space.", nameof(text)); - - if (text.Contains(' ')) - throw new ArgumentException( - "The text should not contain any white space.", - nameof(text) - ); - - return $"{Prefix}{text}"; - } -} diff --git a/src/CCVTAC.Console/Comparers.cs b/src/CCVTAC.Console/Comparers.cs deleted file mode 100644 index 2960209d..00000000 --- a/src/CCVTAC.Console/Comparers.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CCVTAC.Console; - -internal class Comparers -{ - public sealed class CaseInsensitiveStringComparer : IEqualityComparer - { - public bool Equals(string? x, string? y) - { - return (x, y) switch - { - (null, null) => true, - (null, _) or (_, null) => false, - _ => string.Equals(x.Trim(), y.Trim(), StringComparison.OrdinalIgnoreCase), - }; - } - - public int GetHashCode(string obj) - { - return obj.ToLower().GetHashCode(); - } - } -} diff --git a/src/CCVTAC.Console/Downloading/Downloader.cs b/src/CCVTAC.Console/Downloading/Downloader.cs deleted file mode 100644 index 2972e284..00000000 --- a/src/CCVTAC.Console/Downloading/Downloader.cs +++ /dev/null @@ -1,193 +0,0 @@ -using CCVTAC.Console.ExternalTools; -using MediaTypeWithUrls = CCVTAC.FSharp.Downloading.MediaType; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.Downloading; - -internal static class Downloader -{ - private static readonly string ProgramName = "yt-dlp"; - - private record Urls(string Primary, string? Supplementary); - - internal static Result WrapUrlInMediaType(string url) - { - var result = FSharp.Downloading.MediaTypeWithIds(url); - - return result.IsOk ? Result.Ok(result.ResultValue) : Result.Fail(result.ErrorValue); - } - - /// - /// Completes the actual download process. - /// - /// A `Result` that, if successful, contains the name of the successfully downloaded format. - internal static Result Run( - MediaTypeWithUrls mediaType, - UserSettings settings, - Printer printer - ) - { - if (mediaType is { IsVideo: false, IsPlaylistVideo: false }) - { - printer.Info("Please wait for multiple videos to be downloaded..."); - } - - var rawUrls = FSharp.Downloading.ExtractDownloadUrls(mediaType); - var urls = new Urls(rawUrls[0], rawUrls.Length == 2 ? rawUrls[1] : null); - - var downloadResult = new Result<(int, string)>(); - string? successfulFormat = null; - - foreach (string format in settings.AudioFormats) - { - string args = GenerateDownloadArgs(format, settings, mediaType, urls.Primary); - var commandWithArgs = $"{ProgramName} {args}"; - var downloadSettings = new ToolSettings(commandWithArgs, settings.WorkingDirectory!); - - downloadResult = Runner.Run(downloadSettings, otherSuccessExitCodes: [1], printer); - - if (downloadResult.IsSuccess) - { - successfulFormat = format; - - var (exitCode, warnings) = downloadResult.Value; - if (exitCode != 0) - { - printer.Warning("Downloading completed with minor issues."); - if (warnings.HasText()) - { - printer.Warning(warnings); - } - } - - break; - } - - printer.Debug($"Failure downloading \"{format}\" format."); - } - - var errors = downloadResult.Errors.Select(e => e.Message).ToList(); - - int audioFileCount = IoUtilities.Directories.AudioFileCount(settings.WorkingDirectory); - if (audioFileCount == 0) - { - return Result.Fail( - string.Join(Environment.NewLine, errors.Prepend("No audio files were downloaded.")) - ); - } - - if (errors.Count != 0) - { - downloadResult.Errors.ToList().ForEach(e => printer.Error(e.Message)); - printer.Info("Post-processing will still be attempted."); // For any partial downloads - } - else if (urls.Supplementary is not null) - { - string supplementaryArgs = GenerateDownloadArgs( - null, - settings, - null, - urls.Supplementary - ); - - var commandWithArgs = $"{ProgramName} {supplementaryArgs}"; - - var supplementaryDownloadSettings = new ToolSettings(commandWithArgs, settings.WorkingDirectory!); - - var supplementaryDownloadResult = Runner.Run( - supplementaryDownloadSettings, - otherSuccessExitCodes: [1], - printer - ); - - if (supplementaryDownloadResult.IsSuccess) - { - printer.Info("Supplementary download completed OK."); - } - else - { - printer.Error("Supplementary download failed."); - errors.AddRange(supplementaryDownloadResult.Errors.Select(e => e.Message)); - } - } - - return errors.Count > 0 - ? Result.Fail(string.Join(" / ", errors)) - : Result.Ok(successfulFormat); - } - - /// - /// Generate the entire argument string for the download tool. - /// - /// One of the supported audio format codes. - /// - /// A `MediaType` or null (which indicates a metadata-only supplementary download). - /// - /// A string of arguments that can be passed directly to the download tool. - private static string GenerateDownloadArgs( - string? audioFormat, - UserSettings settings, - MediaTypeWithUrls? mediaType, - params string[]? additionalArgs - ) - { - const string writeJson = "--write-info-json"; - const string trimFileNames = "--trim-filenames 250"; - - // yt-dlp warning: "-f best" selects the best pre-merged format which is often not the best option. - // To let yt-dlp download and merge the best available formats, simply do not pass any format selection." - var formatArg = - !audioFormat.HasText() || audioFormat == "best" ? string.Empty : $"-f {audioFormat}"; - - HashSet args = mediaType switch - { - // For metadata-only downloads - null => [$"--flat-playlist {writeJson} {trimFileNames}"], - - // For video(s) with their respective metadata files (JSON and artwork). - _ => - [ - "--extract-audio", - formatArg, - $"--audio-quality {settings.AudioQuality}", - "--write-thumbnail --convert-thumbnails jpg", // For album art - writeJson, // Contains metadata - trimFileNames, - "--retries 2", // Default is 10, which seems like overkill - ], - }; - - // yt-dlp has a `--verbose` option too, but that's too much data. - // It might be worth incorporating it in the future as a third option. - args.Add(settings.QuietMode ? "--quiet --no-warnings" : string.Empty); - - if (mediaType is not null) - { - if (settings.SplitChapters) - { - args.Add("--split-chapters"); - } - - if (mediaType is { IsVideo: false, IsPlaylistVideo: false }) - { - args.Add($"--sleep-interval {settings.SleepSecondsBetweenDownloads}"); - } - - // The numbering of regular playlists should be reversed because the newest items are - // always placed at the top of the list at position #1. Instead, the oldest items - // (at the end of the list) should begin at #1. - if (mediaType.IsStandardPlaylist) - { - // The digits followed by `B` induce trimming to the specified number of bytes. - // Use `s` instead of `B` to trim to a specified number of characters. - // Reference: https://github.com/yt-dlp/yt-dlp/issues/1136#issuecomment-1114252397 - // Also, it's possible this trimming should be applied to `ReleasePlaylist`s too. - args.Add( - """-o "%(uploader).80B - %(playlist).80B - %(playlist_autonumber)s - %(title).150B [%(id)s].%(ext)s" --playlist-reverse""" - ); - } - } - - return string.Join(" ", args.Concat(additionalArgs ?? [])); - } -} diff --git a/src/CCVTAC.Console/Downloading/Updater.cs b/src/CCVTAC.Console/Downloading/Updater.cs deleted file mode 100644 index e66049af..00000000 --- a/src/CCVTAC.Console/Downloading/Updater.cs +++ /dev/null @@ -1,56 +0,0 @@ -using CCVTAC.Console.ExternalTools; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.Downloading; - -internal static class Updater -{ - private record Urls(string Primary, string? Supplementary); - - /// - /// Completes the actual download process. - /// - /// A `Result` that, if successful, contains the name of the successfully downloaded format. - internal static Result Run(UserSettings settings, Printer printer) - { - if (string.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand)) - { - printer.Info("No downloader update command provided, so will skip."); - return Result.Ok(); - } - - var args = new ToolSettings(settings.DownloaderUpdateCommand, settings.WorkingDirectory!); - - var result = Runner.Run(args, otherSuccessExitCodes: [], printer); - - if (result.IsSuccess) - { - var (exitCode, warnings) = result.Value; - - if (exitCode != 0) - { - printer.Warning("Update completed with minor issues."); - - if (warnings.HasText()) - { - printer.Warning(warnings); - } - } - - return Result.Ok(); - } - - printer.Error($"Failure updating..."); - - var errors = result.Errors.Select(e => e.Message).ToList(); - - if (errors.Count != 0) - { - result.Errors.ToList().ForEach(e => printer.Error(e.Message)); - } - - return errors.Count > 0 - ? Result.Fail(string.Join(" / ", errors)) - : Result.Ok(); - } -} diff --git a/src/CCVTAC.Console/ExtensionMethods.cs b/src/CCVTAC.Console/ExtensionMethods.cs deleted file mode 100644 index 1e44e5f4..00000000 --- a/src/CCVTAC.Console/ExtensionMethods.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.IO; -using System.Text; - -namespace CCVTAC.Console; - -public static class ExtensionMethods -{ - /// - /// Determines whether a string contains any text. - /// - /// - /// Specifies whether whitespace characters should be considered as text. - /// Returns true if the string contains text; otherwise, false. - public static bool HasText(this string? maybeText, bool allowWhiteSpace = false) - { - return allowWhiteSpace - ? !string.IsNullOrEmpty(maybeText) - : !string.IsNullOrWhiteSpace(maybeText); - } - - extension(IEnumerable collection) - { - /// - /// Determines whether a collection is empty. - /// - public bool None() => !collection.Any(); - - /// - /// Determines whether no elements of a sequence satisfy a given condition. - /// - public bool None(Func predicate) => - !collection.Any(predicate); - } - - public static bool CaseInsensitiveContains(this IEnumerable collection, string text) => - collection.Contains(text, new Comparers.CaseInsensitiveStringComparer()); - - /// - extension(string sourceText) - { - /// - /// Returns a new string in which all invalid path characters for the current OS - /// have been replaced by specified replacement character. - /// Throws if the replacement character is an invalid path character. - /// - /// - /// Optional additional characters to consider invalid. - public string ReplaceInvalidPathChars(char replaceWith = '_', - char[]? customInvalidChars = null - ) - { - var invalidChars = Path.GetInvalidFileNameChars() - .Concat(Path.GetInvalidPathChars()) - .Concat( - [ - Path.PathSeparator, - Path.DirectorySeparatorChar, - Path.AltDirectorySeparatorChar, - Path.VolumeSeparatorChar, - ] - ) - .Concat(customInvalidChars ?? Enumerable.Empty()) - .ToFrozenSet(); - - if (invalidChars.Contains(replaceWith)) - throw new ArgumentException( - $"The replacement char ('{replaceWith}') must be a valid path character." - ); - - return invalidChars.Aggregate( - new StringBuilder(sourceText), - (workingText, ch) => workingText.Replace(ch, replaceWith), - workingText => workingText.ToString() - ); - } - - public string TrimTerminalLineBreak() => - sourceText.HasText() ? sourceText.TrimEnd(Environment.NewLine.ToCharArray()) : sourceText; - } -} diff --git a/src/CCVTAC.Console/ExternalTools/ExternalTool.cs b/src/CCVTAC.Console/ExternalTools/ExternalTool.cs deleted file mode 100644 index 8596a3f3..00000000 --- a/src/CCVTAC.Console/ExternalTools/ExternalTool.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Diagnostics; - -namespace CCVTAC.Console.ExternalTools; - -internal record ExternalTool -{ - /// - /// The name of the program. This should be the exact text used to call it - /// on the command line, excluding any arguments. - /// - internal string Name { get; } - - /// - /// The URL of the program, from which users should install it if needed. - /// - internal string Url { get; } - - /// - /// A brief summary of the purpose of the program within the context of this program. - /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). - /// - internal string Purpose { get; } - - /// - /// - /// - /// The name of the program. This should be the exact text used to call it - /// on the command line, excluding any arguments. - /// The URL of the program, from which users should install it if needed. - /// A brief summary of the purpose of the program within the context of this program. - /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). - internal ExternalTool(string name, string url, string purpose) - { - Name = name.Trim(); - Url = url.Trim(); - Purpose = purpose.Trim(); - } - - /// - /// Attempts a dry run of the program to determine if it is installed and available on this system. - /// - /// A Result indicating whether the program is available or not. - internal Result ProgramExists() - { - ProcessStartInfo processStartInfo = new() - { - FileName = Name, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - try - { - using var process = Process.Start(processStartInfo); - - if (process is null) - { - return Result.Fail( - $"The program \"{Name}\" was not found. (The process was null.)" - ); - } - - process.WaitForExit(); - return Result.Ok(); - } - catch (Exception) - { - return Result.Fail($"The program \"{Name}\" was not found."); - } - } -} diff --git a/src/CCVTAC.Console/ExternalTools/Runner.cs b/src/CCVTAC.Console/ExternalTools/Runner.cs deleted file mode 100644 index 88e90acb..00000000 --- a/src/CCVTAC.Console/ExternalTools/Runner.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Diagnostics; - -namespace CCVTAC.Console.ExternalTools; - -internal static class Runner -{ - private const int AuthenticSuccessExitCode = 0; - - private static bool IsSuccessExitCode(HashSet otherSuccessExitCodes, int exitCode) => - otherSuccessExitCodes.Append(AuthenticSuccessExitCode).Contains(exitCode); - - /// - /// Calls an external application. - /// - /// - /// Additional exit codes, other than 0, that can be treated as non-failures. - /// - /// A `Result` containing the exit code, if successful, or else an error message. - internal static Result<(int SuccessExitCode, string Warnings)> Run( - ToolSettings settings, - HashSet otherSuccessExitCodes, - Printer printer - ) - { - Watch watch = new(); - - printer.Info($"Running {settings.CommandWithArgs}..."); - - var splitCommandWithArgs = settings.CommandWithArgs.Split([' '], 2); - - ProcessStartInfo processStartInfo = new() - { - FileName = splitCommandWithArgs[0], - Arguments = splitCommandWithArgs.Length > 1 ? splitCommandWithArgs[1] : string.Empty, - UseShellExecute = false, - RedirectStandardOutput = false, - RedirectStandardError = true, - CreateNoWindow = true, - WorkingDirectory = settings.WorkingDirectory, - }; - - using Process? process = Process.Start(processStartInfo); - - if (process is null) - { - return Result.Fail($"Could not locate {splitCommandWithArgs[0]}."); - } - - string errors = process.StandardError.ReadToEnd(); // Must precede `WaitForExit()` - process.WaitForExit(); - printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}."); - - var trimmedErrors = errors.TrimTerminalLineBreak(); - return IsSuccessExitCode(otherSuccessExitCodes, process.ExitCode) - ? Result.Ok((process.ExitCode, trimmedErrors)) // Errors will be considered warnings. - : Result.Fail( - $"{splitCommandWithArgs[0]} exited with code {process.ExitCode}: {trimmedErrors}." - ); - } -} diff --git a/src/CCVTAC.Console/ExternalTools/ToolSettings.cs b/src/CCVTAC.Console/ExternalTools/ToolSettings.cs deleted file mode 100644 index f02244ca..00000000 --- a/src/CCVTAC.Console/ExternalTools/ToolSettings.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CCVTAC.Console.ExternalTools; - -/// -/// Settings to govern the behavior of an external program. -/// -internal sealed record ToolSettings(string CommandWithArgs, string WorkingDirectory); diff --git a/src/CCVTAC.Console/Help.cs b/src/CCVTAC.Console/Help.cs deleted file mode 100644 index dd7fd327..00000000 --- a/src/CCVTAC.Console/Help.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace CCVTAC.Console; - -public static class Help -{ - internal static void Print(Printer printer) - { - const string helpText = """ - CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI - tool that acts as a wrapper around yt-dlp (https://github.com/yt-dlp/yt-dlp) - to enable easier downloads of audio from YouTube videos, playlists, and - channels, plus do some automatic post-processing (tagging, renaming, and - moving) too. - - While I maintain it primarily for my own use, feel free to use it yourself. - No warranties or guarantees are provided. - - FEATURES - - - Converts YouTube videos, playlists, and channels to local audio files (via yt-dlp) - - Writes ID3 tags to files where possible using available or regex-detected metadata - - Adds video metadata (channel name and URL, video URL, etc.) to files' Comment tags - - Auto-renames files via custom regex patterns (to remove media IDs, etc.) - - Optionally writes video thumbnails to files as artwork (if mogrify is installed) - - Customized behavior via a user settings file -- e.g., chapter splitting, image embedding, directories - - Saves entered URLs to a local history file - - PREREQUISITES - - • .NET 10 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/10.0) - • yt-dlp (https://github.com/yt-dlp/yt-dlp) - • [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) - • Optional: mogrify https://imagemagick.org/script/mogrify.php - (for auto-trimming album art) - - RUNNING IT - - Settings: - - A valid settings file is mandatory to use this application. - - The application will look for a file named `settings.json` in its directory. - However, you can manually specify an existing file path using the `-s` - option, such as `dotnet run -- -s `. - - If your `settings.json` file does not exist, a default file will be created in the - application directory with default settings. At minimum, you will need to - enter (1) an existing directory for temporary working files, (2) an existing - directory to which the final audio files should be moved, and (3) a path to - your history file. The other settings have sensible defaults. - - I added the `sleepSecondsBetweenDownloads` and `sleepSecondsBetweenURLs` - settings to help reduce concentrated loads on YouTube servers. Please avoid - lowering these values too much and slamming their servers with enormous, - long-running downloads (even if you feel their servers can take it). Such behavior - might get you rate-limited by YouTube. - - See the README file on the GitHub repo for more about settings. - - Using the application: - - Once your settings are ready, run the application with `dotnet run`. - Alternatively, pass `-h` or `--help` for instructions (e.g., - `dotnet run -- --help`). - - When the application is running, enter at least one YouTube media URL (video, - playlist, or channel) at the prompt and press Enter. No spaces between - items are necessary. - - You can also enter the following commands: - - "\help" to see this list of commands - - "\quit" or "\q" to quit - - "\history" to see the last few URLs you entered - - "\update-downloader" or "\update-dl" to update yt-dlp using the command in your settings - (If you start experiencing constant download errors, try this command) - - Modify the current session only (without updating the settings file): - - `\split` toggles chapter splitting - - `\images` toggles image embedding - - `\quiet` toggles quiet mode - - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the format - - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality - - Enter `\commands` to see this summary in the application. - - Reporting issues: - - If you run into any issues, feel free to create an issue on GitHub. Please provide as much - information as possible (e.g., entered URLs, system information, yt-dlp version). - """; - - printer.Info(helpText, processMarkup: false); - } -} diff --git a/src/CCVTAC.Console/History.cs b/src/CCVTAC.Console/History.cs deleted file mode 100644 index 8c5fac8c..00000000 --- a/src/CCVTAC.Console/History.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.IO; -using System.Text.Json; -using Spectre.Console; - -namespace CCVTAC.Console; - -/// -/// Handles storing, retrieving, and (eventually) analyzing data relating -/// to URLs that the user has entered. -/// -public class History -{ - private const char Separator = ';'; - private string FilePath { get; } - private byte DisplayCount { get; } - - public History(string filePath, byte displayCount) - { - FilePath = filePath; - DisplayCount = displayCount; - } - - /// - /// Add a URL and related data to the history file. - /// - public void Append(string url, DateTime entryTime, Printer printer) - { - try - { - string serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", ""); - File.AppendAllText( - FilePath, - serializedEntryTime + Separator + url + Environment.NewLine - ); - - printer.Debug($"Added \"{url}\" to the history log."); - } - catch (Exception ex) - { - printer.Error("Could not append URL(s) to history log: " + ex.Message); - } - } - - public void ShowRecent(Printer printer) - { - try - { - IEnumerable> historyData = File.ReadAllLines(FilePath) - .TakeLast(DisplayCount) - .Select(line => line.Split(Separator)) - .Where(lineItems => lineItems.Length == 2) // Only lines with date-times - .GroupBy(line => DateTime.Parse(line[0]), line => line[1]); - - Table table = new(); - table.Border(TableBorder.None); - table.AddColumns("Time", "URL"); - table.Columns[0].PadRight(3); - - foreach (IGrouping thisDate in historyData) - { - var formattedTime = $"{thisDate.Key:yyyy-MM-dd HH:mm:ss}"; - var urls = string.Join(Environment.NewLine, thisDate); - table.AddRow(formattedTime, urls); - } - - Printer.PrintTable(table); - } - catch (Exception ex) - { - printer.Error($"Could not display recent history: {ex.Message}"); - } - } -} diff --git a/src/CCVTAC.Console/InputHelper.cs b/src/CCVTAC.Console/InputHelper.cs deleted file mode 100644 index b1adb630..00000000 --- a/src/CCVTAC.Console/InputHelper.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Text.RegularExpressions; - -namespace CCVTAC.Console; - -public static partial class InputHelper -{ - internal static readonly string Prompt = - $"Enter one or more YouTube media URLs or commands (or \"{Commands.HelpCommand}\"):\n▶︎"; - - /// - /// A regular expression that detects where commands and URLs begin in input strings. - /// - [GeneratedRegex("""(?:https:|\\)""")] - private static partial Regex UserInputRegex(); - - private record IndexPair(int Start, int End); - - /// - /// Takes a user input string and splits it into a collection of inputs based - /// upon substrings detected by the class's regular expression pattern. - /// - public static ImmutableArray SplitInput(string input) - { - var matches = UserInputRegex().Matches(input).OfType().ToImmutableArray(); - - if (matches.Length == 0) - { - return []; - } - - if (matches.Length == 1) - { - return [input]; - } - - var startIndices = matches.Select(m => m.Index).ToImmutableArray(); - - var indexPairs = startIndices.Select( - (startIndex, iterIndex) => - { - int endIndex = - iterIndex == startIndices.Length - 1 - ? input.Length - : startIndices[iterIndex + 1]; - - return new IndexPair(startIndex, endIndex); - } - ); - - var splitInputs = indexPairs.Select(p => input[p.Start..p.End].Trim()).Distinct(); - - return [.. splitInputs]; - } - - internal enum InputCategory - { - Url, - Command, - } - - internal record CategorizedInput(string Text, InputCategory Category); - - internal static ImmutableArray CategorizeInputs(ICollection inputs) - { - return - [ - .. inputs.Select(input => new CategorizedInput( - input, - input.StartsWith(Commands.Prefix) ? InputCategory.Command : InputCategory.Url - )), - ]; - } - - internal class CategoryCounts - { - private readonly Dictionary _counts; - - internal CategoryCounts(Dictionary counts) - { - _counts = counts; - } - - public int this[InputCategory category] => _counts.GetValueOrDefault(category, 0); - } - - internal static CategoryCounts CountCategories(ICollection inputs) - { - var counts = inputs.GroupBy(i => i.Category).ToDictionary(gr => gr.Key, gr => gr.Count()); - - return new CategoryCounts(counts); - } -} diff --git a/src/CCVTAC.Console/IoUtilities/Directories.cs b/src/CCVTAC.Console/IoUtilities/Directories.cs deleted file mode 100644 index 2703a8c6..00000000 --- a/src/CCVTAC.Console/IoUtilities/Directories.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.IO; -using System.Text; -using CCVTAC.Console.PostProcessing; - -namespace CCVTAC.Console.IoUtilities; - -internal static class Directories -{ - private const string AllFilesSearchPattern = "*"; - private static readonly EnumerationOptions EnumerationOptions = new(); - - internal static int AudioFileCount(string directory) - { - return new DirectoryInfo(directory) - .EnumerateFiles() - .Count(f => PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)); - } - - internal static Result WarnIfAnyFiles(string directory, int showMax) - { - var fileNames = GetDirectoryFileNames(directory); - var fileCount = fileNames.Length; - - if (fileNames.IsEmpty) - { - return Result.Ok(); - } - - var fileLabel = fileCount == 1 ? "file" : "files"; - var report = new StringBuilder( - $"Unexpectedly found {fileCount} {fileLabel} in working directory \"{directory}\":{Environment.NewLine}" - ); - - foreach (string fileName in fileNames.Take(showMax)) - { - report.AppendLine($"• {fileName}"); - } - - if (fileCount > showMax) - { - report.AppendLine($"... plus {fileCount - showMax} more."); - } - - report.AppendLine("This generally occurs due to the same video appearing twice in playlists."); - - return Result.Fail(report.ToString()); - } - - internal static Result DeleteAllFiles(string workingDirectory, int showMaxErrors) - { - var fileNames = GetDirectoryFileNames(workingDirectory); - - int successCount = 0; - var errors = new List(); - - foreach (var fileName in fileNames) - { - try - { - File.Delete(fileName); - successCount++; - } - catch (Exception ex) - { - errors.Add(ex.Message); - } - } - - if (errors.Count == 0) - { - return Result.Ok(successCount); - } - - var output = new StringBuilder( - $"While {successCount} files were deleted successfully, some files could not be deleted:" - ); - foreach (string error in errors.Take(showMaxErrors)) - { - output.AppendLine($"• {error}"); - } - - if (errors.Count > showMaxErrors) - { - output.AppendLine($"... plus {errors.Count - showMaxErrors} more."); - } - - return Result.Fail(output.ToString()); - } - - internal static Result AskToDeleteAllFiles(string workingDirectory, Printer printer) - { - bool doDelete = printer.AskToBool("Delete all temporary files?", "Yes", "No"); - - return doDelete - ? DeleteAllFiles(workingDirectory, 10) - : Result.Fail("Will not delete the files."); - } - - /// - /// Returns the filenames in a given directory, optionally ignoring specific filenames. - /// - /// - /// An optional list of files to be excluded. - private static ImmutableArray GetDirectoryFileNames( - string directoryName, - IEnumerable? customIgnoreFiles = null - ) - { - var ignoreFiles = customIgnoreFiles?.Distinct() ?? []; - - return - [ - .. Directory - .GetFiles(directoryName, AllFilesSearchPattern, EnumerationOptions) - .Where(filePath => ignoreFiles.None(filePath.EndsWith)), - ]; - } -} diff --git a/src/CCVTAC.Console/Orchestrator.cs b/src/CCVTAC.Console/Orchestrator.cs deleted file mode 100644 index 21d8cf73..00000000 --- a/src/CCVTAC.Console/Orchestrator.cs +++ /dev/null @@ -1,418 +0,0 @@ -using System.Threading; -using CCVTAC.Console.Downloading; -using CCVTAC.Console.IoUtilities; -using CCVTAC.Console.PostProcessing; -using CCVTAC.Console.Settings; -using Spectre.Console; -using static CCVTAC.Console.InputHelper; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console; - -/// -/// Drives the primary input gathering and processing tasks. -/// -internal class Orchestrator -{ - /// - /// Ensures the download environment is ready, then initiates the UI input and download process. - /// - internal static void Start(UserSettings settings, Printer printer) - { - // The working directory should start empty. Give the user a chance to empty it. - var emptyDirResult = Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); - if (emptyDirResult.IsFailed) - { - printer.FirstError(emptyDirResult); - - var deleteResult = Directories.AskToDeleteAllFiles(settings.WorkingDirectory, printer); - if (deleteResult.IsSuccess) - { - printer.Info($"{deleteResult.Value} file(s) deleted."); - } - else - { - printer.FirstError(deleteResult); - printer.Info("Aborting..."); - return; - } - } - - var results = new ResultTracker(printer); - var history = new History(settings.HistoryFile, settings.HistoryDisplayCount); - var nextAction = NextAction.Continue; - - while (nextAction is NextAction.Continue) - { - var input = printer.GetInput(InputHelper.Prompt); - var splitInputs = InputHelper.SplitInput(input); - - if (splitInputs.IsEmpty) - { - printer.Error( - $"Invalid input. Enter only URLs or commands beginning with \"{Commands.Prefix}\"." - ); - continue; - } - - var categorizedInputs = InputHelper.CategorizeInputs(splitInputs); - var categoryCounts = InputHelper.CountCategories(categorizedInputs); - - SummarizeInput(categorizedInputs, categoryCounts, printer); - - nextAction = ProcessBatch( - categorizedInputs, - categoryCounts, - ref settings, - results, - history, - printer - ); - } - - results.PrintSessionSummary(); - } - - /// - /// Processes a single user request, from input to downloading and file post-processing. - /// - /// Returns the next action the application should take (e.g., continue or quit). - private static NextAction ProcessBatch( - ImmutableArray categorizedInputs, - CategoryCounts categoryCounts, - ref UserSettings settings, - ResultTracker resultTracker, - History history, - Printer printer - ) - { - var inputTime = DateTime.Now; - var nextAction = NextAction.Continue; - Watch watch = new(); - - var batchResults = new ResultTracker(printer); - int inputIndex = 0; - - foreach (var input in categorizedInputs) - { - var result = - input.Category is InputCategory.Command - ? ProcessCommand(input.Text, ref settings, history, printer) - : ProcessUrl( - input.Text, - settings, - resultTracker, - history, - inputTime, - categoryCounts[InputCategory.Url], - ++inputIndex, - printer - ); - - batchResults.RegisterResult(input.Text, result); - - if (result.IsFailed) - { - printer.Error(result.Errors.First().Message); - continue; - } - - nextAction = result.Value; - - if (nextAction is not NextAction.Continue) - { - break; - } - } - - if (categoryCounts[InputCategory.Url] > 1) - { - printer.Info( - $"{Environment.NewLine}Finished with batch of {categoryCounts[InputCategory.Url]} URLs in {watch.ElapsedFriendly}." - ); - batchResults.PrintBatchFailures(); - } - - return nextAction; - } - - private static Result ProcessUrl( - string url, - UserSettings settings, - ResultTracker resultTracker, - History history, - DateTime urlInputTime, - int batchSize, - int urlIndex, - Printer printer - ) - { - var emptyDirResult = Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); - if (emptyDirResult.IsFailed) - { - printer.FirstError(emptyDirResult); - return NextAction.QuitDueToErrors; // TODO: Perhaps determine a better way. - } - - if (urlIndex > 1) // Don't sleep for the very first URL. - { - Sleep(settings.SleepSecondsBetweenURLs); - printer.Info( - $"Slept for {settings.SleepSecondsBetweenURLs} second(s).", - appendLines: 1 - ); - } - - if (batchSize > 1) - { - printer.Info($"Processing group {urlIndex} of {batchSize}..."); - } - - Watch jobWatch = new(); - - var mediaTypeResult = Downloader.WrapUrlInMediaType(url); - if (mediaTypeResult.IsFailed) - { - var errorMsg = $"URL parse error: {mediaTypeResult.Errors.First().Message}"; - printer.Error(errorMsg); - return Result.Fail(errorMsg); - } - var mediaType = mediaTypeResult.Value; - - printer.Info($"{mediaType.GetType().Name} URL '{url}' detected."); - history.Append(url, urlInputTime, printer); - - var downloadResult = Downloader.Run(mediaType, settings, printer); - resultTracker.RegisterResult(url, downloadResult); - - if (downloadResult.IsFailed) - { - var errorMsg = $"Download error: {downloadResult.Errors.First().Message}"; - printer.Error(errorMsg); - return Result.Fail(errorMsg); - } - - printer.Debug($"Successfully downloaded \"{downloadResult.Value}\" format."); - - PostProcessor.Run(settings, mediaType, printer); - - string groupClause = batchSize > 1 ? $" (group {urlIndex} of {batchSize})" : string.Empty; - - printer.Info($"Processed '{url}'{groupClause} in {jobWatch.ElapsedFriendly}."); - return NextAction.Continue; - } - - private static Result ProcessCommand( - string command, - ref UserSettings settings, - History history, - Printer printer - ) - { - if (Commands.HelpCommand.Equals(command, StringComparison.InvariantCultureIgnoreCase)) - { - foreach (var (cmd, description) in Commands.Summary) - { - printer.Info(cmd); - printer.Info($" {description}"); - } - - return Result.Ok(NextAction.Continue); - } - - if (Commands.QuitCommands.CaseInsensitiveContains(command)) - { - return Result.Ok(NextAction.QuitAtUserRequest); - } - - if (Commands.History.CaseInsensitiveContains(command)) - { - history.ShowRecent(printer); - return Result.Ok(NextAction.Continue); - } - - if (Commands.UpdateDownloader.CaseInsensitiveContains(command)) - { - Updater.Run(settings, printer); - return Result.Ok(NextAction.Continue); - } - - if (Commands.SettingsSummary.CaseInsensitiveContains(command)) - { - SettingsAdapter.PrintSummary(settings, printer); - return Result.Ok(NextAction.Continue); - } - - static string SummarizeToggle(string settingName, bool setting) => - $"{settingName} was toggled to {(setting ? "ON" : "OFF")} for this session."; - - static string SummarizeUpdate(string settingName, string setting) => - $"{settingName} was updated to \"{setting}\" for this session."; - - if (Commands.SplitChapterToggles.CaseInsensitiveContains(command)) - { - settings = SettingsAdapter.ToggleSplitChapters(settings); - printer.Info(SummarizeToggle("Split Chapters", settings.SplitChapters)); - return Result.Ok(NextAction.Continue); - } - - if (Commands.EmbedImagesToggles.CaseInsensitiveContains(command)) - { - settings = SettingsAdapter.ToggleEmbedImages(settings); - printer.Info(SummarizeToggle("Embed Images", settings.EmbedImages)); - return Result.Ok(NextAction.Continue); - } - - if (Commands.QuietModeToggles.CaseInsensitiveContains(command)) - { - settings = SettingsAdapter.ToggleQuietMode(settings); - printer.Info(SummarizeToggle("Quiet Mode", settings.QuietMode)); - printer.ShowDebug(!settings.QuietMode); - return Result.Ok(NextAction.Continue); - } - - if ( - command.StartsWith( - Commands.UpdateAudioFormatPrefix, - StringComparison.InvariantCultureIgnoreCase - ) - ) - { - var format = command - .Replace(Commands.UpdateAudioFormatPrefix, string.Empty) - .ToLowerInvariant(); - - if (format == string.Empty) - { - return Result.Fail( - $"You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." - ); - } - - var updateResult = SettingsAdapter.UpdateAudioFormat(settings, format); - if (updateResult.IsError) - { - return Result.Fail(updateResult.ErrorValue); - } - - settings = updateResult.ResultValue; - printer.Info( - SummarizeUpdate("Audio Formats", string.Join(", ", settings.AudioFormats)) - ); - return Result.Ok(NextAction.Continue); - } - - if ( - command.StartsWith( - Commands.UpdateAudioQualityPrefix, - StringComparison.InvariantCultureIgnoreCase - ) - ) - { - var inputQuality = command.Replace(Commands.UpdateAudioQualityPrefix, string.Empty); - - if (inputQuality == string.Empty) - { - return Result.Fail($"You must enter a number representing an audio quality."); - } - - if (!byte.TryParse(inputQuality, out var quality)) - { - return Result.Fail($"\"{inputQuality}\" is an invalid quality value."); - } - - var updateResult = SettingsAdapter.UpdateAudioQuality(settings, quality); - if (updateResult.IsError) - { - return Result.Fail(updateResult.ErrorValue); // For out-of-range values - } - - settings = updateResult.ResultValue; - printer.Info(SummarizeUpdate("Audio Quality", settings.AudioQuality.ToString())); - return Result.Ok(NextAction.Continue); - } - - return Result.Fail( - $"\"{command}\" is not a valid command. Enter \"\\commands\" to see a list of commands." - ); - } - - private static void SummarizeInput( - ImmutableArray categorizedInputs, - CategoryCounts counts, - Printer printer - ) - { - if (categorizedInputs.Length > 1) - { - var urlSummary = counts[InputCategory.Url] switch - { - 1 => "1 URL", - > 1 => $"{counts[InputCategory.Url]} URLs", - _ => string.Empty, - }; - - var commandSummary = counts[InputCategory.Command] switch - { - 1 => "1 command", - > 1 => $"{counts[InputCategory.Command]} commands", - _ => string.Empty, - }; - - var connector = - urlSummary.HasText() && commandSummary.HasText() ? " and " : string.Empty; - - printer.Info($"Batch of {urlSummary}{connector}{commandSummary} entered."); - - foreach (CategorizedInput input in categorizedInputs) - { - printer.Info($" • {input.Text}"); - } - Printer.EmptyLines(1); - } - } - - private static void Sleep(ushort sleepSeconds) - { - ushort remainingSeconds = sleepSeconds; - - AnsiConsole - .Status() - .Start( - $"Sleeping for {sleepSeconds} seconds...", - ctx => - { - ctx.Spinner(Spinner.Known.Star); - ctx.SpinnerStyle(Style.Parse("blue")); - - while (remainingSeconds > 0) - { - ctx.Status($"Sleeping for {remainingSeconds} seconds..."); - remainingSeconds--; - Thread.Sleep(1000); - } - } - ); - } - - /// - /// Actions that inform the program what it should do after the current step is done. - /// - private enum NextAction : byte - { - /// - /// Program execution should continue and not end. - /// - Continue, - - /// - /// Program execution should end at the user's request. - /// - QuitAtUserRequest, - - /// - /// Program execution should end due to an inability to continue. - /// - QuitDueToErrors, - } -} diff --git a/src/CCVTAC.Console/PostProcessing/CollectionMetadata.cs b/src/CCVTAC.Console/PostProcessing/CollectionMetadata.cs deleted file mode 100644 index b79008ef..00000000 --- a/src/CCVTAC.Console/PostProcessing/CollectionMetadata.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json.Serialization; - -namespace CCVTAC.Console.PostProcessing; - -/// -/// Represents the JSON file of a YouTube playlist or channel. (Both contain -/// the fields that I use, so this is a combined object for now. This might -/// be changed in the future, though.) -/// -public readonly record struct CollectionMetadata( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("availability")] string Availability, - [property: JsonPropertyName("description")] string Description, - [property: JsonPropertyName("tags")] IReadOnlyList Tags, - [property: JsonPropertyName("modified_date")] string ModifiedDate, - [property: JsonPropertyName("view_count")] int? ViewCount, - [property: JsonPropertyName("playlist_count")] int? PlaylistCount, - [property: JsonPropertyName("channel")] string Channel, - [property: JsonPropertyName("channel_id")] string ChannelId, - [property: JsonPropertyName("uploader_id")] string UploaderId, - [property: JsonPropertyName("uploader")] string Uploader, - [property: JsonPropertyName("channel_url")] string ChannelUrl, - [property: JsonPropertyName("uploader_url")] string UploaderUrl, - [property: JsonPropertyName("_type")] string Type, - [property: JsonPropertyName("webpage_url")] string WebpageUrl, - [property: JsonPropertyName("webpage_url_basename")] string WebpageUrlBasename, - [property: JsonPropertyName("webpage_url_domain")] string WebpageUrlDomain, - [property: JsonPropertyName("epoch")] int? Epoch -); diff --git a/src/CCVTAC.Console/PostProcessing/Deleter.cs b/src/CCVTAC.Console/PostProcessing/Deleter.cs deleted file mode 100644 index f0d7a300..00000000 --- a/src/CCVTAC.Console/PostProcessing/Deleter.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.IO; - -namespace CCVTAC.Console.PostProcessing; - -internal static class Deleter -{ - internal static void Run( - IReadOnlyCollection taggingSetFileNames, - CollectionMetadata? collectionMetadata, - string workingDirectory, - Printer printer - ) - { - ImmutableList collectionFileNames; - var getFileResult = GetCollectionFiles(collectionMetadata, workingDirectory); - if (getFileResult.IsSuccess) - { - var files = getFileResult.Value; - collectionFileNames = files; - - printer.Debug($"Found {files.Count} collection files."); - } - else - { - collectionFileNames = []; - printer.Warning(getFileResult.Errors.First().Message); - } - - var allFileNames = taggingSetFileNames.Concat(collectionFileNames).ToList(); - - if (allFileNames.Count == 0) - { - printer.Warning("No files to delete were found."); - return; - } - - printer.Debug($"Deleting {allFileNames.Count} temporary files..."); - - DeleteAll(allFileNames, printer); - - printer.Info("Deleted temporary files."); - } - - private static Result> GetCollectionFiles( - CollectionMetadata? collectionMetadata, - string workingDirectory - ) - { - if (collectionMetadata is null) - return Result.Ok(ImmutableList.Empty); - - try - { - var id = collectionMetadata.Value.Id; - return Directory.GetFiles(workingDirectory, $"*{id}*").ToImmutableList(); - } - catch (Exception ex) - { - return Result.Fail($"Error collecting filenames: {ex.Message}"); - } - } - - private static void DeleteAll(IEnumerable fileNames, Printer printer) - { - foreach (var fileName in fileNames) - { - try - { - File.Delete(fileName); - - printer.Debug($"• Deleted \"{fileName}\""); - } - catch (Exception ex) - { - printer.Error($"• Deletion error: {ex.Message}"); - } - } - } -} diff --git a/src/CCVTAC.Console/PostProcessing/ImageProcessor.cs b/src/CCVTAC.Console/PostProcessing/ImageProcessor.cs deleted file mode 100644 index c690d57e..00000000 --- a/src/CCVTAC.Console/PostProcessing/ImageProcessor.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CCVTAC.Console.ExternalTools; - -namespace CCVTAC.Console.PostProcessing; - -internal static class ImageProcessor -{ - internal static readonly string ProgramName = "mogrify"; - - internal static void Run(string workingDirectory, Printer printer) - { - ToolSettings imageEditToolSettings = new($"{ProgramName} -trim -fuzz 10% *.jpg", workingDirectory); - - Runner.Run(imageEditToolSettings, [], printer); - } -} diff --git a/src/CCVTAC.Console/PostProcessing/Mover.cs b/src/CCVTAC.Console/PostProcessing/Mover.cs deleted file mode 100644 index 63ff2109..00000000 --- a/src/CCVTAC.Console/PostProcessing/Mover.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System.IO; -using System.Text.Json; -using System.Text.RegularExpressions; -using CCVTAC.Console.PostProcessing.Tagging; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.PostProcessing; - -internal static class Mover -{ - private static readonly Regex PlaylistImageRegex = new(@"\[[OP]L[\w\d_-]{12,}\]"); - private const string ImageFileWildcard = "*.jp*"; - - internal static void Run( - IEnumerable taggingSets, - CollectionMetadata? maybeCollectionData, - UserSettings settings, - bool overwrite, - Printer printer - ) - { - Watch watch = new(); - - var workingDirInfo = new DirectoryInfo(settings.WorkingDirectory); - - string subFolderName = GetSafeSubDirectoryName(maybeCollectionData, taggingSets.First()); - string collectionName = maybeCollectionData?.Title ?? string.Empty; - string fullMoveToDir = Path.Combine( - settings.MoveToDirectory, - subFolderName, - collectionName - ); - - var dirResult = EnsureDirectoryExists(fullMoveToDir, printer); - if (dirResult.IsFailed) - { - return; // The error message is printed within the above method. - } - - var audioFileNames = workingDirInfo - .EnumerateFiles() - .Where(f => PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)) - .ToImmutableList(); - - if (audioFileNames.IsEmpty) - { - printer.Error("No audio filenames to move found."); - return; - } - - printer.Debug($"Moving {audioFileNames.Count} audio file(s) to \"{fullMoveToDir}\"..."); - - var (successCount, failureCount) = MoveAudioFiles( - audioFileNames, - fullMoveToDir, - overwrite, - printer - ); - - MoveImageFile( - collectionName, - subFolderName, - workingDirInfo, - fullMoveToDir, - audioFileNames.Count, - overwrite, - printer - ); - - var fileLabel = successCount == 1 ? "file" : "files"; - printer.Info($"Moved {successCount} audio {fileLabel} in {watch.ElapsedFriendly}."); - - if (failureCount > 0) - { - fileLabel = failureCount == 1 ? "file" : "files"; - printer.Warning($"However, {failureCount} audio {fileLabel} could not be moved."); - } - } - - private static bool IsPlaylistImage(string fileName) - { - return PlaylistImageRegex.IsMatch(fileName); - } - - private static FileInfo? GetCoverImage(DirectoryInfo workingDirInfo, int audioFileCount) - { - var images = workingDirInfo.EnumerateFiles(ImageFileWildcard).ToImmutableArray(); - if (images.IsEmpty) - { - return null; - } - - var playlistImages = images.Where(i => IsPlaylistImage(i.FullName)); - if (playlistImages.Any()) - { - return playlistImages.First(); - } - - return audioFileCount > 1 && images.Length == 1 ? images.First() : null; - } - - private static Result EnsureDirectoryExists(string moveToDir, Printer printer) - { - try - { - if (Path.Exists(moveToDir)) - { - printer.Debug($"Found move-to directory \"{moveToDir}\"."); - - return Result.Ok(); - } - - printer.Debug( - $"Creating move-to directory \"{moveToDir}\" (based on playlist metadata)... ", - appendLineBreak: false - ); - - Directory.CreateDirectory(moveToDir); - printer.Debug("OK."); - return Result.Ok(); - } - catch (Exception ex) - { - printer.Error($"Error creating move-to directory \"{moveToDir}\": {ex.Message}"); - return Result.Fail(string.Empty); - } - } - - private static (uint successCount, uint failureCount) MoveAudioFiles( - ICollection audioFiles, - string moveToDir, - bool overwrite, - Printer printer - ) - { - uint successCount = 0; - uint failureCount = 0; - - foreach (FileInfo file in audioFiles) - { - try - { - File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite); - - successCount++; - - printer.Debug($"• Moved \"{file.Name}\""); - } - catch (Exception ex) - { - failureCount++; - printer.Error($"• Error moving file \"{file.Name}\": {ex.Message}"); - } - } - - return (successCount, failureCount); - } - - private static void MoveImageFile( - string maybeCollectionName, - string subFolderName, - DirectoryInfo workingDirInfo, - string moveToDir, - int audioFileCount, - bool overwrite, - Printer printer - ) - { - try - { - var baseFileName = string.IsNullOrWhiteSpace(maybeCollectionName) - ? subFolderName - : $"{subFolderName} - {maybeCollectionName.ReplaceInvalidPathChars()}"; - - if (GetCoverImage(workingDirInfo, audioFileCount) is not FileInfo image) - { - return; - } - - image.MoveTo( - Path.Combine(moveToDir, $"{baseFileName.Trim()}.jpg"), - overwrite: overwrite - ); - - printer.Info("Moved image file."); - } - catch (Exception ex) - { - printer.Warning($"Error copying the image file: {ex.Message}"); - } - } - - private static string GetSafeSubDirectoryName( - CollectionMetadata? collectionData, - TaggingSet taggingSet - ) - { - string workingName; - - if ( - collectionData is CollectionMetadata metadata - && metadata.Uploader.HasText() - && metadata.Title.HasText() - ) - { - workingName = metadata.Uploader; - } - else - { - var jsonResult = GetParsedVideoJson(taggingSet); - - workingName = jsonResult.IsSuccess ? jsonResult.Value.Uploader : string.Empty; - } - - var safeName = workingName.ReplaceInvalidPathChars().Trim(); - - const string topicSuffix = " - Topic"; // Official channels append this to uploader names. - return safeName.EndsWith(topicSuffix) - ? safeName.Replace(topicSuffix, string.Empty) - : safeName; - } - - private static Result GetParsedVideoJson(TaggingSet taggingSet) - { - string json; - try - { - json = File.ReadAllText(taggingSet.JsonFilePath); - } - catch (Exception ex) - { - return Result.Fail( - $"Error reading JSON file \"{taggingSet.JsonFilePath}\": {ex.Message}." - ); - } - - try - { - var videoData = JsonSerializer.Deserialize(json); - return Result.Ok(videoData); - } - catch (JsonException ex) - { - return Result.Fail( - $"Error deserializing JSON from file \"{taggingSet.JsonFilePath}\": {ex.Message}" - ); - } - } -} diff --git a/src/CCVTAC.Console/PostProcessing/PostProcessing.cs b/src/CCVTAC.Console/PostProcessing/PostProcessing.cs deleted file mode 100644 index f4ddcb80..00000000 --- a/src/CCVTAC.Console/PostProcessing/PostProcessing.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.IO; -using System.Text.Json; -using System.Text.RegularExpressions; -using CCVTAC.Console.IoUtilities; -using CCVTAC.Console.PostProcessing.Tagging; -using static CCVTAC.FSharp.Downloading; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.PostProcessing; - -internal static partial class PostProcessor -{ - internal static readonly string[] AudioExtensions = - [ - ".aac", - ".alac", - ".flac", - ".m4a", - ".mp3", - ".ogg", - ".vorbis", - ".opus", - ".wav", - ]; - - internal static void Run(UserSettings settings, MediaType mediaType, Printer printer) - { - Watch watch = new(); - string workingDirectory = settings.WorkingDirectory; - - printer.Info("Starting post-processing..."); - - var taggingSetsResult = GenerateTaggingSets(workingDirectory); - if (taggingSetsResult.IsFailed) - { - printer.Error("No tagging sets were generated, so tagging cannot be done."); - return; - } - var taggingSets = taggingSetsResult.Value; - - var collectionJsonResult = GetCollectionJson(workingDirectory); - CollectionMetadata? collectionJson; - if (collectionJsonResult.IsFailed) - { - printer.Debug( - $"No playlist or channel metadata found: {collectionJsonResult.Errors.First().Message}" - ); - collectionJson = null; - } - else - { - printer.Debug("Found playlist/channel metadata."); - collectionJson = collectionJsonResult.Value; - } - - if (settings.EmbedImages) - { - ImageProcessor.Run(workingDirectory, printer); - } - - var tagResult = Tagger.Run(settings, taggingSets, collectionJson, mediaType, printer); - if (tagResult.IsSuccess) - { - printer.Info(tagResult.Value); - - Renamer.Run(settings, workingDirectory, printer); - - Mover.Run(taggingSets, collectionJson, settings, true, printer); - - var taggingSetFileNames = taggingSets.SelectMany(set => set.AllFiles).ToList(); - Deleter.Run(taggingSetFileNames, collectionJson, workingDirectory, printer); - - var leftoverFilesResult = Directories.WarnIfAnyFiles(workingDirectory, 20); - if (leftoverFilesResult.IsFailed) - { - printer.FirstError(leftoverFilesResult); - - printer.Info("Will delete the remaining files..."); - var deleteResult = Directories.DeleteAllFiles(workingDirectory, 20); - if (deleteResult.IsSuccess) - { - printer.Info($"{deleteResult.Value} file(s) deleted."); - } - else - { - printer.FirstError(deleteResult); - } - } - } - else - { - printer.Errors("Tagging error(s) preventing further post-processing: ", tagResult); - } - - printer.Info($"Post-processing done in {watch.ElapsedFriendly}."); - } - - private static Result GetCollectionJson(string workingDirectory) - { - try - { - var fileNames = Directory - .GetFiles(workingDirectory) - .Where(f => CollectionMetadataRegex().IsMatch(f)) - .ToImmutableHashSet(); - - if (fileNames.Count == 0) - { - return Result.Fail("No relevant files found."); - } - - if (fileNames.Count > 1) - { - return Result.Fail( - "Unexpectedly found more than one relevant file, so none will be processed." - ); - } - - string fileName = fileNames.Single(); - string json = File.ReadAllText(fileName); - var collectionData = JsonSerializer.Deserialize(json); - - return Result.Ok(collectionData); - } - catch (Exception ex) - { - return Result.Fail($"{ex.Message}"); - } - } - - private static Result> GenerateTaggingSets(string directory) - { - try - { - string[] files = Directory.GetFiles(directory); - var taggingSets = TaggingSet.CreateSets(files); - - return taggingSets.Any() - ? Result.Ok(taggingSets) - : Result.Fail( - $"No tagging sets were created using working directory \"{directory}\"." - ); - } - catch (DirectoryNotFoundException) - { - return Result.Fail($"Directory \"{directory}\" does not exist."); - } - catch (Exception ex) - { - return Result.Fail($"Error reading working directory files: {ex.Message}"); - } - } - - /// - /// A regular expression that detects metadata files for collections. - /// - [GeneratedRegex("""(?<=\[)[\w\-]{17,}(?=\]\.info.json)""")] - private static partial Regex CollectionMetadataRegex(); -} diff --git a/src/CCVTAC.Console/PostProcessing/Renamer.cs b/src/CCVTAC.Console/PostProcessing/Renamer.cs deleted file mode 100644 index 7386c98a..00000000 --- a/src/CCVTAC.Console/PostProcessing/Renamer.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.PostProcessing; - -internal static class Renamer -{ - public static void Run(UserSettings settings, string workingDirectory, Printer printer) - { - Watch watch = new(); - - var workingDirInfo = new DirectoryInfo(workingDirectory); - var audioFiles = workingDirInfo - .EnumerateFiles() - .Where(f => PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)) - .ToImmutableList(); - - if (audioFiles.None()) - { - printer.Warning("No audio files to rename were found."); - return; - } - - printer.Debug($"Renaming {audioFiles.Count} audio file(s)..."); - - Regex regex; - List matches; - string matchedPatternSummary; - - foreach (FileInfo file in audioFiles) - { - var newFileName = settings.RenamePatterns.Aggregate( - new StringBuilder(file.Name), - (newNameSb, renamePattern) => - { - regex = new Regex(renamePattern.RegexPattern); - - // A match is generated for each instance of the matched substring. - matches = regex - .Matches(newNameSb.ToString()) - .Where(m => m.Success) - .Reverse() // Avoids index errors. - .ToList(); - - if (matches.Count == 0) - { - return newNameSb; - } - - if (!settings.QuietMode) - { - matchedPatternSummary = renamePattern.Summary is null - ? $"`{renamePattern.RegexPattern}` (no description)" - : $"\"{renamePattern.Summary}\""; - - printer.Debug( - $"Rename pattern {matchedPatternSummary} matched × {matches.Count}." - ); - } - - foreach (Match match in matches) - { - // Delete the matched substring from the filename by index. - newNameSb.Remove(match.Index, match.Length); - - // Generate replacement text to be inserted at the same starting index - // using the matches and the replacement patterns from the settings. - var replacementText = match - .Groups.OfType() - // `Select()` indexing begins at 0, but usable regex matches begin at 1, - // so add 1 to both the match group and replacement placeholder indices. - .Select( - (_, i) => - ( - SearchFor: $"%<{i + 1}>s", // Start with placeholder #1 because... - ReplaceWith: match.Groups[i + 1].Value.Trim() // ...we start with regex group #1. - ) - ) - // Starting with the placeholder text from the settings, replace each - // individual placeholder with the correlated match text, then - // return the final string. - .Aggregate( - new StringBuilder(renamePattern.ReplaceWithPattern), - (workingText, replacementParts) => - workingText.Replace( - replacementParts.SearchFor, - replacementParts.ReplaceWith - ), - workingText => workingText.ToString() - ); - - newNameSb.Insert(match.Index, replacementText); - } - - return newNameSb; - }, - newFileNameSb => newFileNameSb.ToString() - ); - - try - { - File.Move( - file.FullName, - Path.Combine(workingDirectory, newFileName) - .Normalize(GetNormalizationForm(settings.NormalizationForm)) - ); - - printer.Debug($"• From: \"{file.Name}\""); - printer.Debug($" To: \"{newFileName}\""); - } - catch (Exception ex) - { - printer.Error($"• Error renaming \"{file.Name}\": {ex.Message}"); - } - } - - printer.Info($"Renaming done in {watch.ElapsedFriendly}."); - } - - private static NormalizationForm GetNormalizationForm(string form) => - form.Trim().ToUpperInvariant() switch - { - "D" => NormalizationForm.FormD, - "KD" => NormalizationForm.FormKD, - "KC" => NormalizationForm.FormKC, - _ => NormalizationForm.FormC, - }; -} diff --git a/src/CCVTAC.Console/PostProcessing/Tagging/Detectors.cs b/src/CCVTAC.Console/PostProcessing/Tagging/Detectors.cs deleted file mode 100644 index 0ed5e786..00000000 --- a/src/CCVTAC.Console/PostProcessing/Tagging/Detectors.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Text.RegularExpressions; -using static CCVTAC.FSharp.Settings; - -namespace CCVTAC.Console.PostProcessing.Tagging; - -/// -/// Detection of specific text within video metadata files. -/// -internal static class Detectors -{ - /// - /// Finds and returns the first instance of text matching a given detection scheme pattern, - /// parsing into T if necessary. - /// - /// A match of type T if there was a match; otherwise, the default value provided. - internal static T? DetectSingle( - VideoMetadata videoMetadata, - IEnumerable patterns, - T? defaultValue - ) - { - foreach (TagDetectionPattern pattern in patterns) - { - string fieldText = ExtractMetadataText(videoMetadata, pattern.SearchField); - - // TODO: Instantiate regexes during settings deserialization. - var match = new Regex(pattern.RegexPattern).Match(fieldText); - - if (!match.Success) - { - continue; - } - - string matchedText = match.Groups[pattern.MatchGroup].Value.Trim(); - return Cast(matchedText, defaultValue); - } - - return defaultValue; // No matches were found. - } - - /// - /// Finds and returns all instances of text matching a given detection scheme pattern, - /// concatenating them into a single string (using a custom separator), then casting - /// to type T if necessary. - /// - /// A match of type T if there were any matches; otherwise, the default value provided. - internal static T? DetectMultiple( - VideoMetadata data, - IEnumerable patterns, - T? defaultValue, - string separator - ) - { - HashSet matchedValues = []; - - foreach (TagDetectionPattern pattern in patterns) - { - string fieldText = ExtractMetadataText(data, pattern.SearchField); - - // TODO: Instantiate regexes during settings deserialization. - var matches = new Regex(pattern.RegexPattern).Matches(fieldText); - - foreach (Match match in matches.Where(m => m.Success)) - { - matchedValues.Add(match.Groups[pattern.MatchGroup].Value.Trim()); - } - } - - if (matchedValues.Count == 0) - { - return defaultValue; - } - - string joinedMatchedText = string.Join(separator, matchedValues); - return Cast(joinedMatchedText, defaultValue); - } - - /// - /// Attempts casting the input text to type T and returning it. - /// If casting fails, the default value is returned instead. - /// - private static T? Cast(string? text, T? defaultValue) - { - if (text is T) - { - return (T)(object)text; - } - - try - { - return (T?)Convert.ChangeType(text, typeof(T)); - } - catch (InvalidCastException) - { - return defaultValue; - } - } - - /// - /// Extracts the value of the specified tag field from the given data. - /// - /// - /// The name of the field within the video metadata to read. - /// The text content of the requested field of the video metadata. - private static string ExtractMetadataText(VideoMetadata metadata, string fieldName) - { - return fieldName switch - { - "title" => metadata.Title, - "description" => metadata.Description, - - // TODO: It would be best to check for invalid entries upon settings deserialization. - _ => throw new ArgumentException( - $"\"{fieldName}\" is an invalid video metadata field name." - ), - }; - } -} diff --git a/src/CCVTAC.Console/PostProcessing/Tagging/TagDetector.cs b/src/CCVTAC.Console/PostProcessing/Tagging/TagDetector.cs deleted file mode 100644 index 4e7c9957..00000000 --- a/src/CCVTAC.Console/PostProcessing/Tagging/TagDetector.cs +++ /dev/null @@ -1,45 +0,0 @@ -using static CCVTAC.FSharp.Settings; - -namespace CCVTAC.Console.PostProcessing.Tagging; - -/// -/// Provides methods to search for specific tag field data (artist, album, etc.) within video metadata. -/// -internal sealed class TagDetector -{ - private TagDetectionPatterns Patterns { get; } - - internal TagDetector(TagDetectionPatterns tagDetectionPatterns) - { - Patterns = tagDetectionPatterns; - } - - internal string? DetectTitle(VideoMetadata videoData, string? defaultTitle = null) - { - return Detectors.DetectSingle(videoData, Patterns.Title, null) ?? defaultTitle; - } - - internal string? DetectArtist(VideoMetadata videoData, string? defaultArtist = null) - { - return Detectors.DetectSingle(videoData, Patterns.Artist, null) ?? defaultArtist; - } - - internal string? DetectAlbum(VideoMetadata videoData, string? defaultAlbum = null) - { - return Detectors.DetectSingle(videoData, Patterns.Album, null) ?? defaultAlbum; - } - - internal string? DetectComposers(VideoMetadata videoData) - { - return Detectors.DetectMultiple(videoData, Patterns.Composer, null, "; "); - } - - internal ushort? DetectReleaseYear(VideoMetadata videoData, ushort? defaultYear) - { - ushort detectedYear = Detectors.DetectSingle(videoData, Patterns.Year, default); - - return detectedYear is default(ushort) // The default ushort value indicates no match was found. - ? defaultYear - : detectedYear; - } -} diff --git a/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs b/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs deleted file mode 100644 index 288028af..00000000 --- a/src/CCVTAC.Console/PostProcessing/Tagging/Tagger.cs +++ /dev/null @@ -1,302 +0,0 @@ -using System.IO; -using System.Text.Json; -using static CCVTAC.FSharp.Downloading; -using TaggedFile = TagLib.File; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.PostProcessing.Tagging; - -internal static class Tagger -{ - internal static Result Run( - UserSettings settings, - IEnumerable taggingSets, - CollectionMetadata? collectionJson, - MediaType mediaType, - Printer printer - ) - { - printer.Debug("Adding file tags..."); - - Watch watch = new(); - - bool embedImages = settings.EmbedImages && mediaType.IsVideo || mediaType.IsPlaylistVideo; - - foreach (var taggingSet in taggingSets) - { - ProcessSingleTaggingSet(settings, taggingSet, collectionJson, embedImages, printer); - } - - return Result.Ok($"Tagging done in {watch.ElapsedFriendly}."); - } - - private static void ProcessSingleTaggingSet( - UserSettings settings, - TaggingSet taggingSet, - CollectionMetadata? collectionJson, - bool embedImages, - Printer printer - ) - { - printer.Debug( - $"{taggingSet.AudioFilePaths.Count} audio file(s) with resource ID \"{taggingSet.ResourceId}\"" - ); - - var parsedJsonResult = ParseVideoJson(taggingSet); - if (parsedJsonResult.IsFailed) - { - printer.Errors( - $"Error deserializing video metadata from \"{taggingSet.JsonFilePath}\":", - parsedJsonResult - ); - return; - } - - TaggingSet finalTaggingSet = DeleteSourceFile(taggingSet, printer); - - // If a single video was split, the tagging set will have multiple audio file paths. - // In this case, we will NOT embed the image file (with the assumption that - // the standalone image file will be available in the move-to directory). - string? maybeImagePath = - embedImages && finalTaggingSet.AudioFilePaths.Count == 1 - ? finalTaggingSet.ImageFilePath - : null; - - foreach (string audioPath in finalTaggingSet.AudioFilePaths) - { - try - { - TagSingleFile( - settings, - parsedJsonResult.Value, - audioPath, - maybeImagePath, - collectionJson, - printer - ); - } - catch (Exception ex) - { - printer.Error($"Error tagging file: {ex.Message}"); - } - } - } - - private static void TagSingleFile( - UserSettings settings, - VideoMetadata videoData, - string audioFilePath, - string? imageFilePath, - CollectionMetadata? collectionData, - Printer printer - ) - { - var audioFileName = Path.GetFileName(audioFilePath); - - printer.Debug($"Current audio file: \"{audioFileName}\""); - - using var taggedFile = TaggedFile.Create(audioFilePath); - TagDetector tagDetector = new(settings.TagDetectionPatterns); - - if (videoData.Track is { } metadataTitle) - { - printer.Debug($"• Using metadata title \"{metadataTitle}\""); - taggedFile.Tag.Title = metadataTitle; - } - else - { - var title = tagDetector.DetectTitle(videoData, videoData.Title); - printer.Debug($"• Found title \"{title}\""); - taggedFile.Tag.Title = title; - } - - if (videoData.Artist is { } metadataArtists) - { - var firstArtist = metadataArtists.Split(", ").First(); - var diffSummary = - firstArtist == metadataArtists - ? string.Empty - : $" (extracted from \"{metadataArtists}\")"; - taggedFile.Tag.Performers = [firstArtist]; - - printer.Debug($"• Using metadata artist \"{firstArtist}\"{diffSummary}"); - } - else if (tagDetector.DetectArtist(videoData) is { } artist) - { - printer.Debug($"• Found artist \"{artist}\""); - taggedFile.Tag.Performers = [artist]; - } - - if (videoData.Album is { } metadataAlbum) - { - printer.Debug($"• Using metadata album \"{metadataAlbum}\""); - taggedFile.Tag.Album = metadataAlbum; - } - else if (tagDetector.DetectAlbum(videoData, collectionData?.Title) is { } album) - { - printer.Debug($"• Found album \"{album}\""); - taggedFile.Tag.Album = album; - } - - if (tagDetector.DetectComposers(videoData) is { } composers) - { - printer.Debug($"• Found composer(s) \"{composers}\""); - taggedFile.Tag.Composers = [composers]; - } - - if (videoData.PlaylistIndex is { } trackNo) - { - printer.Debug($"• Using playlist index of {trackNo} for track number"); - taggedFile.Tag.Track = trackNo; - } - - if (videoData.ReleaseYear is { } releaseYear) - { - printer.Debug($"• Using metadata release year \"{releaseYear}\""); - taggedFile.Tag.Year = releaseYear; - } - else - { - ushort? maybeDefaultYear = GetAppropriateReleaseDateIfAny(settings, videoData); - - if (tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) is { } year) - { - printer.Debug($"• Found year \"{year}\""); - taggedFile.Tag.Year = year; - } - } - - taggedFile.Tag.Comment = videoData.GenerateComment(collectionData); - - if ( - settings.EmbedImages - && !settings.DoNotEmbedImageUploaders.Contains(videoData.Uploader) - && imageFilePath is not null - ) - { - printer.Info("Embedding artwork."); - WriteImage(taggedFile, imageFilePath, printer); - } - else - { - printer.Debug("Skipping artwork embedding."); - } - - taggedFile.Save(); - printer.Debug($"Wrote tags to \"{audioFileName}\"."); - return; - - // If the supplied video uploader is specified in the settings, returns the video's upload year. - // Otherwise, returns null. - static ushort? GetAppropriateReleaseDateIfAny( - UserSettings settings, - VideoMetadata videoData - ) - { - if ( - settings.IgnoreUploadYearUploaders?.Contains( - videoData.Uploader, - StringComparer.OrdinalIgnoreCase - ) == true - ) - { - return null; - } - - return ushort.TryParse(videoData.UploadDate[..4], out var parsedYear) - ? parsedYear - : null; - } - } - - private static Result ParseVideoJson(TaggingSet taggingSet) - { - string json; - - try - { - json = File.ReadAllText(taggingSet.JsonFilePath); - } - catch (Exception ex) - { - return Result.Fail( - $"Error reading JSON file \"{taggingSet.JsonFilePath}\": {ex.Message}." - ); - } - - try - { - var videoData = JsonSerializer.Deserialize(json); - return Result.Ok(videoData); - } - catch (JsonException ex) - { - return Result.Fail($"{ex.Message}{Environment.NewLine}{ex.StackTrace}"); - } - } - - /// - /// Deletes the pre-split source audio for split videos (if any), each of which will have the same resource ID. - /// Returns a modified TaggingSet with the deleted files removed. - /// - /// - /// - private static TaggingSet DeleteSourceFile(TaggingSet taggingSet, Printer printer) - { - // If there is only one file, then there are no child files, so no action is necessary. - if (taggingSet.AudioFilePaths.Count <= 1) - { - return taggingSet; - } - - // The largest audio file must be the source file. - var largestFileInfo = taggingSet - .AudioFilePaths.Select(fileName => new FileInfo(fileName)) - .OrderByDescending(fi => fi.Length) - .First(); - - try - { - File.Delete(largestFileInfo.FullName); - printer.Debug($"Deleted pre-split source file \"{largestFileInfo.Name}\""); - - return taggingSet with - { - AudioFilePaths = taggingSet.AudioFilePaths.Remove(largestFileInfo.FullName), - }; - } - catch (Exception ex) - { - printer.Error( - $"Error deleting pre-split source file \"{largestFileInfo.Name}\": {ex.Message}" - ); - return taggingSet; - } - } - - /// - /// Write the video thumbnail to the file tags. - /// - /// Heavily inspired by https://stackoverflow.com/a/61264720/11767771. - private static void WriteImage(TaggedFile taggedFile, string imageFilePath, Printer printer) - { - if (string.IsNullOrWhiteSpace(imageFilePath)) - { - printer.Error("No image file path was provided, so cannot add an image to the file."); - return; - } - - try - { - var pics = new TagLib.IPicture[1]; - pics[0] = new TagLib.Picture(imageFilePath); - taggedFile.Tag.Pictures = pics; - - printer.Debug("Image written to file tags OK."); - } - catch (Exception ex) - { - printer.Error($"Error writing image to the audio file: {ex.Message}"); - } - } -} diff --git a/src/CCVTAC.Console/PostProcessing/Tagging/TaggingSet.cs b/src/CCVTAC.Console/PostProcessing/Tagging/TaggingSet.cs deleted file mode 100644 index e1b36730..00000000 --- a/src/CCVTAC.Console/PostProcessing/Tagging/TaggingSet.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; - -namespace CCVTAC.Console.PostProcessing.Tagging; - -/// -/// Contains all the data necessary for tagging a related set of files. -/// -/// -/// Files are "related" if they share the same resource ID. Generally, only a single downloaded video -/// has a certain video ID, but if the split-chapter option is used, all the child videos that were -/// split out will also have the same resource ID. -/// -internal readonly record struct TaggingSet -{ - /// - /// The ID of a single video and perhaps its child videos (if "split chapters" was used). - /// Used to locate all the related files (whose filenames will contain the same ID). - /// - internal string ResourceId { get; } - - /// - /// All audio files for the associated resource ID. Several files with identical IDs indicates - /// that the original video was split into several audio files. - /// - internal ImmutableHashSet AudioFilePaths { get; init; } - - /// - /// The path to the JSON file containing metadata related to the source video. - /// - internal string JsonFilePath { get; } - - /// - /// The path to the image file containing the thumbnail related to the source video. - /// - internal string ImageFilePath { get; } - - internal IReadOnlyList AllFiles => [.. AudioFilePaths, JsonFilePath, ImageFilePath]; - - /// - /// A regex that finds all files whose filename includes a video ID. - /// Group 1 contains the video ID itself. - /// - private static readonly Regex FileNamesWithVideoIdsRegex = new( - @".+\[([\w_\-]{11})\](?:.*)?\.(\w+)" - ); - - private TaggingSet( - string resourceId, - ICollection audioFilePaths, - string jsonFilePath, - string imageFilePath - ) - { - if (string.IsNullOrWhiteSpace(resourceId)) - throw new ArgumentException("The resource ID must be provided."); - if (string.IsNullOrWhiteSpace(jsonFilePath)) - throw new ArgumentException("The JSON file path must be provided."); - if (string.IsNullOrWhiteSpace(imageFilePath)) - throw new ArgumentException("The image file path must be provided."); - if (audioFilePaths.Count == 0) - throw new ArgumentException( - "At least one audio file path must be provided.", - nameof(audioFilePaths) - ); - - ResourceId = resourceId.Trim(); - AudioFilePaths = audioFilePaths.ToImmutableHashSet(); - JsonFilePath = jsonFilePath.Trim(); - ImageFilePath = imageFilePath.Trim(); - } - - /// - /// Create a collection of TaggingSets from a collection of file paths - /// related to several video IDs. Files that don't match the requirements - /// will be ignored. - /// - /// - /// A collection of file paths. Expected to contain all related audio files (>=1), - /// 1 JSON file, and 1 image file for each distinct video ID. - /// - /// Does not include collection (playlist or channel) metadata files. - internal static ImmutableList CreateSets(ICollection filePaths) - { - if (filePaths.None()) - { - return Enumerable.Empty().ToImmutableList(); - } - - const string jsonFileExt = ".json"; - const string imageFileExt = ".jpg"; - - return filePaths - // First, get regex matches of all files whose filenames contain a video ID regex. - .Select(f => FileNamesWithVideoIdsRegex.Match(f)) - .Where(m => m.Success) - .Select(m => m.Captures.OfType().First()) - // Then, group those files as key-value pairs using the video ID as the key. - .GroupBy( - m => m.Groups[1].Value, // Video ID - m => m.Groups[0].Value // Full filenames (1 or more for each video ID) - ) - // Next, ensure the correct count of image and JSON files, ignoring those that don't match. - // (For thought: It might be an option to track and report the invalid ones as well.) - .Where(gr => - gr.Any(f => - PostProcessor.AudioExtensions.CaseInsensitiveContains(Path.GetExtension(f)) - ) - && gr.Count(f => f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) == 1 - && gr.Count(f => f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) == 1 - ) - // Lastly, group everything into new TaggingSets. - .Select(gr => - { - return new TaggingSet( - gr.Key, // Video ID - gr.Where(f => - PostProcessor.AudioExtensions.CaseInsensitiveContains( - Path.GetExtension(f) - ) - ) - .ToList(), - gr.Single(f => f.EndsWith(jsonFileExt)), - gr.Single(f => f.EndsWith(imageFileExt)) - ); - }) - .ToImmutableList(); - } -} diff --git a/src/CCVTAC.Console/PostProcessing/VideoMetadata.cs b/src/CCVTAC.Console/PostProcessing/VideoMetadata.cs deleted file mode 100644 index 90ef5860..00000000 --- a/src/CCVTAC.Console/PostProcessing/VideoMetadata.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text.Json.Serialization; - -namespace CCVTAC.Console.PostProcessing; - -/// -/// Represents the data containing the JSON file downloaded alongside the video. -/// -public readonly record struct VideoMetadata( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("thumbnail")] string Thumbnail, - [property: JsonPropertyName("description")] string Description, - [property: JsonPropertyName("channel_id")] string ChannelId, - [property: JsonPropertyName("channel_url")] string ChannelUrl, - [property: JsonPropertyName("duration")] int? Duration, - [property: JsonPropertyName("view_count")] int? ViewCount, - [property: JsonPropertyName("age_limit")] int? AgeLimit, - [property: JsonPropertyName("webpage_url")] string WebpageUrl, - [property: JsonPropertyName("categories")] IReadOnlyList Categories, - [property: JsonPropertyName("tags")] IReadOnlyList Tags, - [property: JsonPropertyName("playable_in_embed")] bool? PlayableInEmbed, - [property: JsonPropertyName("live_status")] string LiveStatus, - [property: JsonPropertyName("release_timestamp")] int? ReleaseTimestamp, - [property: JsonPropertyName("_format_sort_fields")] IReadOnlyList FormatSortFields, - [property: JsonPropertyName("album")] string Album, - [property: JsonPropertyName("artist")] string Artist, - [property: JsonPropertyName("track")] string Track, - [property: JsonPropertyName("comment_count")] int? CommentCount, - [property: JsonPropertyName("like_count")] int? LikeCount, - [property: JsonPropertyName("channel")] string Channel, - [property: JsonPropertyName("channel_follower_count")] int? ChannelFollowerCount, - [property: JsonPropertyName("channel_is_verified")] bool? ChannelIsVerified, - [property: JsonPropertyName("uploader")] string Uploader, - [property: JsonPropertyName("uploader_id")] string UploaderId, - [property: JsonPropertyName("uploader_url")] string UploaderUrl, - [property: JsonPropertyName("upload_date")] string UploadDate, - [property: JsonPropertyName("creator")] string Creator, - [property: JsonPropertyName("alt_title")] string AltTitle, - [property: JsonPropertyName("availability")] string Availability, - [property: JsonPropertyName("webpage_url_basename")] string WebpageUrlBasename, - [property: JsonPropertyName("webpage_url_domain")] string WebpageUrlDomain, - [property: JsonPropertyName("extractor")] string Extractor, - [property: JsonPropertyName("extractor_key")] string ExtractorKey, - [property: JsonPropertyName("playlist_count")] int? PlaylistCount, - [property: JsonPropertyName("playlist")] string Playlist, - [property: JsonPropertyName("playlist_id")] string PlaylistId, - [property: JsonPropertyName("playlist_title")] string PlaylistTitle, - [property: JsonPropertyName("n_entries")] int? NEntries, - [property: JsonPropertyName("playlist_index")] uint? PlaylistIndex, - [property: JsonPropertyName("display_id")] string DisplayId, - [property: JsonPropertyName("fulltitle")] string Fulltitle, - [property: JsonPropertyName("duration_string")] string DurationString, - [property: JsonPropertyName("release_date")] string ReleaseDate, - [property: JsonPropertyName("release_year")] uint? ReleaseYear, - [property: JsonPropertyName("is_live")] bool? IsLive, - [property: JsonPropertyName("was_live")] bool? WasLive, - [property: JsonPropertyName("epoch")] int? Epoch, - [property: JsonPropertyName("asr")] int? Asr, - [property: JsonPropertyName("filesize")] int? Filesize, - [property: JsonPropertyName("format_id")] string FormatId, - [property: JsonPropertyName("format_note")] string FormatNote, - [property: JsonPropertyName("source_preference")] int? SourcePreference, - [property: JsonPropertyName("audio_channels")] int? AudioChannels, - [property: JsonPropertyName("quality")] double? Quality, - [property: JsonPropertyName("has_drm")] bool? HasDrm, - [property: JsonPropertyName("tbr")] double? Tbr, - [property: JsonPropertyName("url")] string Url, - [property: JsonPropertyName("language_preference")] int? LanguagePreference, - [property: JsonPropertyName("ext")] string Ext, - [property: JsonPropertyName("vcodec")] string Vcodec, - [property: JsonPropertyName("acodec")] string Acodec, - [property: JsonPropertyName("container")] string Container, - [property: JsonPropertyName("protocol")] string Protocol, - [property: JsonPropertyName("resolution")] string Resolution, - [property: JsonPropertyName("audio_ext")] string AudioExt, - [property: JsonPropertyName("video_ext")] string VideoExt, - [property: JsonPropertyName("vbr")] int? Vbr, - [property: JsonPropertyName("abr")] double? Abr, - [property: JsonPropertyName("format")] string Format, - [property: JsonPropertyName("_type")] string Type -); diff --git a/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs b/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs deleted file mode 100644 index 2b915aff..00000000 --- a/src/CCVTAC.Console/PostProcessing/YouTubeMetadataExtensionMethods.cs +++ /dev/null @@ -1,84 +0,0 @@ -namespace CCVTAC.Console.PostProcessing; - -public static class YouTubeMetadataExtensionMethods -{ - extension(VideoMetadata videoData) - { - /// - /// Returns a string summarizing video uploader information. - /// - private string UploaderSummary() - { - string uploaderLinkOrIdOrEmpty = - videoData.UploaderUrl.HasText() ? videoData.UploaderUrl - : videoData.UploaderId.HasText() ? videoData.UploaderId - : string.Empty; - - return videoData.Uploader - + (uploaderLinkOrIdOrEmpty.HasText() ? $" ({uploaderLinkOrIdOrEmpty})" : string.Empty); - } - - /// - /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") from the - /// plain YYYYMMDD version (e.g., "20230827") within the parsed JSON file data. - /// - private string FormattedUploadDate() - { - return $"{videoData.UploadDate[4..6]}/{videoData.UploadDate[6..8]}/{videoData.UploadDate[..4]}"; - } - - /// - /// Returns a formatted comment using data parsed from the JSON file. - /// - public string GenerateComment(CollectionMetadata? maybeCollectionData - ) - { - System.Text.StringBuilder sb = new(); - - sb.AppendLine("CCVTAC SOURCE DATA:"); - sb.AppendLine($"■ Downloaded: {DateTime.Now}"); - // sb.AppendLine($"■ Service: {videoData.ExtractorKey}"); // "Youtube" - sb.AppendLine($"■ URL: {videoData.WebpageUrl}"); - sb.AppendLine($"■ Title: {videoData.Fulltitle}"); - sb.AppendLine($"■ Uploader: {videoData.UploaderSummary()}"); - if (videoData.Creator != videoData.Uploader && videoData.Creator.HasText()) - { - sb.AppendLine($"■ Creator: {videoData.Creator}"); - } - if (videoData.Artist.HasText()) - { - sb.AppendLine($"■ Artist: {videoData.Artist}"); - } - if (videoData.Album.HasText()) - { - sb.AppendLine($"■ Album: {videoData.Album}"); - } - if (videoData.Title.HasText() && videoData.Title != videoData.Fulltitle) - { - sb.AppendLine($"■ Track Title: {videoData.Title}"); - } - sb.AppendLine($"■ Uploaded: {videoData.FormattedUploadDate()}"); - var description = string.IsNullOrWhiteSpace(videoData.Description) - ? "None." - : videoData.Description; - sb.AppendLine($"■ Video description: {description}"); - - if (maybeCollectionData is { } collectionData) - { - sb.AppendLine(); - sb.AppendLine($"■ Playlist name: {collectionData.Title}"); - sb.AppendLine($"■ Playlist URL: {collectionData.WebpageUrl}"); - if (videoData.PlaylistIndex is { } index) - { - sb.AppendLine($"■ Playlist index: {index}"); - } - if (collectionData.Description.HasText()) - { - sb.AppendLine($"■ Playlist description: {collectionData.Description}"); - } - } - - return sb.ToString(); - } - } -} diff --git a/src/CCVTAC.Console/Printer.cs b/src/CCVTAC.Console/Printer.cs deleted file mode 100644 index 085e9dd2..00000000 --- a/src/CCVTAC.Console/Printer.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Spectre.Console; - -namespace CCVTAC.Console; - -public sealed class Printer -{ - private enum Level - { - Critical, - Error, - Warning, - Info, - Debug, - } - - private record ColorFormat(string? Foreground, string? Background, bool Bold = false); - - /// - /// Color reference: https://spectreconsole.net/appendix/colors - /// - private static readonly Dictionary Colors = new() - { - { Level.Critical, new("white", "red3", true) }, - { Level.Error, new("red", null) }, - { Level.Warning, new("yellow", null) }, - { Level.Info, new(null, null) }, - { Level.Debug, new("grey70", null) }, - }; - - private Level MinimumLogLevel { get; set; } - - public Printer(bool showDebug) - { - MinimumLogLevel = showDebug ? Level.Debug : Level.Info; - } - - public void ShowDebug(bool show) - { - MinimumLogLevel = show ? Level.Debug : Level.Info; - } - - /// - /// - /// - private static string EscapeText(string text) => - text.Replace("{", "{{").Replace("}", "}}").Replace("[", "[[").Replace("]", "]]"); - - private static string AddMarkup(string message, ColorFormat colors) - { - if (colors.Foreground is null && colors.Background is null && !colors.Bold) - { - return message; - } - - var bold = colors.Bold ? "bold " : string.Empty; - var fg = colors.Foreground ?? "default"; - var bg = colors.Background is null ? string.Empty : $" on {colors.Background}"; - var markUp = $"{bold}{fg}{bg}"; - - return $"[{markUp}]{message}[/]"; - } - - private void Print( - Level logLevel, - string message, - bool appendLineBreak = true, - byte prependLines = 0, - byte appendLines = 0, - bool processMarkup = true - ) - { - if (logLevel > MinimumLogLevel) - { - return; - } - - if (string.IsNullOrWhiteSpace(message)) - { - throw new ArgumentNullException(nameof(message), "Message cannot be empty."); - } - - EmptyLines(prependLines); - - var escapedMessage = EscapeText(message); - if (processMarkup) - { - var markedUpMessage = AddMarkup(escapedMessage, Colors[logLevel]); - AnsiConsole.Markup(markedUpMessage); - } - else - { - // `AnsiConsole.Write()` calls an internal function that uses format strings, - // so we must duplicate any curly brackets to safely escape the message text. - // See https://github.com/spectreconsole/spectre.console/issues/1495. - AnsiConsole.Write(escapedMessage); - } - - if (appendLineBreak) - { - AnsiConsole.WriteLine(); - } - - EmptyLines(appendLines); - } - - public static void PrintTable(Table table) - { - AnsiConsole.Write(table); - } - - public void Critical( - string message, - bool appendLineBreak = true, - byte prependLines = 0, - byte appendLines = 0, - bool processMarkup = true - ) - { - Print(Level.Critical, message, appendLineBreak, prependLines, appendLines, processMarkup); - } - - public void Error( - string message, - bool appendLineBreak = true, - byte prependLines = 0, - byte appendLines = 0, - bool processMarkup = true - ) - { - Print(Level.Error, message, appendLineBreak, prependLines, appendLines, processMarkup); - } - - public void Errors(ICollection errors, byte appendLines = 0) - { - if (errors.Count == 0) - throw new ArgumentException("No errors were provided!", nameof(errors)); - - foreach (var error in errors.Where(e => e.HasText())) - { - Error(error); - } - - EmptyLines(appendLines); - } - - private void Errors(string headerMessage, IEnumerable errors) - { - Errors([headerMessage, .. errors]); - } - - public void Errors(Result failResult, byte appendLines = 0) - { - Errors(failResult.Errors.Select(e => e.Message).ToList(), appendLines); - } - - public void Errors(string headerMessage, Result failingResult) - { - Errors(headerMessage, failingResult.Errors.Select(e => e.Message)); - } - - public void FirstError(IResultBase failResult, string? prepend = null) - { - string pre = prepend is null ? string.Empty : $"{prepend} "; - string message = failResult.Errors?.FirstOrDefault()?.Message ?? string.Empty; - - Error($"{pre}{message}"); - } - - public void Warning( - string message, - bool appendLineBreak = true, - byte prependLines = 0, - byte appendLines = 0, - bool processMarkup = true - ) - { - Print(Level.Warning, message, appendLineBreak, prependLines, appendLines, processMarkup); - } - - public void Info( - string message, - bool appendLineBreak = true, - byte prependLines = 0, - byte appendLines = 0, - bool processMarkup = true - ) - { - Print(Level.Info, message, appendLineBreak, prependLines, appendLines, processMarkup); - } - - public void Debug( - string message, - bool appendLineBreak = true, - byte prependLines = 0, - byte appendLines = 0, - bool processMarkup = true - ) - { - Print(Level.Debug, message, appendLineBreak, prependLines, appendLines, processMarkup); - } - - /// - /// Prints the requested number of blank lines. - /// - /// - public static void EmptyLines(byte count) - { - if (count == 0) - return; - - AnsiConsole.WriteLine(string.Concat(Enumerable.Repeat(Environment.NewLine, count - 1))); - } - - public string GetInput(string prompt) - { - EmptyLines(1); - return AnsiConsole.Ask($"[skyblue1]{prompt}[/]"); - } - - private static string Ask(string title, string[] options) - { - return AnsiConsole.Prompt(new SelectionPrompt().Title(title).AddChoices(options)); - } - - public bool AskToBool(string title, string trueAnswer, string falseAnswer) => - Ask(title, [trueAnswer, falseAnswer]) == trueAnswer; -} diff --git a/src/CCVTAC.Console/Program.cs b/src/CCVTAC.Console/Program.cs deleted file mode 100644 index d7bfb1e1..00000000 --- a/src/CCVTAC.Console/Program.cs +++ /dev/null @@ -1,83 +0,0 @@ -using CCVTAC.Console.IoUtilities; -using CCVTAC.Console.Settings; -using Spectre.Console; - -namespace CCVTAC.Console; - -internal static class Program -{ - private static readonly string[] HelpFlags = ["-h", "--help"]; - private static readonly string[] SettingsFileFlags = ["-s", "--settings"]; - private const string DefaultSettingsFileName = "settings.json"; - - private static void Main(string[] args) - { - Printer printer = new(showDebug: true); - - if (args.Length > 0 && HelpFlags.CaseInsensitiveContains(args[0])) - { - Help.Print(printer); - return; - } - - string maybeSettingsPath = - args.Length >= 2 && SettingsFileFlags.CaseInsensitiveContains(args[0]) - ? args[1] // Expected to be a settings file path. - : DefaultSettingsFileName; - - var settingsResult = SettingsAdapter.ProcessSettings(maybeSettingsPath, printer); - if (settingsResult.IsFailed) - { - printer.Errors(settingsResult.Errors.Select(e => e.Message).ToList()); - return; - } - if (settingsResult.Value is null) // If a new settings file was created. - { - return; - } - - var settings = settingsResult.Value; - SettingsAdapter.PrintSummary(settings, printer, header: "Settings loaded OK."); - - printer.ShowDebug(!settings.QuietMode); - - // Catch Ctrl-C (SIGINT). - System.Console.CancelKeyPress += delegate - { - printer.Warning("\nQuitting at user's request."); - - var warnResult = Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10); - - if (warnResult.IsSuccess) - { - return; - } - - printer.FirstError(warnResult); - - var deleteResult = Directories.AskToDeleteAllFiles(settings.WorkingDirectory, printer); - if (deleteResult.IsSuccess) - { - printer.Info($"{deleteResult.Value} file(s) deleted."); - } - else - { - printer.FirstError(deleteResult); - } - }; - - // Top-level `try` block to catch and pretty-print unexpected exceptions. - try - { - Orchestrator.Start(settings, printer); - } - catch (Exception topException) - { - printer.Critical($"Fatal error: {topException.Message}"); - AnsiConsole.WriteException(topException); - printer.Info( - "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." - ); - } - } -} diff --git a/src/CCVTAC.Console/ResultTracker.cs b/src/CCVTAC.Console/ResultTracker.cs deleted file mode 100644 index 758083dc..00000000 --- a/src/CCVTAC.Console/ResultTracker.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace CCVTAC.Console; - -/// -/// Tracks the successes and failures of various operations. -/// Successes are only counted; failure error messages are tracked. -/// -internal sealed class ResultTracker -{ - private nuint _successCount; - private readonly Dictionary _failures = []; - private readonly Printer _printer; - - private static string CombineErrors(Result result) => - string.Join(" / ", result.Errors.Select(e => e.Message)); - - internal ResultTracker(Printer printer) - { - ArgumentNullException.ThrowIfNull(printer); - _printer = printer; - } - - /// - /// Logs the result for a specific corresponding input. - /// - internal void RegisterResult(string input, Result result) - { - if (result.IsSuccess) - { - _successCount++; - return; - } - - string errors = CombineErrors(result); - if (!_failures.TryAdd(input, errors)) - { - // Keep only the latest error for each input. - _failures[input] = errors; - } - } - - /// - /// Prints any failures for the current batch. - /// - internal void PrintBatchFailures() - { - if (_failures.Count == 0) - { - _printer.Debug("No failures in batch."); - return; - } - - var failureLabel = _failures.Count == 1 ? "failure" : "failures"; - _printer.Info($"{_failures.Count} {failureLabel} in this batch:"); - - foreach (var (url, error) in _failures) - { - _printer.Warning($"- {url}: {error}"); - } - } - - /// - /// Prints the output for the current application session. - /// Expected to be used upon quitting. - /// - internal void PrintSessionSummary() - { - var successLabel = _successCount == 1 ? "success" : "successes"; - var failureLabel = _failures.Count == 1 ? "failure" : "failures"; - - _printer.Info( - $"Quitting with {_successCount} {successLabel} and {_failures.Count} {failureLabel}." - ); - - foreach (var (url, error) in _failures) - { - _printer.Warning($"- {url}: {error}"); - } - } -} diff --git a/src/CCVTAC.Console/Settings/Id3Version.cs b/src/CCVTAC.Console/Settings/Id3Version.cs deleted file mode 100644 index bf038728..00000000 --- a/src/CCVTAC.Console/Settings/Id3Version.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace CCVTAC.Console.Settings; - -public sealed class TagFormat -{ - /// - /// Point versions of ID3 version 2 (such as 2.3 or 2.4). - /// - public enum Id3V2Version : byte - { - TwoPoint2 = 2, - TwoPoint3 = 3, - TwoPoint4 = 4, - } - - /// - /// Locks the ID3v2.x version to a valid one and optionally forces that version. - /// - /// The ID3 version 2 subversion to use. - /// - /// When true, forces the specified version when writing the file. - /// When false, will defer to the version within the file, if any. - /// - public static void SetId3V2Version(Id3V2Version version, bool forceAsDefault) - { - TagLib.Id3v2.Tag.DefaultVersion = (byte)version; - TagLib.Id3v2.Tag.ForceDefaultVersion = forceAsDefault; - } -} diff --git a/src/CCVTAC.Console/Settings/SettingsAdapter.cs b/src/CCVTAC.Console/Settings/SettingsAdapter.cs deleted file mode 100644 index 1fd95aa0..00000000 --- a/src/CCVTAC.Console/Settings/SettingsAdapter.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.FSharp.Core; -using Spectre.Console; -using UserSettings = CCVTAC.FSharp.Settings.UserSettings; - -namespace CCVTAC.Console.Settings; - -/// -/// Settings are managed by the corresponding F# library. This class acts as -/// the bridge between this project and the F# library. -/// -/// -/// It would be easier to do everything here in C#, but I incorporated F# in -/// PR #38 as functional programming practice. -/// -public static class SettingsAdapter -{ - private const string DefaultFileName = "settings.json"; - - /// - /// Reads settings or creates a new default settings file. - /// - /// - /// - /// - /// A Result indicating three possible conditions: - /// 1. `Ok` with successfully parsed user settings from the disk. - /// 2. `OK` with no value, indicating that a new default file was created. - /// 3. `Fail`, indicating a failure in the read or write process or in settings validation. - /// - /// This is intended to be a temporary solution until more code is moved to F#. - internal static Result ProcessSettings(string? maybeSettingsPath, Printer printer) - { - var path = FSharp.Settings.FilePath.NewFilePath(maybeSettingsPath); - - if (FSharp.Settings.IO.FileExists(path) is { IsOk: true }) - { - try - { - var result = FSharp.Settings.IO.Read(path); - - return result is { IsError: true } - ? Result.Fail($"Settings validation error: {result.ErrorValue}") - : Result.Ok(result.ResultValue); - } - catch (Exception ex) - { - return Result.Fail($"Error reading settings: {ex.Message}"); - } - } - - try - { - var result = FSharp.Settings.IO.WriteDefaultFile(path, DefaultFileName); - if (result is { IsError: true }) - { - return Result.Fail( - $"Unexpected error writing the default settings: {result.ErrorValue}" - ); - } - - printer.Info(result.ResultValue); // The new-file message. - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Error writing default settings: {ex.Message}"); - } - } - - /// - /// Returns a new Settings instance with the Split Chapters value toggled. - /// This only affects the current session; the settings file is not updated. - /// - internal static UserSettings ToggleSplitChapters(UserSettings settings) => - FSharp.Settings.LiveUpdating.ToggleSplitChapters(settings); - - /// - /// Returns a new Settings instance with the Embed Images value toggled. - /// This only affects the current session; the settings file is not updated. - /// - internal static UserSettings ToggleEmbedImages(UserSettings settings) => - FSharp.Settings.LiveUpdating.ToggleEmbedImages(settings); - - /// - /// Returns a new Settings instance with the Quiet Mode value toggled. - /// This only affects the current session; the settings file is not updated. - /// - internal static UserSettings ToggleQuietMode(UserSettings settings) => - FSharp.Settings.LiveUpdating.ToggleQuietMode(settings); - - /// - /// Returns a Result containing a new Settings instance with the Audio Format value updated, - /// or else an Error. This only affects the current session; the settings file is not updated. - /// - internal static FSharpResult UpdateAudioFormat( - UserSettings settings, - string newFormat - ) => FSharp.Settings.LiveUpdating.UpdateAudioFormat(settings, newFormat.Split(',')); - - /// - /// Returns a Result containing a new Settings instance with the Audio Quality value updated, - /// or else an Error. This only affects the current session; the settings file is not updated. - /// - internal static FSharpResult UpdateAudioQuality( - UserSettings settings, - byte newQuality - ) => FSharp.Settings.LiveUpdating.UpdateAudioQuality(settings, newQuality); - - /// - /// Prints a summary of the given settings. - /// - /// - /// - /// An optional line of text to appear above the settings. - internal static void PrintSummary(UserSettings settings, Printer printer, string? header = null) - { - if (header.HasText()) - { - printer.Info(header!); - } - - var table = new Table(); - table.Expand(); - table.Border(TableBorder.HeavyEdge); - table.BorderColor(Color.Grey27); - table.AddColumns("Name", "Value"); - table.HideHeaders(); - table.Columns[1].Width = 100; // Ensure maximum width. - - var settingPairs = FSharp.Settings.Summarize(settings); - foreach (var pair in settingPairs) - { - table.AddRow(pair.Item1, pair.Item2); - } - - Printer.PrintTable(table); - } -} diff --git a/src/CCVTAC.Console/Usings.cs b/src/CCVTAC.Console/Usings.cs deleted file mode 100644 index 3de73b43..00000000 --- a/src/CCVTAC.Console/Usings.cs +++ /dev/null @@ -1,7 +0,0 @@ -global using System; -global using System.Collections.Frozen; -global using System.Collections.Generic; -global using System.Collections.Immutable; -global using System.Linq; -global using FluentResults; -global using Startwatch.Library; diff --git a/src/CCVTAC.Console/settings.default.json b/src/CCVTAC.Console/settings.default.json deleted file mode 100644 index d28e1749..00000000 --- a/src/CCVTAC.Console/settings.default.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "workingDirectory": "", - "moveToDirectory": "", - "historyFile": "", - "historyDisplayCount": 25, - "logDirectory": "", - "audioFormats": ["m4a", "best"], - "audioQuality": 0, - "splitChapters": true, - "embedImages": true, - "quietMode": false, - "sleepSecondsBetweenDownloads": 7, - "sleepSecondsBetweenURLs": 15, - "normalizationForm": "C", - "doNotEmbedImageUploaders": [], - "ignoreUploadYearUploaders": [], - "tagDetectionPatterns": { - "title": [], - "artist": [], - "album": [], - "composer": [], - "year": [] - }, - "renamePatterns": [] -} diff --git a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs b/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs index 9a7541ef..84e62be1 100644 --- a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs +++ b/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs @@ -1,7 +1,8 @@ module DownloadEntityTests open Xunit -open CCVTAC.FSharp.Downloading +open CCVTAC.Console.Downloading +open CCVTAC.Console.Downloading.Downloading module MediaTypeWithIdsTests = let incorrectMediaType = "Incorrect media type" diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 47852341..0ca40ec2 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -3,38 +3,41 @@ net10.0 true true + enable - - - + + + + + + + - - + + + + + + + + - - - - - - - + - + - - diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index a183ec19..8a6d00a0 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -1,54 +1,173 @@ namespace CCVTAC.Console.Downloading +open CCVTAC.Console +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings open CCVTAC.Console.ExternalTools -open CCVTAC.FSharp.Settings +open System +open System.Collections.Generic +open System.Linq +open CCVTAC.Console.ExternalTools +open CCVTAC.Console.Downloading.Downloading +// open CCVTAC.FSharp.Downloading +// open CCVTAC.Console.Settings.UserSettings + +module Downloader = + + [] + let private ProgramName = "yt-dlp" + + type Urls = { Primary: string; Supplementary: string option } + + /// Generate the entire argument string for the download tool. + /// audioFormat: one of the supported audio format codes (or null for none) + /// mediaType: Some MediaType for normal downloads, None for metadata-only supplementary downloads + /// additionalArgs: optional extra args (e.g., the URL) + let GenerateDownloadArgs + (audioFormat: string option) + (settings: UserSettings) + (mediaType: MediaType option) + (additionalArgs: string[] option) + : string = + + let writeJson = "--write-info-json" + let trimFileNames = "--trim-filenames 250" + + let formatArg = + // if String.IsNullOrWhiteSpace audioFormat || audioFormat = "best" then + // String.Empty + // else + // $"-f {audioFormat}" + match audioFormat with + | None -> String.Empty + | Some format when format = "best" -> String.Empty + | Some format -> $"-f {format}" + + let args = + match mediaType with + | None -> + HashSet([ $"--flat-playlist {writeJson} {trimFileNames}" ]) + | Some _ -> + HashSet( + [ + "--extract-audio" + formatArg + $"--audio-quality {settings.AudioQuality}" + "--write-thumbnail --convert-thumbnails jpg" + writeJson + trimFileNames + "--retries 2" + ] + ) + + // quiet vs verbose + args.Add(if settings.QuietMode then "--quiet --no-warnings" else String.Empty) |> ignore + + match mediaType with + | Some mt -> + if settings.SplitChapters then args.Add("--split-chapters") |> ignore + + if not mt.IsVideo && not mt.IsPlaylistVideo then + args.Add($"--sleep-interval {settings.SleepSecondsBetweenDownloads}") |> ignore + + if mt.IsStandardPlaylist then + args.Add( + """-o "%(uploader).80B - %(playlist).80B - %(playlist_autonumber)s - %(title).150B [%(id)s].%(ext)s" --playlist-reverse""" + ) |> ignore + | None -> () + + let extras = defaultArg additionalArgs [||] + String.Join(" ", args.Concat(extras)) -/// Manages downloader updates -module Updater = - /// Represents download URLs - type private Urls = { - Primary: string - Supplementary: string option - } + let internal WrapUrlInMediaType (url: string) : Result = + mediaTypeWithIds url + // if result.IsOk then Result.Ok result.ResultValue else Result.Fail result.ErrorValue /// Completes the actual download process. - /// A `Result` that, if successful, contains the name of the successfully downloaded format. - let internal run (settings: UserSettings) (printer: Printer) = - // Check if update command is provided - if System.String.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand) then - printer.Info("No downloader update command provided, so will skip.") - Ok() - else - // Prepare tool settings - let args = ToolSettings( - settings.DownloaderUpdateCommand, - settings.WorkingDirectory - ) + /// Returns a Result that, if successful, contains the name of the successfully downloaded format. + let internal Run + (mediaType: MediaType) + (settings: UserSettings) + (printer: Printer) + : Result = + + if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then + printer.Info("Please wait for multiple videos to be downloaded...") + + let rawUrls = extractDownloadUrls(mediaType) + let urls = + { Primary = rawUrls.[0] + Supplementary = if rawUrls.Length = 2 then Some rawUrls.[1] else None } + + // Placeholder for the download result; we'll overwrite as we try formats. + let mutable downloadResult : Result = Error "" + let mutable successfulFormat : string = "" + + let mutable stopped = false + for format in settings.AudioFormats do + if not stopped then + let args = GenerateDownloadArgs (Some format) settings (Some mediaType) (Some [| urls.Primary |]) + let commandWithArgs = $"{ProgramName} {args}" + let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory + + let downloadResult = Runner.run downloadSettings [| 1 |] printer - // Run the update process - match Runner.Run(args, [||], printer) with - | Ok (exitCode, warnings) -> - // Handle successful run with potential warnings - if exitCode <> 0 then - printer.Warning("Update completed with minor issues.") + match downloadResult with + | Ok (exitCode, warning) -> + successfulFormat <- format - if not (System.String.IsNullOrEmpty(warnings)) then - printer.Warning(warnings) + if exitCode <> 0 then + printer.Warning("Downloading completed with minor issues.") + if not (String.IsNullOrWhiteSpace warning) then + printer.Warning(warning) - Ok() + stopped <- true + | Error e -> + printer.Debug(sprintf "Failure downloading \"%s\" format." format) - | Error errors -> - // Handle errors - printer.Error("Failure updating...") + // Collect error messages from the last download attempt + let mutable errors = match downloadResult with Error e -> [e] | Ok _ -> [] - // Print and process errors + let audioFileCount = IoUtilities.Directories.audioFileCount settings.WorkingDirectory + if audioFileCount = 0 then + let combined = errors - |> Array.iter (fun e -> printer.Error(e.Message)) - - // Return failure result if errors exist - if errors.Length > 0 then - Error (System.String.Join(" / ", errors |> Array.map (fun e -> e.Message))) - else - Ok() - CCVTAC.FSharp.Downloading.Downloader + |> Seq.append (seq { yield "No audio files were downloaded." }) + |> String.concat Environment.NewLine + Error combined + else + // If there were errors from the primary download attempts, print them and continue to post-processing. + if errors.Length <> 0 then + errors |> List.iter printer.Error + printer.Info("Post-processing will still be attempted.") + else + // If no errors and there is a supplementary URL, attempt a metadata-only supplementary download. + match urls.Supplementary with + | Some supp -> + let supplementaryArgs = GenerateDownloadArgs None settings None (Some [| supp |]) + let commandWithArgs = $"{ProgramName} {supplementaryArgs}" + let supplementaryDownloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory + + let supplementaryDownloadResult = + Runner.run supplementaryDownloadSettings [| 1 |] printer + + // if supplementaryDownloadResult.IsSuccess then + // printer.Info("Supplementary download completed OK.") + // else + // printer.Error("Supplementary download failed.") + // errors.AddRange(supplementaryDownloadResult.Errors.Select(fun e -> e.Message)) + match supplementaryDownloadResult with + | Ok _ -> + printer.Info("Supplementary download completed OK.") + | Error e -> + printer.Error("Supplementary download failed.") + errors <- errors |> List.append [e] + | None -> () + + if errors.Length > 0 then + Error (String.Join(" / ", errors)) + else + Ok successfulFormat + + diff --git a/src/CCVTAC.FSharp/Downloading/Downloading.fs b/src/CCVTAC.FSharp/Downloading/Downloading.fs index b11412d0..39cd4e33 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloading.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloading.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.FSharp +namespace CCVTAC.Console.Downloading module public Downloading = open System.Text.RegularExpressions diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs new file mode 100644 index 00000000..0c15e9bd --- /dev/null +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -0,0 +1,45 @@ +namespace CCVTAC.Console.Downloading + +open CCVTAC.Console.ExternalTools +open CCVTAC.Console +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings + +/// Manages downloader updates +module Updater = + /// Represents download URLs + type private Urls = { + Primary: string + Supplementary: string option + } + + /// Completes the actual download process. + /// A `Result` that, if successful, contains the name of the successfully downloaded format. + let internal run (settings: UserSettings) (printer: Printer) = + // Check if update command is provided + if System.String.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand) then + printer.Info("No downloader update command provided, so will skip.") + Ok() + else + let args : ToolSettings = { + CommandWithArgs = settings.DownloaderUpdateCommand + WorkingDirectory = settings.WorkingDirectory + } + + // Run the update process + match Runner.run args [||] printer with + | Ok (exitCode, warnings) -> + // Handle successful run with potential warnings + if exitCode <> 0 then + printer.Warning("Update completed with minor issues.") + + if not (System.String.IsNullOrEmpty(warnings)) then + printer.Warning(warnings) + + Ok() + + | Error error -> + printer.Error($"Failure updating: {error}") + Error error + + diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index f03646dc..c890257b 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -1,7 +1,10 @@ -module CCVTAC.FSharp.Downloading.Uploader +// module CCVTAC.FSharp.Downloading.Uploader +namespace CCVTAC.Console open CCVTAC.Console.ExternalTools -open CCVTAC.FSharp.Settings +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.ExternalTools module Updater = /// Represents download URLs @@ -19,13 +22,10 @@ module Updater = Ok() else // Prepare tool settings - let args = ToolSettings( - settings.DownloaderUpdateCommand, - settings.WorkingDirectory - ) + let args = ToolSettings.create settings.DownloaderUpdateCommand settings.WorkingDirectory // Run the update process - match Runner.Run(args, [||], printer) with + match Runner.run args [||] printer with | Ok (exitCode, warnings) -> // Handle non-zero exit code with potential warnings if exitCode <> 0 then @@ -36,21 +36,7 @@ module Updater = Ok() - | Error errors -> + | Error error -> // Handle and log errors - printer.Error("Failure updating...") - - // Collect error messages - let errorMessages = - errors - |> Array.map (fun e -> e.Message) - - // Print individual error messages - errorMessages - |> Array.iter printer.Error - - // Return result based on error messages - if errorMessages.Length > 0 then - Error (System.String.Join(" / ", errorMessages)) - else - Ok() + printer.Error($"Failure updating: %s{error}") + Error error diff --git a/src/CCVTAC.FSharp/ExtensionMethods.fs b/src/CCVTAC.FSharp/ExtensionMethods.fs index 836a3adc..a1cd2659 100644 --- a/src/CCVTAC.FSharp/ExtensionMethods.fs +++ b/src/CCVTAC.FSharp/ExtensionMethods.fs @@ -4,33 +4,32 @@ open System open System.IO open System.Text open System.Collections.Generic -open System.Linq module ExtensionMethods = /// Determines whether a string contains any text. /// allowWhiteSpace = true allows whitespace to count as text. - let HasText (maybeText: string) (allowWhiteSpace: bool) = + let hasText (maybeText: string) (allowWhiteSpace: bool) = if allowWhiteSpace then not (String.IsNullOrEmpty maybeText) else not (String.IsNullOrWhiteSpace maybeText) /// Overload with default parameter for F# callers. - let HasTextDefault (maybeText: string) = HasText maybeText false + let HasTextDefault (maybeText: string) = hasText maybeText false /// Collection helpers (similar to the original extension members). module SeqEx = /// Determines whether a sequence is empty. - let None (collection: seq<'T>) : bool = + let None (collection: seq<'a>) : bool = Seq.isEmpty collection /// Determines whether no elements of a sequence satisfy a given condition. - let NoneBy (predicate: 'T -> bool) (collection: seq<'T>) : bool = + let NoneBy (predicate: 'a -> bool) (collection: seq<'a>) : bool = not (Seq.exists predicate collection) /// Case-insensitive contains for a sequence of strings. - let CaseInsensitiveContains (collection: seq) (text: string) : bool = + let caseInsensitiveContains (collection: seq) (text: string) : bool = collection |> Seq.exists (fun s -> String.Equals(s, text, StringComparison.OrdinalIgnoreCase)) @@ -60,7 +59,7 @@ module ExtensionMethods = let invalidSet = HashSet(invalidCharsSeq) if invalidSet.Contains(replaceWith) then - invalidArg "replaceWith" (sprintf "The replacement char ('%c') must be a valid path character." replaceWith) + invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." // Replace each invalid char in the string using StringBuilder for efficiency let sb = StringBuilder(this) @@ -74,3 +73,36 @@ module ExtensionMethods = this.TrimEnd(Environment.NewLine.ToCharArray()) else this + + +module Utilities = + /// Returns a new string in which all invalid path characters for the current OS + /// have been replaced by the specified replacement character. + /// Throws if the replacement character is an invalid path character. + let replaceInvalidPathChars (replaceWith: char option) (customInvalidChars: char array option) (text: string) : string = + let replaceWith = defaultArg replaceWith '_' + let custom = defaultArg customInvalidChars [||] + + let invalidCharsSeq = + seq { + yield! Path.GetInvalidFileNameChars() + yield! Path.GetInvalidPathChars() + yield Path.PathSeparator + yield Path.DirectorySeparatorChar + yield Path.AltDirectorySeparatorChar + yield Path.VolumeSeparatorChar + yield! custom + } + |> Seq.distinct + + let invalidSet = HashSet(invalidCharsSeq) + + if invalidSet.Contains(replaceWith) + then invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." + + // Replace each invalid char in the string. // TODO: `fold`/`reduce` this up and return a Result! + let sb = StringBuilder text + for ch in invalidSet do + sb.Replace(ch, replaceWith) |> ignore + sb.ToString() + diff --git a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs index cb69e8a2..67e3b7f4 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs @@ -44,8 +44,8 @@ type ExternalTool = { match process' with | null -> Error $"The program \"{this.Name}\" was not found. (The process was null.)" - | _ -> - process'.WaitForExit() + | process'' -> + process''.WaitForExit() Ok() with | _ -> diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 0659488a..8a37c548 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -1,6 +1,9 @@ namespace CCVTAC.Console.ExternalTools open System.Diagnostics +open System +open CCVTAC.Console +open Startwatch.Library module Runner = /// Authentic success exit code @@ -19,7 +22,8 @@ module Runner = let internal run (settings: ToolSettings) (otherSuccessExitCodes: int[]) - (printer: Printer) = + (printer: Printer) + : Result = let watch = Watch() @@ -31,38 +35,32 @@ module Runner = settings.CommandWithArgs.Split([|' '|], 2) // Prepare process start info - let processStartInfo = ProcessStartInfo( - FileName = splitCommandWithArgs.[0], - Arguments = if splitCommandWithArgs.Length > 1 then splitCommandWithArgs.[1] else "", - UseShellExecute = false, - RedirectStandardOutput = false, - RedirectStandardError = true, - CreateNoWindow = true, - WorkingDirectory = settings.WorkingDirectory - ) + let processStartInfo = ProcessStartInfo splitCommandWithArgs[0] + // processStartInfo.FileName <- splitCommandWithArgs.[0] + processStartInfo.Arguments <- if splitCommandWithArgs.Length > 1 then splitCommandWithArgs.[1] else "" + processStartInfo.UseShellExecute <- false + processStartInfo.RedirectStandardOutput <- false + processStartInfo.RedirectStandardError <- true + processStartInfo.CreateNoWindow <- true + processStartInfo.WorkingDirectory <- settings.WorkingDirectory // Start the process match Process.Start(processStartInfo) with | null -> // Process failed to start Error $"Could not locate {splitCommandWithArgs.[0]}." - | process -> + | process' -> // Read errors before waiting for exit - let errors = process.StandardError.ReadToEnd() + let error = process'.StandardError.ReadToEnd() // Wait for process to complete - process.WaitForExit() + process'.WaitForExit() - // Log completion time printer.Info($"{splitCommandWithArgs.[0]} finished in {watch.ElapsedFriendly}.") - // Trim terminal line break from errors - let trimmedErrors = errors.TrimTerminalLineBreak() + let trimmedErrors = error // TODO: Trim terminal line break? - // Determine result based on exit code - if isSuccessExitCode otherSuccessExitCodes process.ExitCode then - // Successful execution (with potential warnings) - Ok (process.ExitCode, trimmedErrors) + if isSuccessExitCode otherSuccessExitCodes process'.ExitCode then + Ok (process'.ExitCode, trimmedErrors) else - // Failed execution - Error $"{splitCommandWithArgs.[0]} exited with code {process.ExitCode}: {trimmedErrors}." + Error $"{splitCommandWithArgs.[0]} exited with code {process'.ExitCode}: {trimmedErrors}." diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 396efa44..8317a2d1 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -2,13 +2,13 @@ namespace CCVTAC.Console open System open System.IO -open System.Linq open System.Text.Json open Spectre.Console type History(filePath: string, displayCount: byte) = let separator = ';' + member private _.FilePath = filePath member private _.DisplayCount = displayCount @@ -34,16 +34,16 @@ type History(filePath: string, displayCount: byte) = let historyData = lines - |> Seq.map (fun line -> line.Split(separator)) + |> Seq.map _.Split(separator) |> Seq.filter (fun parts -> parts.Length = 2) - |> Seq.map (fun parts -> DateTime.Parse(parts.[0]), parts.[1]) + |> Seq.map (fun parts -> DateTime.Parse(parts[0]), parts[1]) |> Seq.groupBy fst |> Seq.map (fun (dt, pairs) -> dt, pairs |> Seq.map snd |> Seq.toList) let table = Table() table.Border <- TableBorder.None table.AddColumns("Time", "URL") |> ignore - table.Columns.[0].PadRight <- 3 + table.Columns[0].PadRight(3) |> ignore for (dateTime, urls) in historyData do let formattedTime = sprintf "%s" (dateTime.ToString("yyyy-MM-dd HH:mm:ss")) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 10be1275..b6832e1f 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -48,29 +48,33 @@ module InputHelper = type CategorizedInput = { Text: string; Category: InputCategory } - let CategorizeInputs (inputs: ICollection) : ImmutableArray = + let CategorizeInputs (inputs: ICollection) : CategorizedInput list = inputs |> Seq.cast |> Seq.map (fun input -> - let category = if input.StartsWith(string Commands.Prefix) then InputCategory.Command else InputCategory.Url + let category = + if input.StartsWith(string Commands.Prefix) + then InputCategory.Command + else InputCategory.Url { Text = input; Category = category }) - |> ImmutableArray.CreateRange + |> List.ofSeq - type CategoryCounts(counts: Dictionary) = + type CategoryCounts(counts: Map) = member _.Item with get (category: InputCategory) = match counts.TryGetValue(category) with | true, v -> v | _ -> 0 - let CountCategories (inputs: ICollection) : CategoryCounts = + let CountCategories (inputs: CategorizedInput seq) : CategoryCounts = let counts = inputs |> Seq.cast |> Seq.groupBy (fun i -> i.Category) |> Seq.map (fun (k, grp) -> k, grp |> Seq.length) - |> dict - :?> IDictionary - |> fun d -> Dictionary(d) + // |> dict + // :?> IDictionary + // |> fun d -> Dictionary(d) + |> Map.ofSeq CategoryCounts(counts) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index d0b5d0e0..f993546e 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -3,7 +3,7 @@ namespace CCVTAC.Console.IoUtilities open System open System.IO open System.Text -open CCVTAC.Console.PostProcessing +open CCVTAC.Console module Directories = [] @@ -15,9 +15,12 @@ module Directories = let internal audioFileCount (directory: string) = Directory.GetFiles(directory) |> Array.filter (fun f -> - PostProcessor.AudioExtensions + AudioExtensions |> Array.exists (fun ext -> - Path.GetExtension(f).Equals(ext, StringComparison.OrdinalIgnoreCase) + // let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. + // let ext' = match Path.GetExtension (ext: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. + // Path.GetExtension(f).Equals(ext, StringComparison.OrdinalIgnoreCase) // Error occurs here. + StringComparer.OrdinalIgnoreCase.Equals(Path.GetExtension(f), ext) ) ) |> Array.length @@ -89,10 +92,9 @@ module Directories = let internal askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = let doDelete = printer.AskToBool("Delete all temporary files?", "Yes", "No") - if doDelete then - deleteAllFiles workingDirectory 10 - else - Error "Will not delete the files." + if doDelete + then deleteAllFiles workingDirectory 10 + else Error "Will not delete the files." /// Returns the filenames in a given directory, optionally ignoring specific filenames let private getDirectoryFileNames @@ -106,7 +108,5 @@ module Directories = |> Seq.toArray Directory.GetFiles(directoryName, AllFilesSearchPattern, enumerationOptions) - |> Array.filter (fun filePath -> - not (ignoreFiles |> Array.exists (fun ignore -> filePath.EndsWith(ignore))) - ) - |> Array + |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) + diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 7cbd0546..579814e7 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -1,65 +1,256 @@ namespace CCVTAC.Console open System +open System.Threading open CCVTAC.Console.Downloading +open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.IoUtilities open CCVTAC.Console.PostProcessing open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.Settings.TagFormat +open CCVTAC.Console.Settings.Settings.Validation +open CCVTAC.Console.Settings.Settings.IO +open CCVTAC.Console.Settings.Settings.LiveUpdating open Spectre.Console open CCVTAC.Console.InputHelper -open CCVTAC.FSharp.Settings +open ExtensionMethods +open Startwatch.Library -type Orchestrator() = +module Orchestrator = + type NextAction = + | Continue = 0uy + | QuitAtUserRequest = 1uy + | QuitDueToErrors = 2uy - /// Ensures the download environment is ready, then initiates the UI input and download process. - static member Start (settings: UserSettings) (printer: Printer) : unit = - // The working directory should start empty. Give the user a chance to empty it. - match Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10) with + let summarizeInput + (categorizedInputs: CategorizedInput list) + (counts: CategoryCounts) + (printer: Printer) + : unit + = + + if categorizedInputs.Length > 1 then + let urlCount = counts.[InputCategory.Url] + let cmdCount = counts.[InputCategory.Command] + + let urlSummary = + match urlCount with + | 1 -> "1 URL" + | n when n > 1 -> sprintf "%d URLs" n + | _ -> String.Empty + + let commandSummary = + match cmdCount with + | 1 -> "1 command" + | n when n > 1 -> sprintf "%d commands" n + | _ -> String.Empty + + let connector = + if hasText urlSummary false && hasText commandSummary false then " and " else String.Empty + + printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." + + for input in categorizedInputs do + printer.Info(sprintf " • %s" input.Text) + + Printer.EmptyLines(1uy) + + let sleep (sleepSeconds: uint16) : unit = + // Use a mutable remainingSeconds to mirror the C# behavior + let mutable remainingSeconds = sleepSeconds + + AnsiConsole + .Status() + .Start(sprintf "Sleeping for %d seconds..." sleepSeconds, + fun ctx -> + ctx.Spinner(Spinner.Known.Star) |> ignore + ctx.SpinnerStyle(Style.Parse("blue")) |> ignore + + while remainingSeconds > 0us do + ctx.Status(sprintf "Sleeping for %d seconds..." remainingSeconds) |> ignore + remainingSeconds <- remainingSeconds - 1us + Thread.Sleep(1000) + ) + + let processUrl + (url: string) + (settings: UserSettings) + (resultTracker: ResultTracker) + (history: History) + (urlInputTime: DateTime) + (batchSize: int) + (urlIndex: int) + (printer: Printer) + : Result + = + + match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Error firstErr -> printer.FirstError(firstErr) - - match Directories.AskToDeleteAllFiles(settings.WorkingDirectory, printer) with - | Ok deletedCount -> - printer.Info (sprintf "%d file(s) deleted." deletedCount) - | Error err -> - printer.FirstError(err) - printer.Info "Aborting..." - // abort Start by returning - () + Ok NextAction.QuitDueToErrors | Ok () -> - // proceed + // Don't sleep for the very first URL. + if urlIndex > 1 then + Threading.Thread.Sleep(int settings.SleepSecondsBetweenURLs * 1000) + printer.Info(sprintf "Slept for %d second(s)." settings.SleepSecondsBetweenURLs, appendLines = 1uy) - let results = ResultTracker(printer) - let history = History(settings.HistoryFile, settings.HistoryDisplayCount) - let mutable nextAction = NextAction.Continue - let mutable settingsRef = settings + if batchSize > 1 then + printer.Info(sprintf "Processing group %d of %d..." urlIndex batchSize) - while nextAction = NextAction.Continue do - let input = printer.GetInput InputHelper.Prompt - let splitInputs = InputHelper.SplitInput input + let jobWatch = Watch() - if splitInputs.IsEmpty then - printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands.Prefix) - else - let categorizedInputs = InputHelper.CategorizeInputs(splitInputs) - let categoryCounts = InputHelper.CountCategories(categorizedInputs) - SummarizeInput(categorizedInputs, categoryCounts, printer) + match Downloading.mediaTypeWithIds url with + | Error e -> + let errorMsg = $"URL parse error: %s{e}" + printer.Error(errorMsg) + Error errorMsg + | Ok mediaType -> + printer.Info(sprintf "%s URL '%s' detected." (mediaType.GetType().Name) url) + history.Append(url, urlInputTime, printer) + + let downloadResult = Downloader.Run mediaType settings printer + resultTracker.RegisterResult(url, downloadResult) - // ProcessBatch may modify settings; reflect that by using a mutable reference - nextAction <- ProcessBatch(categorizedInputs, categoryCounts, &settingsRef, results, history, printer) + match downloadResult with + | Error e -> + let errorMsg = $"Download error: %s{e}" + printer.Error(errorMsg) + Error errorMsg + | Ok s -> + printer.Debug $"Successfully downloaded \"%s{s}\" format." + PostProcessor.Run settings mediaType printer - results.PrintSessionSummary() + let groupClause = + if batchSize > 1 + then $" (group %d{urlIndex} of %d{batchSize})" + else String.Empty + + printer.Info $"Processed '%s{url}'%s{groupClause} in %s{jobWatch.ElapsedFriendly}." + Ok NextAction.Continue + + let equalsIgnoreCase (a: string) (b: string) = + String.Equals(a, b, StringComparison.InvariantCultureIgnoreCase) + + let seqContainsIgnoreCase (seq: seq) (value: string) = + seq |> Seq.exists (fun s -> equalsIgnoreCase s value) + + let startsWithIgnoreCase (text: string) (prefix: string) = + text.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) + + let summarizeToggle settingName setting = + sprintf "%s was toggled to %s for this session." settingName (if setting then "ON" else "OFF") + + let summarizeUpdate settingName setting = + sprintf "%s was updated to \"%s\" for this session." settingName setting + + let processCommand + (command: string) + (settings: byref) + (history: History) + (printer: Printer) + : Result = + + // Help + if equalsIgnoreCase Commands.HelpCommand command then + for kvp in Commands.Summary do + printer.Info(kvp.Key) + printer.Info(sprintf " %s" kvp.Value) + Ok NextAction.Continue + + // Quit + elif seqContainsIgnoreCase Commands.QuitCommands command then + Ok NextAction.QuitAtUserRequest + + // History + elif seqContainsIgnoreCase Commands.History command then + history.ShowRecent(printer) + Ok NextAction.Continue + + // Update downloader + elif seqContainsIgnoreCase Commands.UpdateDownloader command then + Updater.run settings printer |> ignore + Ok NextAction.Continue + + // Settings summary + elif seqContainsIgnoreCase Commands.SettingsSummary command then + Settings.PrintSummary settings printer None + Ok NextAction.Continue + + // Toggle split chapters + elif seqContainsIgnoreCase Commands.SplitChapterToggles command then + settings <- toggleSplitChapters(settings) + printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) + Ok NextAction.Continue + + // Toggle embed images + elif seqContainsIgnoreCase Commands.EmbedImagesToggles command then + settings <- toggleEmbedImages(settings) + printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) + Ok NextAction.Continue + + // Toggle quiet mode + elif seqContainsIgnoreCase Commands.QuietModeToggles command then + settings <- toggleQuietMode(settings) + printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) + printer.ShowDebug(not settings.QuietMode) + Ok NextAction.Continue + + // Update audio formats prefix + elif startsWithIgnoreCase command Commands.UpdateAudioFormatPrefix then + let format = command.Replace(Commands.UpdateAudioFormatPrefix, "").ToLowerInvariant() + if String.IsNullOrEmpty format then + Error "You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." + else + let updateResult = updateAudioFormat settings format + match updateResult with + | Error e -> Error e + | Ok x -> + settings <- x + printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) + Ok NextAction.Continue + // if updateResult.IsError then Error updateResult.ErrorValue + // else + // settings <- updateResult.ResultValue + // printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) + // Ok NextAction.Continue + + // Update audio quality prefix + elif startsWithIgnoreCase command Commands.UpdateAudioQualityPrefix then + let inputQuality = command.Replace(Commands.UpdateAudioQualityPrefix, "") + if String.IsNullOrEmpty inputQuality then + Error "You must enter a number representing an audio quality." + else + match Byte.TryParse(inputQuality) with + | (true, quality) -> + let updateResult = updateAudioQuality settings quality + match updateResult with + | Error e -> Error e + | Ok x -> + settings <- x + printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) + Ok NextAction.Continue + // if updateResult.IsError then Error updateResult.ErrorValue + // else + // settings <- updateResult.ResultValue + // printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) + // Ok NextAction.Continue + | _ -> + Error (sprintf "\"%s\" is an invalid quality value." inputQuality) + + // Unknown command + else + Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." command (string Commands.Prefix)) -/// TODO: Redo? /// Processes a single user request, from input to downloading and file post-processing. /// Returns the next action the application should take (e.g., continue or quit). - let private ProcessBatch - (categorizedInputs: ImmutableArray) + let processBatch + (categorizedInputs: CategorizedInput list) (categoryCounts: CategoryCounts) (settings: byref) - (resultTracker: ResultTracker) + (resultTracker: ResultTracker) (history: History) (printer: Printer) : NextAction = @@ -70,34 +261,25 @@ type Orchestrator() = let mutable inputIndex = 0 for input in categorizedInputs do + let mutable stop = false // increment input index before passing to ProcessUrl to mirror ++inputIndex inputIndex <- inputIndex + 1 let result = match input.Category with | InputCategory.Command -> - ProcessCommand(input.Text, &settings, history, printer) + processCommand input.Text &settings history printer | InputCategory.Url -> - ProcessUrl( - input.Text, - settings, - resultTracker, - history, - inputTime, - categoryCounts.[InputCategory.Url], - inputIndex, - printer - ) + processUrl input.Text settings resultTracker history inputTime categoryCounts[InputCategory.Url] inputIndex printer batchResults.RegisterResult(input.Text, result) - if result.IsFailed then - printer.Error(result.Errors.First().Message) - else - nextAction <- result.Value + match result with + | Error e -> printer.Error e + | Ok action -> + nextAction <- action if nextAction <> NextAction.Continue then - // break out early - break + stop <- true if categoryCounts.[InputCategory.Url] > 1 then printer.Info(sprintf "%sFinished with batch of %d URLs in %s." @@ -108,215 +290,43 @@ type Orchestrator() = nextAction -open System - let private ProcessUrl - (url: string) - (settings: UserSettings) - (resultTracker: ResultTracker) - (history: History) - (urlInputTime: DateTime) - (batchSize: int) - (urlIndex: int) - (printer: Printer) - : Result = - match Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10) with + /// Ensures the download environment is ready, then initiates the UI input and download process. + let start (settings: UserSettings) (printer: Printer) : unit = + // The working directory should start empty. Give the user a chance to empty it. + match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Error firstErr -> printer.FirstError(firstErr) - Ok NextAction.QuitDueToErrors - | Ok () -> - // Don't sleep for the very first URL. - if urlIndex > 1 then - Threading.Thread.Sleep(settings.SleepSecondsBetweenURLs * 1000) - printer.Info(sprintf "Slept for %d second(s)." settings.SleepSecondsBetweenURLs, appendLines = 1) - if batchSize > 1 then - printer.Info(sprintf "Processing group %d of %d..." urlIndex batchSize) - - let jobWatch = Watch() - - match Downloader.WrapUrlInMediaType(url) with - | Error e -> - let errorMsg = sprintf "URL parse error: %s" (e |> Seq.map (fun er -> er.Message) |> Seq.head) - printer.Error(errorMsg) - Error errorMsg - | Ok mediaType -> - printer.Info(sprintf "%s URL '%s' detected." (mediaType.GetType().Name) url) - history.Append(url, urlInputTime, printer) - - let downloadResult = Downloader.Run(mediaType, settings, printer) - resultTracker.RegisterResult(url, downloadResult) - - if downloadResult.IsFailed then - let errorMsg = sprintf "Download error: %s" (downloadResult.Errors |> Seq.map (fun er -> er.Message) |> Seq.head) - printer.Error(errorMsg) - Error errorMsg - else - printer.Debug(sprintf "Successfully downloaded \"%s\" format." downloadResult.Value) - PostProcessor.Run(settings, mediaType, printer) - - let groupClause = if batchSize > 1 then sprintf " (group %d of %d)" urlIndex batchSize else String.Empty - printer.Info(sprintf "Processed '%s'%s in %s." url groupClause jobWatch.ElapsedFriendly) - Ok NextAction.Continue + match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with + | Ok deletedCount -> + printer.Info $"%d{deletedCount} file(s) deleted." + | Error err -> + printer.FirstError(err) + printer.Info "Aborting..." + // abort Start by returning + () + | Ok () -> + let results = ResultTracker(printer) + let history = History(settings.HistoryFile, settings.HistoryDisplayCount) + let mutable nextAction = NextAction.Continue + let mutable settingsRef = settings - let private ProcessCommand - - let private equalsIgnoreCase (a: string) (b: string) = - String.Equals(a, b, StringComparison.InvariantCultureIgnoreCase) - - let private seqContainsIgnoreCase (seq: seq) (value: string) = - seq |> Seq.exists (fun s -> equalsIgnoreCase s value) - - let private startsWithIgnoreCase (text: string) (prefix: string) = - text.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) - - let private summarizeToggle settingName setting = - sprintf "%s was toggled to %s for this session." settingName (if setting then "ON" else "OFF") - - let private summarizeUpdate settingName setting = - sprintf "%s was updated to \"%s\" for this session." settingName setting - - let private ProcessCommand - (command: string) - (settings: byref) - (history: History) - (printer: Printer) - : Result = - - // Help - if equalsIgnoreCase Commands.HelpCommand command then - for kvp in Commands.Summary do - printer.Info(kvp.Key) - printer.Info(sprintf " %s" kvp.Value) - Ok NextAction.Continue - - // Quit - elif seqContainsIgnoreCase Commands.QuitCommands command then - Ok NextAction.QuitAtUserRequest - - // History - elif seqContainsIgnoreCase Commands.History command then - history.ShowRecent(printer) - Ok NextAction.Continue - - // Update downloader - elif seqContainsIgnoreCase Commands.UpdateDownloader command then - Updater.Run(settings, printer) |> ignore - Ok NextAction.Continue - - // Settings summary - elif seqContainsIgnoreCase Commands.SettingsSummary command then - SettingsAdapter.PrintSummary(settings, printer) - Ok NextAction.Continue - - // Toggle split chapters - elif seqContainsIgnoreCase Commands.SplitChapterToggles command then - settings <- SettingsAdapter.ToggleSplitChapters(settings) - printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) - Ok NextAction.Continue - - // Toggle embed images - elif seqContainsIgnoreCase Commands.EmbedImagesToggles command then - settings <- SettingsAdapter.ToggleEmbedImages(settings) - printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) - Ok NextAction.Continue - - // Toggle quiet mode - elif seqContainsIgnoreCase Commands.QuietModeToggles command then - settings <- SettingsAdapter.ToggleQuietMode(settings) - printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) - printer.ShowDebug(not settings.QuietMode) - Ok NextAction.Continue - - // Update audio formats prefix - elif startsWithIgnoreCase command Commands.UpdateAudioFormatPrefix then - let format = command.Replace(Commands.UpdateAudioFormatPrefix, "").ToLowerInvariant() - if String.IsNullOrEmpty format then - Error "You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." - else - let updateResult = SettingsAdapter.UpdateAudioFormat(settings, format) - if updateResult.IsError then Error updateResult.ErrorValue - else - settings <- updateResult.ResultValue - printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) - Ok NextAction.Continue + while nextAction = NextAction.Continue do + let input = printer.GetInput InputHelper.Prompt + let splitInputs = InputHelper.SplitInput input - // Update audio quality prefix - elif startsWithIgnoreCase command Commands.UpdateAudioQualityPrefix then - let inputQuality = command.Replace(Commands.UpdateAudioQualityPrefix, "") - if String.IsNullOrEmpty inputQuality then - Error "You must enter a number representing an audio quality." + if splitInputs.IsEmpty then + printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands.Prefix) else - match Byte.TryParse(inputQuality) with - | (true, quality) -> - let updateResult = SettingsAdapter.UpdateAudioQuality(settings, quality) - if updateResult.IsError then Error updateResult.ErrorValue - else - settings <- updateResult.ResultValue - printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) - Ok NextAction.Continue - | _ -> - Error (sprintf "\"%s\" is an invalid quality value." inputQuality) - - // Unknown command - else - Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." command (string Commands.Prefix)) + let categorizedInputs = InputHelper.CategorizeInputs(splitInputs) + let categoryCounts = InputHelper.CountCategories(categorizedInputs) + summarizeInput categorizedInputs categoryCounts printer + // ProcessBatch may modify settings; reflect that by using a mutable reference + nextAction <- processBatch categorizedInputs categoryCounts &settingsRef results history printer - let summarizeInput + results.PrintSessionSummary() - type NextAction = - | Continue = 0uy - | QuitAtUserRequest = 1uy - | QuitDueToErrors = 2uy - - let private SummarizeInput - (categorizedInputs: ImmutableArray) - (counts: CategoryCounts) - (printer: Printer) - : unit = - if categorizedInputs.Length > 1 then - let urlCount = counts.[InputCategory.Url] - let cmdCount = counts.[InputCategory.Command] - - let urlSummary = - match urlCount with - | 1 -> "1 URL" - | n when n > 1 -> sprintf "%d URLs" n - | _ -> String.Empty - - let commandSummary = - match cmdCount with - | 1 -> "1 command" - | n when n > 1 -> sprintf "%d commands" n - | _ -> String.Empty - - let connector = - if urlSummary.HasText() && commandSummary.HasText() then " and " else String.Empty - - printer.Info(sprintf "Batch of %s%s%s entered." urlSummary connector commandSummary) - - for input in categorizedInputs do - printer.Info(sprintf " • %s" input.Text) - - Printer.EmptyLines(1) - - let private Sleep (sleepSeconds: uint16) : unit = - // Use a mutable remainingSeconds to mirror the C# behavior - let mutable remainingSeconds = sleepSeconds - - AnsiConsole - .Status() - .Start(sprintf "Sleeping for %d seconds..." sleepSeconds, - fun ctx -> - ctx.Spinner(Spinner.Known.Star) - ctx.SpinnerStyle(Style.Parse("blue")) - - while remainingSeconds > 0us do - ctx.Status(sprintf "Sleeping for %d seconds..." remainingSeconds) - remainingSeconds <- remainingSeconds - 1us - Thread.Sleep(1000) - ) - |> ignore diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index 256d6a55..47470a84 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -2,8 +2,41 @@ namespace CCVTAC.Console.PostProcessing open System open System.IO +open CCVTAC.Console module Deleter = + /// Retrieves collection files based on collection metadata + let private getCollectionFiles + (collectionMetadata: CollectionMetadata option) + (workingDirectory: string) + : Result = + + match collectionMetadata with + | None -> Ok [||] + | Some metadata -> + try + let files = + Directory.GetFiles(workingDirectory, $"*{metadata.Id}*") + + Ok files + with + | ex -> Error $"Error collecting filenames: {ex.Message}" + + /// Deletes all specified files + let private deleteAll + (fileNames: string[]) + (printer: Printer) + : unit = + + fileNames + |> Array.iter (fun fileName -> + try + File.Delete(fileName) + printer.Debug($"• Deleted \"{fileName}\"") + with + | ex -> printer.Error($"• Deletion error: {ex.Message}") + ) + /// Runs the deletion process for temporary files let internal run (taggingSetFileNames: string seq) @@ -34,35 +67,3 @@ module Deleter = printer.Debug($"Deleting {allFileNames.Length} temporary files...") deleteAll allFileNames printer printer.Info("Deleted temporary files.") - - /// Retrieves collection files based on collection metadata - and private getCollectionFiles - (collectionMetadata: CollectionMetadata option) - (workingDirectory: string) - : Result = - - match collectionMetadata with - | None -> Ok [||] - | Some metadata -> - try - let files = - Directory.GetFiles(workingDirectory, $"*{metadata.Id}*") - - Ok files - with - | ex -> Error $"Error collecting filenames: {ex.Message}" - - /// Deletes all specified files - and private deleteAll - (fileNames: string[]) - (printer: Printer) - : unit = - - fileNames - |> Array.iter (fun fileName -> - try - File.Delete(fileName) - printer.Debug($"• Deleted \"{fileName}\"") - with - | ex -> printer.Error($"• Deletion error: {ex.Message}") - ) diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs index 9cd09ed8..384a592e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -1,5 +1,7 @@ namespace CCVTAC.Console.PostProcessing +open CCVTAC.Console.ExternalTools +open CCVTAC.Console open CCVTAC.Console.ExternalTools module ImageProcessor = @@ -7,5 +9,8 @@ module ImageProcessor = let internal ProgramName = "mogrify" let internal Run (workingDirectory: string) (printer: Printer) : unit = - let imageEditToolSettings = ToolSettings($"{ProgramName} -trim -fuzz 10% *.jpg", workingDirectory) - Runner.Run(imageEditToolSettings, [||], printer) |> ignore + let imageEditToolSettings = { + CommandWithArgs = $"{ProgramName} -trim -fuzz 10%% *.jpg" + WorkingDirectory = workingDirectory + } + Runner.run imageEditToolSettings [||] printer |> ignore diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 3b786433..e80246ea 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -5,10 +5,14 @@ open System.IO open System.Linq open System.Text.Json open System.Text.RegularExpressions -open System.Collections.Generic -open System.Collections.Immutable +open CCVTAC.Console.ExtensionMethods open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.FSharp.Settings +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console +open Startwatch.Library +open Utilities +open CCVTAC.Console.PostProcessing module Mover = @@ -19,52 +23,6 @@ module Mover = ", RegexOptions.Compiled) let private ImageFileWildcard = "*.jp*" - let Run - (taggingSets: seq) - (maybeCollectionData: CollectionMetadata option) - (settings: UserSettings) - (overwrite: bool) - (printer: Printer) - : unit = - printer.Debug "Starting move..." - let watch = Watch() // assumes Watch type with ElapsedFriendly exists - - let workingDirInfo = DirectoryInfo(settings.WorkingDirectory) - - let firstTaggingSet = - taggingSets - |> Seq.tryHead - |> Option.defaultWith (fun () -> failwith "No tagging sets provided") - - let subFolderName = GetSafeSubDirectoryName(maybeCollectionData, firstTaggingSet) - let collectionName = maybeCollectionData |> Option.map (fun c -> c.Title) |> Option.defaultValue String.Empty - let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) - - match EnsureDirectoryExists(fullMoveToDir, printer) with - | Error _ -> () // error already printed - | Ok () -> - let audioFileNames = - workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)) - |> Seq.toImmutableList - - if audioFileNames.IsEmpty then - printer.Error "No audio filenames to move found." - else - printer.Debug (sprintf "Moving %d audio file(s) to \"%s\"..." audioFileNames.Count fullMoveToDir) - - let (successCount, failureCount) = - MoveAudioFiles(audioFileNames, fullMoveToDir, overwrite, printer) - - MoveImageFile(collectionName, subFolderName, workingDirInfo, fullMoveToDir, audioFileNames.Count, overwrite, printer) - - let fileLabel = if successCount = 1u then "file" else "files" - printer.Info (sprintf "Moved %d audio %s in %s." successCount fileLabel watch.ElapsedFriendly) - - if failureCount > 0u then - let fileLabel' = if failureCount = 1u then "file" else "files" - printer.Warning (sprintf "However, %d audio %s could not be moved." failureCount fileLabel') - let private IsPlaylistImage (fileName: string) = PlaylistImageRegex.IsMatch(fileName) @@ -91,7 +49,13 @@ module Mover = printer.Error (sprintf "Error creating move-to directory \"%s\": %s" moveToDir ex.Message) Error String.Empty - let private MoveAudioFiles (audioFiles: ImmutableList) (moveToDir: string) (overwrite: bool) (printer: Printer) : uint32 * uint32 = + let private MoveAudioFiles + (audioFiles: FileInfo list) + (moveToDir: string) + (overwrite: bool) + (printer: Printer) + : uint32 * uint32 = + let mutable successCount = 0u let mutable failureCount = 0u for file in audioFiles do @@ -118,7 +82,7 @@ module Mover = if String.IsNullOrWhiteSpace maybeCollectionName then subFolderName else - sprintf "%s - %s" subFolderName (maybeCollectionName.ReplaceInvalidPathChars()) + sprintf "%s - %s" subFolderName (replaceInvalidPathChars None None maybeCollectionName) match GetCoverImage workingDirInfo audioFileCount with | None -> () @@ -129,10 +93,25 @@ module Mover = with ex -> printer.Warning (sprintf "Error copying the image file: %s" ex.Message) + let private GetParsedVideoJson (taggingSet: TaggingSet) : Result = + try + let json = File.ReadAllText(taggingSet.JsonFilePath) + try + #nowarn 3265 + let videoData = JsonSerializer.Deserialize(json) + #warnon 3265 + + if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) + else Ok videoData + with :? JsonException as ex -> + Error (sprintf "Error deserializing JSON from file \"%s\": %s" taggingSet.JsonFilePath ex.Message) + with ex -> + Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) + let private GetSafeSubDirectoryName (collectionData: CollectionMetadata option) (taggingSet: TaggingSet) : string = let workingName = match collectionData with - | Some metadata when metadata.Uploader.HasText() && metadata.Title.HasText() -> metadata.Uploader + | Some metadata when hasText metadata.Uploader false && hasText metadata.Title false -> metadata.Uploader | _ -> match GetParsedVideoJson taggingSet with | Ok v -> v.Uploader @@ -143,14 +122,48 @@ module Mover = if safeName.EndsWith(topicSuffix) then safeName.Replace(topicSuffix, String.Empty) else safeName - let private GetParsedVideoJson (taggingSet: TaggingSet) : Result = - try - let json = File.ReadAllText(taggingSet.JsonFilePath) - try - let videoData = JsonSerializer.Deserialize(json) - if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) - else Ok videoData - with :? JsonException as ex -> - Error (sprintf "Error deserializing JSON from file \"%s\": %s" taggingSet.JsonFilePath ex.Message) - with ex -> - Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) + let Run + (taggingSets: seq) + (maybeCollectionData: CollectionMetadata option) + (settings: UserSettings) + (overwrite: bool) + (printer: Printer) + : unit = + printer.Debug "Starting move..." + let watch = Watch() // assumes Watch type with ElapsedFriendly exists + + let workingDirInfo = DirectoryInfo(settings.WorkingDirectory) + + let firstTaggingSet = + taggingSets + |> Seq.tryHead + |> Option.defaultWith (fun () -> failwith "No tagging sets provided") + + let subFolderName = GetSafeSubDirectoryName maybeCollectionData firstTaggingSet + let collectionName = maybeCollectionData |> Option.map (fun c -> c.Title) |> Option.defaultValue String.Empty + let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) + + match EnsureDirectoryExists fullMoveToDir printer with + | Error _ -> () // error already printed + | Ok () -> + let audioFileNames = + workingDirInfo.EnumerateFiles() + |> Seq.filter (fun f -> caseInsensitiveContains AudioExtensions f.Extension) + |> List.ofSeq + + if audioFileNames.IsEmpty then + printer.Error "No audio filenames to move found." + else + printer.Debug $"Moving %d{audioFileNames.Length} audio file(s) to \"%s{fullMoveToDir}\"..." + + let successCount, failureCount = + MoveAudioFiles audioFileNames fullMoveToDir overwrite printer + + MoveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite printer + + let fileLabel = if successCount = 1u then "file" else "files" + printer.Info $"Moved %d{successCount} audio %s{fileLabel} in %s{watch.ElapsedFriendly}." + + if failureCount > 0u then + let fileLabel' = if failureCount = 1u then "file" else "files" + printer.Warning $"However, %d{failureCount} audio %s{fileLabel'} could not be moved." diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 9f6cc0c6..b58cf18b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -1,6 +1,5 @@ namespace CCVTAC.Console.PostProcessing -open System open System.IO open System.Linq open System.Text.Json @@ -8,12 +7,17 @@ open System.Text.RegularExpressions open System.Collections.Immutable open CCVTAC.Console.IoUtilities open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.FSharp.Settings +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console +open CCVTAC.Console.Downloading +open CCVTAC.Console.Downloading.Downloading +open Startwatch.Library module PostProcessor = - let internal AudioExtensions = - [| ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" |] + // let internal AudioExtensions = + // [| ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" |] let private collectionMetadataRegex = Regex(@"(?<= @@ -25,6 +29,43 @@ module PostProcessor = let private getCollectionMetadataMatches (path: string) = collectionMetadataRegex.IsMatch(path) + let private GetCollectionJson (workingDirectory: string) : Result = + try + let fileNames = + Directory.GetFiles(workingDirectory) + |> Seq.filter getCollectionMetadataMatches + // |> Seq.toImmutableHashSet + |> Set.ofSeq + + if fileNames.Count = 0 then + Error "No relevant files found." + elif fileNames.Count > 1 then + Error "Unexpectedly found more than one relevant file, so none will be processed." + else + let fileName = fileNames.Single() + let json = File.ReadAllText(fileName) + #nowarn 3265 + let collectionData = JsonSerializer.Deserialize(json) + #warnon 3265 + if isNull (box collectionData) then + Error "Deserialized collection metadata was null." + else + Ok collectionData + with ex -> + Error ex.Message + + let private GenerateTaggingSets (directory: string) : Result = + try + let files = Directory.GetFiles(directory) + let taggingSets = TaggingSet.CreateSets(files) + if taggingSets.Any() then Ok taggingSets + else Error (sprintf "No tagging sets were created using working directory \"%s\"." directory) + with + | :? DirectoryNotFoundException -> + Error (sprintf "Directory \"%s\" does not exist." directory) + | ex -> + Error (sprintf "Error reading working directory files: %s" ex.Message) + let Run (settings: UserSettings) (mediaType: MediaType) (printer: Printer) : unit = let watch = Watch() let workingDirectory = settings.WorkingDirectory @@ -32,7 +73,7 @@ module PostProcessor = printer.Info "Starting post-processing..." match GenerateTaggingSets workingDirectory with - | Error err -> + | Error _ -> printer.Error "No tagging sets were generated, so tagging cannot be done." | Ok taggingSets -> let collectionJsonResult = GetCollectionJson workingDirectory @@ -40,70 +81,38 @@ module PostProcessor = let collectionJsonOpt = match collectionJsonResult with | Error e -> - printer.Debug (sprintf "No playlist or channel metadata found: %s" e) + printer.Debug $"No playlist or channel metadata found: %s{e}" None | Ok cm -> printer.Debug "Found playlist/channel metadata." Some cm - if settings.EmbedImages then - ImageProcessor.Run(workingDirectory, printer) + if settings.EmbedImages + then ImageProcessor.Run workingDirectory printer + else () - match Tagger.Run(settings, taggingSets, collectionJsonOpt, mediaType, printer) with + match Tagger.Run settings taggingSets collectionJsonOpt mediaType printer with | Ok msg -> printer.Info msg - Renamer.Run(settings, workingDirectory, printer) - Mover.Run(taggingSets, collectionJsonOpt, settings, true, printer) + Renamer.Run settings workingDirectory printer + Mover.Run taggingSets collectionJsonOpt settings true printer let taggingSetFileNames = taggingSets - |> Seq.collect (fun s -> s.AllFiles :?> seq) + |> Seq.collect _.AllFiles |> Seq.toList - Deleter.Run(taggingSetFileNames, collectionJsonOpt, workingDirectory, printer) + Deleter.run taggingSetFileNames collectionJsonOpt workingDirectory printer - match Directories.WarnIfAnyFiles(workingDirectory, 20) with + match Directories.warnIfAnyFiles workingDirectory 20 with + | Ok _ -> () | Error firstErr -> - printer.FirstError(firstErr) + printer.FirstError firstErr printer.Info "Will delete the remaining files..." - match Directories.DeleteAllFiles(workingDirectory, 20) with - | Ok deletedCount -> printer.Info (sprintf "%d file(s) deleted." deletedCount) + match Directories.deleteAllFiles workingDirectory 20 with + | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." | Error e -> printer.FirstError(e) - | Ok _ -> () - | Error errs -> - printer.Errors("Tagging error(s) preventing further post-processing: ", Error errs) + | Error e -> + printer.Error($"Tagging error(s) preventing further post-processing: {e}") - printer.Info (sprintf "Post-processing done in %s." watch.ElapsedFriendly) - - let private GetCollectionJson (workingDirectory: string) : Result = - try - let fileNames = - Directory.GetFiles(workingDirectory) - |> Seq.filter getCollectionMetadataMatches - |> Seq.toImmutableHashSet - - if fileNames.Count = 0 then - Error "No relevant files found." - elif fileNames.Count > 1 then - Error "Unexpectedly found more than one relevant file, so none will be processed." - else - let fileName = fileNames.Single() - let json = File.ReadAllText(fileName) - let collectionData = JsonSerializer.Deserialize(json) - if isNull (box collectionData) then - Error "Deserialized collection metadata was null." - else - Ok collectionData - with ex -> - Error ex.Message - - let private GenerateTaggingSets (directory: string) : Result, string> = - try - let files = Directory.GetFiles(directory) - let taggingSets = TaggingSet.CreateSets(files) - if taggingSets.Any() then Ok taggingSets - else Error (sprintf "No tagging sets were created using working directory \"%s\"." directory) - with :? DirectoryNotFoundException -> - Error (sprintf "Directory \"%s\" does not exist." directory) - with ex -> - Error (sprintf "Error reading working directory files: %s" ex.Message) + printer.Info $"Post-processing done in %s{watch.ElapsedFriendly}." diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 7cd3a9f8..a0e63dce 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -4,9 +4,11 @@ open System open System.IO open System.Text open System.Text.RegularExpressions -open System.Linq -open System.Collections.Immutable -open CCVTAC.FSharp.Settings +open CCVTAC.Console +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open Startwatch.Library +open ExtensionMethods module Renamer = @@ -24,13 +26,13 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(f.Extension)) - |> Seq.toImmutableList + |> Seq.filter (fun f -> caseInsensitiveContains AudioExtensions f.Extension) + |> List.ofSeq - if audioFiles.None() then + if audioFiles.Length = 0 then printer.Warning "No audio files to rename were found." else - printer.Debug (sprintf "Renaming %d audio file(s)..." audioFiles.Count) + printer.Debug $"Renaming %d{audioFiles.Length} audio file(s)..." for file in audioFiles do let newFileName = @@ -46,16 +48,16 @@ module Renamer = |> Seq.rev |> Seq.toList - if matches.Count = 0 then sb + if matches.Length = 0 then sb else if not settings.QuietMode then let matchedPatternSummary = - if isNull renamePattern.Summary then + // if isNull renamePattern.Summary then // TODO: Check on this. sprintf "`%s` (no description)" renamePattern.RegexPattern - else - sprintf "\"%s\"" renamePattern.Summary + // else + // sprintf "\"%s\"" renamePattern.Summary - printer.Debug (sprintf "Rename pattern %s matched × %d." matchedPatternSummary matches.Count) + printer.Debug (sprintf "Rename pattern %s matched × %d." matchedPatternSummary matches.Length) for m in matches do // remove matched substring @@ -85,7 +87,7 @@ module Renamer = try let dest = Path.Combine(workingDirectory, newFileName) - |> fun p -> p.Normalize(getNormalizationForm settings.NormalizationForm) + |> _.Normalize(getNormalizationForm settings.NormalizationForm) File.Move(file.FullName, dest) printer.Debug (sprintf "• From: \"%s\"" file.Name) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index f11fc769..5e8adae8 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -2,30 +2,60 @@ namespace CCVTAC.Console.PostProcessing.Tagging open System open System.Text.RegularExpressions -open CCVTAC.FSharp.Settings +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.PostProcessing module Detectors = + /// Attempts casting the input text to type T and returning it. + /// If casting fails, the default value is returned instead. + let private cast<'a> (text: string option) (defaultValue: 'a) : 'a = + match text with + | None -> defaultValue + | Some textValue -> + try + // If T is string, return the text directly + if typeof<'a> = typeof then + box textValue :?> 'a + // Some textValue + else + // Try to convert to the target type + Convert.ChangeType(textValue, typeof<'a>) :?> 'a + with + | _ -> defaultValue + + /// Extracts the value of the specified tag field from the given data. + /// Video metadata + /// The name of the field within the video metadata to read + /// The text content of the requested field of the video metadata + let private extractMetadataText (metadata: VideoMetadata) (fieldName: string) = + match fieldName with + | "title" -> metadata.Title + | "description" -> metadata.Description + | _ -> + // TODO: It would be best to check for invalid entries upon settings deserialization. + raise (ArgumentException($"\"{fieldName}\" is an invalid video metadata field name.")) + /// Finds and returns the first instance of text matching a given detection scheme pattern, /// parsing into T if necessary. /// A match of type T if there was a match; otherwise, the default value provided. - let internal detectSingle<'T> + let internal detectSingle<'a> (videoMetadata: VideoMetadata) (patterns: TagDetectionPattern seq) - (defaultValue: 'T option) = + (defaultValue: 'a option) = patterns |> Seq.tryPick (fun pattern -> let fieldText = extractMetadataText videoMetadata pattern.SearchField - let match' = Regex(pattern.RegexPattern).Match(fieldText) - if not match'.Success then + if not match'.Success + then None else - let matchedText = - match'.Groups.[pattern.MatchGroup].Value.Trim() + let matchedText = match'.Groups[pattern.MatchGroup].Value.Trim() - cast<'T> matchedText defaultValue + cast (Some matchedText) (Some defaultValue) // TODO: Check why 2nd `Some` is needed. ) |> Option.defaultValue defaultValue @@ -33,11 +63,12 @@ module Detectors = /// concatenating them into a single string (using a custom separator), then casting /// to type T if necessary. /// A match of type T if there were any matches; otherwise, the default value provided. - let internal detectMultiple<'T> + let internal detectMultiple<'a> (data: VideoMetadata) (patterns: TagDetectionPattern seq) - (defaultValue: 'T option) - (separator: string) = + (defaultValue: 'a) + (separator: string) + = let matchedValues = patterns @@ -45,9 +76,8 @@ module Detectors = let fieldText = extractMetadataText data pattern.SearchField Regex(pattern.RegexPattern).Matches(fieldText) - |> Seq.filter (fun m -> m.Success) - |> Seq.map (fun m -> m.Groups.[pattern.MatchGroup].Value.Trim()) - ) + |> Seq.filter _.Success + |> Seq.map _.Groups[pattern.MatchGroup].Value.Trim()) |> Seq.distinct |> Seq.toArray @@ -55,32 +85,4 @@ module Detectors = defaultValue else let joinedMatchedText = String.Join(separator, matchedValues) - cast<'T> joinedMatchedText defaultValue - - /// Attempts casting the input text to type T and returning it. - /// If casting fails, the default value is returned instead. - let private cast<'T> (text: string option) (defaultValue: 'T option) = - match text with - | None -> defaultValue - | Some textValue -> - try - // If T is string, return the text directly - if typeof<'T> = typeof then - Some(box textValue :?> 'T) - else - // Try to convert to the target type - Some(Convert.ChangeType(textValue, typeof<'T>) :?> 'T) - with - | _ -> defaultValue - - /// Extracts the value of the specified tag field from the given data. - /// Video metadata - /// The name of the field within the video metadata to read - /// The text content of the requested field of the video metadata - let private extractMetadataText (metadata: VideoMetadata) (fieldName: string) = - match fieldName with - | "title" -> metadata.Title - | "description" -> metadata.Description - | _ -> - // TODO: It would be best to check for invalid entries upon settings deserialization. - raise (ArgumentException($"\"{fieldName}\" is an invalid video metadata field name.")) + cast<'a> (Some joinedMatchedText) defaultValue diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs index ba442c92..e2c78aaf 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -1,6 +1,9 @@ namespace CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.FSharp.Settings +open System +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.PostProcessing /// Provides methods to search for specific tag field data (artist, album, etc.) within video metadata. type TagDetector(tagDetectionPatterns: TagDetectionPatterns) = @@ -28,7 +31,7 @@ type TagDetector(tagDetectionPatterns: TagDetectionPatterns) = | None, None -> None /// Detects the album from video metadata - member this.DetectAlbum(videoData: VideoMetadata, ?defaultAlbum: string) : string option = + member this.DetectAlbum(videoData: VideoMetadata, defaultAlbum: string option) : string option = let detectedAlbum = Detectors.detectSingle videoData this.Patterns.Album None @@ -39,12 +42,12 @@ type TagDetector(tagDetectionPatterns: TagDetectionPatterns) = /// Detects composers from video metadata member this.DetectComposers(videoData: VideoMetadata) : string option = - Detectors.detectMultiple videoData this.Patterns.Composer None "; " + Detectors.detectMultiple videoData this.Patterns.Composer (String.Empty) "; " |> Some /// Detects the release year from video metadata - member this.DetectReleaseYear(videoData: VideoMetadata, ?defaultYear: uint16) : uint16 option = + member this.DetectReleaseYear(videoData: VideoMetadata, defaultYear: uint32 option) : uint32 option = let detectedYear = - Detectors.detectSingle videoData this.Patterns.Year None + Detectors.detectSingle videoData this.Patterns.Year None match detectedYear, defaultYear with | Some year, _ -> Some year diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 7bba2b9e..3b1aa4e4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -4,10 +4,13 @@ open System open System.IO open System.Text.Json open System.Linq -open CCVTAC.FSharp.Downloading -open CCVTAC.FSharp.Settings -open CCVTAC.Console.ExternalTools -open TagLib +open CCVTAC.Console +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.PostProcessing +open CCVTAC.Console.Downloading +open CCVTAC.Console.Downloading.Downloading +open ExtensionMethods type TaggedFile = TagLib.File @@ -18,57 +21,79 @@ module Tagger = let ts = watch.Elapsed if ts.TotalHours >= 1.0 then sprintf "%d:%02d:%02d" (int ts.TotalHours) ts.Minutes ts.Seconds elif ts.TotalMinutes >= 1.0 then sprintf "%d:%02d" ts.Minutes ts.Seconds - else sprintf "%d ms" ts.TotalMilliseconds + else sprintf "%A ms" ts.TotalMilliseconds // TODO: %d didn't work - let Run - (settings: UserSettings) - (taggingSets: seq) - (collectionJson: CollectionMetadata option) - (mediaType: MediaType) - (printer: Printer) - : Result = - printer.Debug "Adding file tags..." - let watch = System.Diagnostics.Stopwatch.StartNew() - let embedImages = settings.EmbedImages && (mediaType.IsVideo || mediaType.IsPlaylistVideo) + let private ParseVideoJson (taggingSet: TaggingSet) : Result = + try + let json = File.ReadAllText taggingSet.JsonFilePath + try + #nowarn 3265 + let videoData = JsonSerializer.Deserialize(json) + #warnon 3265 - for taggingSet in taggingSets do - ProcessSingleTaggingSet settings taggingSet collectionJson embedImages printer + if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) + else Ok videoData + with + | :? JsonException as ex -> Error (sprintf "%s%s%s" ex.Message Environment.NewLine ex.StackTrace) + with ex -> + Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) - Ok (sprintf "Tagging done in %s." (watchFriendly watch)) - and private ProcessSingleTaggingSet - (settings: UserSettings) - (taggingSet: TaggingSet) - (collectionJson: CollectionMetadata option) - (embedImages: bool) - (printer: Printer) - = - printer.Debug (sprintf "%d audio file(s) with resource ID \"%s\"" taggingSet.AudioFilePaths.Count taggingSet.ResourceId) + let private DeleteSourceFile (taggingSet: TaggingSet) (printer: Printer) : TaggingSet = + if taggingSet.AudioFilePaths.Length <= 1 then taggingSet + else + let largestFileInfo = + taggingSet.AudioFilePaths + |> Seq.map (fun fn -> FileInfo(fn)) + |> Seq.sortByDescending (fun fi -> fi.Length) + |> Seq.head - match ParseVideoJson taggingSet with - | Error err -> - printer.Errors (sprintf "Error deserializing video metadata from \"%s\":" taggingSet.JsonFilePath) (Error err) - () - | Ok videoData -> - let finalTaggingSet = DeleteSourceFile taggingSet printer + try + File.Delete largestFileInfo.FullName + printer.Debug (sprintf "Deleted pre-split source file \"%s\"" largestFileInfo.Name) + { taggingSet with AudioFilePaths = taggingSet.AudioFilePaths |> List.except [largestFileInfo.FullName] } + with ex -> + printer.Error (sprintf "Error deleting pre-split source file \"%s\": %s" largestFileInfo.Name ex.Message) + taggingSet - let maybeImagePath = - if embedImages && finalTaggingSet.AudioFilePaths.Count = 1 then - finalTaggingSet.ImageFilePath - else - null + let private WriteImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = + if String.IsNullOrWhiteSpace imageFilePath then + printer.Error "No image file path was provided, so cannot add an image to the file." + else + try + let pics = Array.zeroCreate 1 + pics.[0] <- TagLib.Picture(imageFilePath) + taggedFile.Tag.Pictures <- pics + printer.Debug "Image written to file tags OK." + with ex -> + printer.Error (sprintf "Error writing image to the audio file: %s" ex.Message) - for audioPath in finalTaggingSet.AudioFilePaths do - try - TagSingleFile(settings, videoData, audioPath, maybeImagePath, collectionJson, printer) - with ex -> - printer.Error (sprintf "Error tagging file: %s" ex.Message) - and private TagSingleFile + /// If the supplied video uploader is specified in the settings, returns the video's upload year. + /// Otherwise, returns null (Nullable). + let GetAppropriateReleaseDateIfAny (settings: UserSettings) (videoData: VideoMetadata) : uint32 option = + // If an ignore list exists and contains the uploader (case-insensitive), return null + if Seq.exists + (fun u -> String.Equals(u, videoData.Uploader, StringComparison.OrdinalIgnoreCase)) + settings.IgnoreUploadYearUploaders + then + None + else + // Try to parse the first 4 characters of UploadDate as a year + if videoData.UploadDate.Length < 4 + then + None + else + let yearStr = videoData.UploadDate.Substring(0, 4) + match UInt32.TryParse(yearStr) with + | true, parsed -> Some parsed + | _ -> None + + let private TagSingleFile (settings: UserSettings) (videoData: VideoMetadata) (audioFilePath: string) - (imageFilePath: string) + (imageFilePath: string option) (collectionData: CollectionMetadata option) (printer: Printer) = @@ -79,131 +104,152 @@ module Tagger = let tagDetector = TagDetector(settings.TagDetectionPatterns) // Title - match videoData.Track with - | null -> - let title = tagDetector.DetectTitle(videoData, videoData.Title) - printer.Debug (sprintf "• Found title \"%s\"" title) - taggedFile.Tag.Title <- title - | metadataTitle -> - printer.Debug (sprintf "• Using metadata title \"%s\"" metadataTitle) - taggedFile.Tag.Title <- metadataTitle + // match videoData.Track with + // | NonNull (metadataTitle: string) -> + // printer.Debug (sprintf "• Using metadata title \"%s\"" metadataTitle) + // taggedFile.Tag.Title <- metadataTitle + // | Null -> + // let title = tagDetector.DetectTitle(videoData, videoData.Title) + // printer.Debug (sprintf "• Found title \"%s\"" title) + // taggedFile.Tag.Title <- title + if not (String.IsNullOrWhiteSpace videoData.Track) then + printer.Debug $"• Using metadata title \"%s{videoData.Track}\"" + taggedFile.Tag.Title <- videoData.Track + else + match tagDetector.DetectTitle(videoData, videoData.Title) with + | Some title -> + printer.Debug $"• Found title \"%s{title}\"" + taggedFile.Tag.Title <- title + | None -> printer.Debug "No title was found." // Artist / Performers - if not (String.IsNullOrWhiteSpace(videoData.Artist)) then + if hasText videoData.Artist false then let metadataArtists = videoData.Artist - let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None).[0] - let diffSummary = if firstArtist = metadataArtists then "" else sprintf " (extracted from \"%s\")" metadataArtists + let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None)[0] + let diffSummary = + if firstArtist = metadataArtists + then String.Empty + else $" (extracted from \"%s{metadataArtists}\")" taggedFile.Tag.Performers <- [| firstArtist |] - printer.Debug (sprintf "• Using metadata artist \"%s\"%s" firstArtist diffSummary) + printer.Debug $"• Using metadata artist \"%s{firstArtist}\"%s{diffSummary}" else - match tagDetector.DetectArtist(videoData) with - | null -> () - | artist -> - printer.Debug (sprintf "• Found artist \"%s\"" artist) + match tagDetector.DetectArtist videoData with + | None -> () + | Some artist -> + printer.Debug $"• Found artist \"%s{artist}\"" taggedFile.Tag.Performers <- [| artist |] // Album - if not (String.IsNullOrWhiteSpace(videoData.Album)) then - printer.Debug (sprintf "• Using metadata album \"%s\"" videoData.Album) + if hasText videoData.Album false then + printer.Debug $"• Using metadata album \"%s{videoData.Album}\"" taggedFile.Tag.Album <- videoData.Album else - match tagDetector.DetectAlbum(videoData, collectionData |> Option.map (fun c -> c.Title) |> Option.toObj) with - | null -> () - | album -> - printer.Debug (sprintf "• Found album \"%s\"" album) + let collectionTitle = collectionData |> Option.map _.Title + match tagDetector.DetectAlbum(videoData, collectionTitle) with + | None -> () + | Some album -> + printer.Debug $"• Found album \"%s{album}\"" taggedFile.Tag.Album <- album // Composers match tagDetector.DetectComposers(videoData) with - | null -> () - | composers -> + | None -> () + | Some composers -> printer.Debug (sprintf "• Found composer(s) \"%s\"" composers) taggedFile.Tag.Composers <- [| composers |] // Track number match videoData.PlaylistIndex with - | null -> () - | trackNo -> + | NullV -> () + | NonNullV (trackNo: uint32) -> printer.Debug (sprintf "• Using playlist index of %d for track number" trackNo) taggedFile.Tag.Track <- uint32 trackNo // Year - if videoData.ReleaseYear <> null then - printer.Debug (sprintf "• Using metadata release year \"%d\"" videoData.ReleaseYear) - taggedFile.Tag.Year <- videoData.ReleaseYear - else + match videoData.ReleaseYear with + | NonNullV (year: uint32) -> + printer.Debug $"• Using metadata release year \"%d{year}\"" + taggedFile.Tag.Year <- year + | NullV -> let maybeDefaultYear = - let rec GetAppropriateReleaseDateIfAny (settings: UserSettings) (videoData: VideoMetadata) = - if settings.IgnoreUploadYearUploaders |> Option.isSome && - settings.IgnoreUploadYearUploaders.Value.Contains(videoData.Uploader, StringComparer.OrdinalIgnoreCase) then - None - else - if String.IsNullOrEmpty videoData.UploadDate then None - else - let prefix = if videoData.UploadDate.Length >= 4 then videoData.UploadDate.Substring(0,4) else "" - match UInt16.TryParse prefix with - | true, parsed -> Some parsed - | _ -> None + // let rec GetAppropriateReleaseDateIfAny (settings: UserSettings) (videoData: VideoMetadata) = + // if settings.IgnoreUploadYearUploaders.Contains(videoData.Uploader, StringComparer.OrdinalIgnoreCase) + // then + // None + // else + // if String.IsNullOrEmpty videoData.UploadDate then None + // else + // let prefix = if videoData.UploadDate.Length >= 4 then videoData.UploadDate.Substring(0,4) else "" + // match UInt16.TryParse prefix with + // | true, parsed -> Some parsed + // | _ -> None GetAppropriateReleaseDateIfAny settings videoData match tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) with - | null -> () - | year -> - printer.Debug (sprintf "• Found year \"%d\"" year) + | None -> () + | Some year -> + printer.Debug $"• Found year \"%d{year}\"" taggedFile.Tag.Year <- year // Comment - taggedFile.Tag.Comment <- videoData.GenerateComment(collectionData) + taggedFile.Tag.Comment <- videoData.GenerateComment collectionData // Artwork embedding - if settings.EmbedImages && - (not (settings.DoNotEmbedImageUploaders.Contains(videoData.Uploader))) && - (not (String.IsNullOrWhiteSpace imageFilePath)) then - printer.Info "Embedding artwork." - WriteImage(taggedFile, imageFilePath, printer) - else - printer.Debug "Skipping artwork embedding." + match imageFilePath with + | Some path -> + if settings.EmbedImages && + (not (settings.DoNotEmbedImageUploaders.Contains(videoData.Uploader))) + then + printer.Info "Embedding artwork." + WriteImage taggedFile path printer + else + printer.Debug "Skipping artwork embedding." + | None -> printer.Debug "Skipping artwork embedding." taggedFile.Save() - printer.Debug (sprintf "Wrote tags to \"%s\"." audioFileName) + printer.Debug $"Wrote tags to \"%s{audioFileName}\"." - and private ParseVideoJson (taggingSet: TaggingSet) : Result = - try - let json = File.ReadAllText taggingSet.JsonFilePath - try - let videoData = JsonSerializer.Deserialize(json) - if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) - else Ok videoData - with - | :? JsonException as ex -> Error (sprintf "%s%s%s" ex.Message Environment.NewLine ex.StackTrace) - with ex -> - Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) + let private ProcessSingleTaggingSet + (settings: UserSettings) + (taggingSet: TaggingSet) + (collectionJson: CollectionMetadata option) + (embedImages: bool) + (printer: Printer) + : unit + = - and private DeleteSourceFile (taggingSet: TaggingSet) (printer: Printer) : TaggingSet = - if taggingSet.AudioFilePaths.Count <= 1 then taggingSet - else - let largestFileInfo = - taggingSet.AudioFilePaths - |> Seq.map (fun fn -> FileInfo(fn)) - |> Seq.sortByDescending (fun fi -> fi.Length) - |> Seq.head + printer.Debug $"%d{taggingSet.AudioFilePaths.Length} audio file(s) with resource ID \"%s{taggingSet.ResourceId}\"" - try - File.Delete largestFileInfo.FullName - printer.Debug (sprintf "Deleted pre-split source file \"%s\"" largestFileInfo.Name) - { taggingSet with AudioFilePaths = taggingSet.AudioFilePaths.Remove(largestFileInfo.FullName) } - with ex -> - printer.Error (sprintf "Error deleting pre-split source file \"%s\": %s" largestFileInfo.Name ex.Message) - taggingSet + match ParseVideoJson taggingSet with + | Error err -> + printer.Error $"Error deserializing video metadata from \"%s{taggingSet.JsonFilePath}\": {err}" + | Ok videoData -> + let finalTaggingSet = DeleteSourceFile taggingSet printer - and private WriteImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = - if String.IsNullOrWhiteSpace imageFilePath then - printer.Error "No image file path was provided, so cannot add an image to the file." - else - try - let pics = Array.zeroCreate 1 - pics.[0] <- TagLib.Picture(imageFilePath) - taggedFile.Tag.Pictures <- pics - printer.Debug "Image written to file tags OK." - with ex -> - printer.Error (sprintf "Error writing image to the audio file: %s" ex.Message) + let imagePath = + if embedImages && finalTaggingSet.AudioFilePaths.Length = 1 then + Some finalTaggingSet.ImageFilePath + else + None + + for audioPath in finalTaggingSet.AudioFilePaths do + try + TagSingleFile settings videoData audioPath imagePath collectionJson printer + with ex -> + printer.Error (sprintf "Error tagging file: %s" ex.Message) + + let Run + (settings: UserSettings) + (taggingSets: seq) + (collectionJson: CollectionMetadata option) + (mediaType: MediaType) + (printer: Printer) + : Result = + printer.Debug "Adding file tags..." + let watch = System.Diagnostics.Stopwatch.StartNew() + let embedImages = settings.EmbedImages && (mediaType.IsVideo || mediaType.IsPlaylistVideo) + + for taggingSet in taggingSets do + ProcessSingleTaggingSet settings taggingSet collectionJson embedImages printer + + Ok (sprintf "Tagging done in %s." (watchFriendly watch)) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 54425503..c892ac03 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -5,12 +5,14 @@ open System.IO open System.Text.RegularExpressions open System.Collections.Generic open System.Collections.Immutable +open CCVTAC.Console +open ExtensionMethods /// Contains all the data necessary for tagging a related set of files. [] type TaggingSet = { ResourceId: string - AudioFilePaths: ImmutableHashSet + AudioFilePaths: string list JsonFilePath: string ImageFilePath: string } /// Expose all related files as a read-only list @@ -35,15 +37,16 @@ type TaggingSet = let imageTrimmed = imageFilePath.Trim() let audioSet = ImmutableHashSet.CreateRange(StringComparer.OrdinalIgnoreCase, audioFilePaths) { ResourceId = resourceIdTrimmed - AudioFilePaths = audioSet + AudioFilePaths = audioSet |> Seq.toList JsonFilePath = jsonTrimmed ImageFilePath = imageTrimmed } /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. /// Files that don't match the requirements will be ignored. - static member CreateSets (filePaths: ICollection) : ImmutableList = - if isNull filePaths || filePaths.Count = 0 then - ImmutableList.Empty + // static member CreateSets (filePaths: ICollection) : TaggingSet list = + static member CreateSets (filePaths: ICollection) : TaggingSet list = + if Seq.isEmpty filePaths then + [] else let jsonFileExt = ".json" let imageFileExt = ".jpg" @@ -53,29 +56,36 @@ type TaggingSet = Regex(@".+\[([\w_\-]{11})\](?:.*)?\.(\w+)", RegexOptions.Compiled) filePaths - |> Seq.map (fun f -> fileNamesWithVideoIdsRegex.Match(f)) - |> Seq.filter (fun m -> m.Success) - |> Seq.map (fun m -> m.Groups.[0].Value, m.Groups.[1].Value) // (fullFilename, videoId) - |> Seq.groupBy snd // group by videoId -> seq of (fullFilename, videoId) - |> Seq.map (fun (videoId, seqFiles) -> videoId, seqFiles |> Seq.map fst) - |> Seq.filter (fun (_videoId, files) -> - let filesList = files |> Seq.toList - // contains at least one audio file - filesList - |> Seq.exists (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(Path.GetExtension(f))) - // exactly one json and exactly one image - && (filesList |> Seq.filter (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length = 1) - && (filesList |> Seq.filter (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length = 1) - ) - |> Seq.map (fun (videoId, files) -> - let filesList = files |> Seq.toList + |> Seq.map fileNamesWithVideoIdsRegex.Match + |> Seq.filter _.Success + |> Seq.map (fun m -> m.Captures |> Seq.cast |> Seq.head) + |> Seq.groupBy (fun m -> m.Groups[1].Value) //(fun m -> m.Groups.[0].Value) + |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) + |> Seq.filter (fun (_, files) -> + let filesSeq = files |> Seq.toArray + let isSupportedExtension = + filesSeq + |> Seq.exists (fun f -> + let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. + caseInsensitiveContains AudioExtensions f') + let jsonCount = + filesSeq |> Seq.filter (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length + let imageCount = + filesSeq |> Seq.filter (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length + isSupportedExtension && jsonCount = 1 && imageCount = 1) + |> Seq.map (fun (key, files) -> + let filesArr = files |> Seq.toArray let audioFiles = - filesList - |> Seq.filter (fun f -> PostProcessor.AudioExtensions.CaseInsensitiveContains(Path.GetExtension(f))) + filesArr + |> Seq.filter (fun f -> + let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. + caseInsensitiveContains AudioExtensions f') |> Seq.toList - :> ICollection - let jsonFile = filesList |> Seq.find (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) - let imageFile = filesList |> Seq.find (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) - TaggingSet.CreateValidated(videoId, audioFiles, jsonFile, imageFile) - ) - |> ImmutableList.CreateRange + let jsonFile = filesArr |> Seq.find (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) + let imageFile = filesArr |> Seq.find (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) + + { ResourceId = key + AudioFilePaths = audioFiles + JsonFilePath = jsonFile + ImageFilePath = imageFile }) + |> List.ofSeq diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index 954cd765..96d81fa4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -2,6 +2,7 @@ namespace CCVTAC.Console.PostProcessing open System open System.Collections.Generic +open System.Text open System.Text.Json.Serialization [] @@ -78,3 +79,63 @@ type VideoMetadata = [] Abr: Nullable [] Format: string [] Type: string } + + /// Returns a string summarizing video uploader information. + member private this.UploaderSummary() : string = + let uploaderLinkOrIdOrEmpty = + if not (String.IsNullOrWhiteSpace this.UploaderUrl) then this.UploaderUrl + elif not (String.IsNullOrWhiteSpace this.UploaderId) then this.UploaderId + else String.Empty + + let suffix = if not (String.IsNullOrWhiteSpace uploaderLinkOrIdOrEmpty) then sprintf " (%s)" uploaderLinkOrIdOrEmpty else String.Empty + this.Uploader + suffix + + /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") + /// from the plain YYYYMMDD version (e.g., "20230827"). + member private this.FormattedUploadDate() : string = + // Assumes UploadDate has at least 8 characters (YYYYMMDD) + let y = if String.IsNullOrEmpty this.UploadDate then "" else this.UploadDate[0..3] + let m = if this.UploadDate.Length >= 6 then this.UploadDate[4..5] else "" + let d = if this.UploadDate.Length >= 8 then this.UploadDate[6..7] else "" + sprintf "%s/%s/%s" m d y + + /// Returns a formatted comment using data parsed from the JSON file. + member this.GenerateComment(maybeCollectionData: CollectionMetadata option) : string = + let sb = StringBuilder() + sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore + sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore + sb.AppendLine(sprintf "■ URL: %s" this.WebpageUrl) |> ignore + sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore + sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore + + if not (String.IsNullOrWhiteSpace this.Creator) && this.Creator <> this.Uploader then + sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore + + if not (String.IsNullOrWhiteSpace this.Artist) then + sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore + + if not (String.IsNullOrWhiteSpace this.Album) then + sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore + + if not (String.IsNullOrWhiteSpace this.Title) && this.Title <> this.Fulltitle then + sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore + + sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore + + let description = + if String.IsNullOrWhiteSpace this.Description then "None." else this.Description + + sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore + + match maybeCollectionData with + | Some collectionData -> + sb.AppendLine() |> ignore + sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore + sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore + match this.PlaylistIndex with + | NonNullV (index: uint32) -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore + | NullV -> () + sb.AppendLine(sprintf "■ Playlist description: %s" (if String.IsNullOrWhiteSpace collectionData.Description then String.Empty else collectionData.Description)) |> ignore + | None -> () + + sb.ToString() diff --git a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs index 463233b6..0b167d5f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs +++ b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs @@ -3,63 +3,65 @@ namespace CCVTAC.Console.PostProcessing open System open System.Text -type VideoMetadata with +module YouTubeMetadataExtensionMethods = + type VideoMetadata with - /// Returns a string summarizing video uploader information. - member private this.UploaderSummary() : string = - let uploaderLinkOrIdOrEmpty = - if not (String.IsNullOrWhiteSpace this.UploaderUrl) then this.UploaderUrl - elif not (String.IsNullOrWhiteSpace this.UploaderId) then this.UploaderId - else String.Empty + /// Returns a string summarizing video uploader information. + member private this.UploaderSummary() : string = + let uploaderLinkOrIdOrEmpty = + if not (String.IsNullOrWhiteSpace this.UploaderUrl) then this.UploaderUrl + elif not (String.IsNullOrWhiteSpace this.UploaderId) then this.UploaderId + else String.Empty - let suffix = if not (String.IsNullOrWhiteSpace uploaderLinkOrIdOrEmpty) then sprintf " (%s)" uploaderLinkOrIdOrEmpty else String.Empty - this.Uploader + suffix + let suffix = if not (String.IsNullOrWhiteSpace uploaderLinkOrIdOrEmpty) then sprintf " (%s)" uploaderLinkOrIdOrEmpty else String.Empty + this.Uploader + suffix - /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") - /// from the plain YYYYMMDD version (e.g., "20230827"). - member private this.FormattedUploadDate() : string = - // Assumes UploadDate has at least 8 characters (YYYYMMDD) - let y = if String.IsNullOrEmpty this.UploadDate then "" else this.UploadDate.[0..3] - let m = if this.UploadDate.Length >= 6 then this.UploadDate.[4..5] else "" - let d = if this.UploadDate.Length >= 8 then this.UploadDate.[6..7] else "" - sprintf "%s/%s/%s" m d y + /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") + /// from the plain YYYYMMDD version (e.g., "20230827"). + member private this.FormattedUploadDate() : string = + // Assumes UploadDate has at least 8 characters (YYYYMMDD) + let y = if String.IsNullOrEmpty this.UploadDate then "" else this.UploadDate.[0..3] + let m = if this.UploadDate.Length >= 6 then this.UploadDate.[4..5] else "" + let d = if this.UploadDate.Length >= 8 then this.UploadDate.[6..7] else "" + sprintf "%s/%s/%s" m d y - /// Returns a formatted comment using data parsed from the JSON file. - member this.GenerateComment(maybeCollectionData: CollectionMetadata option) : string = - let sb = StringBuilder() - sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore - sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore - sb.AppendLine(sprintf "■ URL: %s" this.WebpageUrl) |> ignore - sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore - sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore + /// Returns a formatted comment using data parsed from the JSON file. + member this.GenerateComment(maybeCollectionData: CollectionMetadata option) : string = + let sb = StringBuilder() + sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore + sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore + sb.AppendLine(sprintf "■ URL: %s" this.WebpageUrl) |> ignore + sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore + sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - if not (String.IsNullOrWhiteSpace this.Creator) && this.Creator <> this.Uploader then - sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore + if not (String.IsNullOrWhiteSpace this.Creator) && this.Creator <> this.Uploader then + sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore - if not (String.IsNullOrWhiteSpace this.Artist) then - sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore + if not (String.IsNullOrWhiteSpace this.Artist) then + sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore - if not (String.IsNullOrWhiteSpace this.Album) then - sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore + if not (String.IsNullOrWhiteSpace this.Album) then + sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore - if not (String.IsNullOrWhiteSpace this.Title) && this.Title <> this.Fulltitle then - sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore + if not (String.IsNullOrWhiteSpace this.Title) && this.Title <> this.Fulltitle then + sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore - sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore + sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore - let description = - if String.IsNullOrWhiteSpace this.Description then "None." else this.Description + let description = + if String.IsNullOrWhiteSpace this.Description then "None." else this.Description - sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore + sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore - match maybeCollectionData with - | Some collectionData -> - sb.AppendLine() |> ignore - sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore - sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore - match this.PlaylistIndex with - | Nullable index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore - sb.AppendLine(sprintf "■ Playlist description: %s" (if String.IsNullOrWhiteSpace collectionData.Description then String.Empty else collectionData.Description)) |> ignore - | None -> () + match maybeCollectionData with + | Some collectionData -> + sb.AppendLine() |> ignore + sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore + sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore + match this.PlaylistIndex with + | NullV -> () + | NonNullV index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if String.IsNullOrWhiteSpace collectionData.Description then String.Empty else collectionData.Description)) |> ignore + | None -> () - sb.ToString() + sb.ToString() diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index f258c42b..9482bd99 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -119,11 +119,9 @@ type Printer(showDebug: bool) = member this.Errors<'a>(headerMessage: string, failingResult: Result<'a, string[]>) = this.Errors(headerMessage, extractedErrors failingResult) - member this.FirstError(failResult: Result<'a, string[]>, ?prepend: string) = - let pre = defaultArg prepend null - let prefix = if isNull pre then String.Empty else $"{pre} " - // let message = (if isNull failResult.Errors then String.Empty else (failResult.Errors.FirstOrDefault()?.Message ?? String.Empty)) - let message = extractedErrors failResult |> Seq.head + // member this.FirstError(failResult: Result<'a, string[]>, ?prepend: string) = + member this.FirstError(message: string, ?prepend: string) = + let prefix = match prepend with Some x -> x | None -> String.Empty this.Error($"{prefix}{message}") member this.Warning(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index fcae0bdd..c736f1f1 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -5,6 +5,13 @@ open System.Linq open Spectre.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings +open ExtensionMethods +open CCVTAC.Console +open CCVTAC.Console.Settings +open CCVTAC.Console.Settings.Settings.IO +open CCVTAC.Console.Settings.Settings.LiveUpdating +open CCVTAC.Console.Settings.Settings.Validation module Program = @@ -16,48 +23,49 @@ module Program = let main (args: string[]) : int = let printer = Printer(showDebug = true) - if args.Length > 0 && ExtensionMethods.CaseInsensitiveContains(helpFlags, args.[0]) then + if args.Length > 0 && caseInsensitiveContains helpFlags args[0] then Help.Print(printer) 0 else let maybeSettingsPath = - if args.Length >= 2 && ExtensionMethods.CaseInsensitiveContains(settingsFileFlags, args.[0]) then + if args.Length >= 2 && caseInsensitiveContains settingsFileFlags args[0] then args.[1] // expected to be a settings file path else defaultSettingsFileName - match SettingsAdapter.ProcessSettings(maybeSettingsPath, printer) with - | Error errs -> - // Errors prints the messages and exits - printer.Errors(errs.Select(fun e -> e.Message).ToList()) + // match SettingsAdapter.ProcessSettings(maybeSettingsPath, printer) with + let readResult = Settings.IO.read (FilePath maybeSettingsPath) + match readResult with + | Error e -> + printer.Error(e) 1 - | Ok None -> - // A new settings file was created; nothing more to do - 0 - | Ok (Some settings) -> - SettingsAdapter.PrintSummary(settings, printer, header = "Settings loaded OK.") + // | Ok None -> + // // A new settings file was created; nothing more to do + // 0 + | Ok settings -> + Settings.PrintSummary settings printer (Some "Settings loaded OK.") printer.ShowDebug(not settings.QuietMode) // Catch Ctrl-C (SIGINT) Console.CancelKeyPress.Add(fun args -> printer.Warning("\nQuitting at user's request.") - match Directories.WarnIfAnyFiles(settings.WorkingDirectory, 10) with + match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Ok () -> () | Error warnResult -> printer.FirstError(warnResult) - match Directories.AskToDeleteAllFiles(settings.WorkingDirectory, printer) with - | Ok deletedCount -> printer.Info(sprintf "%d file(s) deleted." deletedCount) + match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with + | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." | Error delErr -> printer.FirstError(delErr) // Do not set args.Cancel here; let default behavior terminate the process ) // Top-level try to catch unexpected exceptions and report them try - Orchestrator.Start(settings, printer) + Orchestrator.start settings printer 0 with ex -> - printer.Critical(sprintf "Fatal error: %s" ex.Message) + printer.Critical $"Fatal error: %s{ex.Message}" AnsiConsole.WriteException(ex) printer.Info( "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 9d1385ab..66e0d871 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -2,27 +2,33 @@ namespace CCVTAC.Console open System open System.Collections.Generic -open System.Linq -type ResultTracker<'T>(printer: Printer) = +type ResultTracker<'a>(printer: Printer) = let mutable successCount : uint64 = 0UL + let failures = Dictionary() + let _printer = if isNull (box printer) then nullArg "printer" else printer - static let combineErrors (result: Result<'T>) = - String.Join(" / ", result.Errors.Select(fun e -> e.Message)) + static let combineErrors (errors: string list) = + String.Join(" / ", errors) + + member _.AddSuccess() : unit = + successCount <- successCount + 1UL + + member _.AddFailure(url: string, error: string) : unit = + failures.Add(url, error) /// Logs the result for a specific corresponding input. - member _.RegisterResult(input: string, result: Result<'T>) : unit = - if result.IsSuccess then + member _.RegisterResult(input: string, result: Result<'a, string>) : unit = + match result with + | Ok _ -> successCount <- successCount + 1UL - else - let errors = combineErrors result - if not (failures.TryAdd(input, errors)) then - // Keep only the latest error for each input. - failures.[input] <- errors + | Error e -> + if not (failures.TryAdd(input, e)) then + failures[input] <- e /// Prints any failures for the current batch. member _.PrintBatchFailures() : unit = @@ -30,16 +36,16 @@ type ResultTracker<'T>(printer: Printer) = _printer.Debug("No failures in batch.") else let failureLabel = if failures.Count = 1 then "failure" else "failures" - _printer.Info(sprintf "%d %s in this batch:" failures.Count failureLabel) + _printer.Info $"%d{failures.Count} %s{failureLabel} in this batch:" for kvp in failures do - _printer.Warning(sprintf "- %s: %s" kvp.Key kvp.Value) + _printer.Warning $"- %s{kvp.Key}: %s{kvp.Value}" /// Prints the output for the current application session. member _.PrintSessionSummary() : unit = let successLabel = if successCount = 1UL then "success" else "successes" let failureLabel = if failures.Count = 1 then "failure" else "failures" - _printer.Info(sprintf "Quitting with %d %s and %d %s." successCount successLabel failures.Count failureLabel) + _printer.Info $"Quitting with %d{successCount} %s{successLabel} and %d{failures.Count} %s{failureLabel}." for kvp in failures do - _printer.Warning(sprintf "- %s: %s" kvp.Key kvp.Value) + _printer.Warning $"- %s{kvp.Key}: %s{kvp.Value}" diff --git a/src/CCVTAC.FSharp/Settings/Id3Version.fs b/src/CCVTAC.FSharp/Settings/Id3Version.fs index f433d91c..cb62fa1d 100644 --- a/src/CCVTAC.FSharp/Settings/Id3Version.fs +++ b/src/CCVTAC.FSharp/Settings/Id3Version.fs @@ -1,7 +1,5 @@ namespace CCVTAC.Console.Settings -open System - module TagFormat = /// Point versions of ID3 version 2 (such as 2.3 or 2.4). diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 71c5a35f..3b632d79 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -1,8 +1,10 @@ -namespace CCVTAC.FSharp +namespace CCVTAC.Console.Settings module Settings = open System open System.Text.Json.Serialization + open CCVTAC.Console + open ExtensionMethods let newLine = Environment.NewLine @@ -16,7 +18,7 @@ module Settings = type TagDetectionPattern = { [] RegexPattern : string - [] MatchGroup : byte + [] MatchGroup : int // byte [] SearchField : string [] Summary : string option } @@ -84,19 +86,44 @@ module Settings = ("Rename patterns", settings.RenamePatterns.Length |> pluralize "pattern") ] + open System + open Spectre.Console + + /// Prints a summary of the given settings. + let PrintSummary (settings: UserSettings) (printer: Printer) (header: string option) : unit = + match header with + | Some h when hasText h false -> + printer.Info(h) + | _ -> () + + let table = Table() + table.Expand() |> ignore + table.Border <- TableBorder.HeavyEdge + table.BorderColor(Color.Grey27) |> ignore + table.AddColumns("Name", "Value") |> ignore + table.HideHeaders() |> ignore + table.Columns.[1].Width <- 100 // Ensure maximum width. + + let settingPairs = summarize(settings) + for pair in settingPairs do + table.AddRow(fst pair, snd pair) |> ignore + + Printer.PrintTable(table) + + module Validation = open System.IO let validate settings = - let isEmpty str = str |> String.IsNullOrWhiteSpace + let isEmpty str = String.IsNullOrWhiteSpace str let dirMissing str = not (Directory.Exists str) // Source: https://github.com/yt-dlp/yt-dlp/?tab=readme-ov-file#post-processing-options + // TODO: Check similar item in Shared module. let supportedAudioFormats = [| "best"; "aac"; "alac"; "flac"; "m4a"; "mp3"; "opus"; "vorbis"; "wav" |] let supportedNormalizationForms = [| "C"; "D"; "KC"; "KD" |] - let validAudioFormat fmt = - supportedAudioFormats |> Array.contains fmt + let validAudioFormat fmt = supportedAudioFormats |> Array.contains fmt match settings with | { WorkingDirectory = d } when d |> isEmpty -> @@ -127,11 +154,13 @@ module Settings = open System.Text.Encodings.Web open Validation - let deserialize<'a> (json: string) = + let deserialize<'a> (json: string) : Result<'a, string> = let options = JsonSerializerOptions() options.AllowTrailingCommas <- true options.ReadCommentHandling <- JsonCommentHandling.Skip - JsonSerializer.Deserialize<'a>(json, options) + match JsonSerializer.Deserialize<'a>(json, options) with // TODO: Add exception handling. + | null -> Error "Could not deserialize the JSON" + | s -> Ok s [] let fileExists (FilePath path) = @@ -145,7 +174,7 @@ module Settings = path |> File.ReadAllText |> deserialize - |> validate + |> Result.bind validate with | :? FileNotFoundException -> Error $"File \"{path}\" was not found." | :? JsonException as e -> Error $"Parse error in \"{path}\": {e.Message}" @@ -221,8 +250,8 @@ module Settings = { settings with QuietMode = toggledSetting } [] - let updateAudioFormat settings newFormat = - let updatedSettings = { settings with AudioFormats = newFormat} + let updateAudioFormat settings (newFormat: string) = + let updatedSettings = { settings with AudioFormats = newFormat.Split(',')} validate updatedSettings [] diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs new file mode 100644 index 00000000..ceae0912 --- /dev/null +++ b/src/CCVTAC.FSharp/Shared.fs @@ -0,0 +1,7 @@ +namespace CCVTAC.Console + +[] +module Shared = + + let AudioExtensions = + [| ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" |] diff --git a/src/CCVTAC.sln b/src/CCVTAC.sln index de22aa50..506e1a60 100644 --- a/src/CCVTAC.sln +++ b/src/CCVTAC.sln @@ -3,10 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 25.0.1706.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CCVTAC.Console", "CCVTAC.Console\CCVTAC.Console.csproj", "{3A6C143A-C91A-43F8-8849-11C1DF100F7C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CCVTAC.Console.Tests", "CCVTAC.Console.Tests\CCVTAC.Console.Tests.csproj", "{8CA8220D-5E89-4F6D-9AA3-AFA534BC258B}" -EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CCVTAC.FSharp", "CCVTAC.FSharp\CCVTAC.FSharp.fsproj", "{44860E27-2F04-428C-8444-C774627A019F}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CCVTAC.FSharp.Tests", "CCVTAC.FSharp.Tests\CCVTAC.FSharp.Tests.fsproj", "{D9BD0637-C4C1-435B-B202-1447FD85DCDE}" @@ -17,14 +13,6 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {3A6C143A-C91A-43F8-8849-11C1DF100F7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3A6C143A-C91A-43F8-8849-11C1DF100F7C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3A6C143A-C91A-43F8-8849-11C1DF100F7C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3A6C143A-C91A-43F8-8849-11C1DF100F7C}.Release|Any CPU.Build.0 = Release|Any CPU - {8CA8220D-5E89-4F6D-9AA3-AFA534BC258B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8CA8220D-5E89-4F6D-9AA3-AFA534BC258B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8CA8220D-5E89-4F6D-9AA3-AFA534BC258B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8CA8220D-5E89-4F6D-9AA3-AFA534BC258B}.Release|Any CPU.Build.0 = Release|Any CPU {44860E27-2F04-428C-8444-C774627A019F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {44860E27-2F04-428C-8444-C774627A019F}.Debug|Any CPU.Build.0 = Debug|Any CPU {44860E27-2F04-428C-8444-C774627A019F}.Release|Any CPU.ActiveCfg = Release|Any CPU From ce2eecfdb66d70881a3c6a51fd4ed63eee8fe3e9 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:35:37 +0900 Subject: [PATCH 008/247] Change F# library to console app --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 0ca40ec2..28a64d09 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -4,6 +4,7 @@ true true enable + Exe @@ -38,6 +39,7 @@ + From 6b3b1ea9aa91466cab8e17611b8eaae59f2901d2 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:09:50 +0900 Subject: [PATCH 009/247] Fix crash due to invalid error --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 8a6d00a0..554acee7 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -136,6 +136,7 @@ module Downloader = |> String.concat Environment.NewLine Error combined else + errors <- errors |> List.filter (fun x -> not (String.IsNullOrWhiteSpace x)) // TODO: Figure out why needed. // If there were errors from the primary download attempts, print them and continue to post-processing. if errors.Length <> 0 then errors |> List.iter printer.Error From 6d25470419c97c6b471bd5eca354f0a9bca527c0 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:22:49 +0900 Subject: [PATCH 010/247] Fix exception due to empty-string error --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 58 +++++++++------------ 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 554acee7..2731c057 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -81,7 +81,6 @@ module Downloader = let internal WrapUrlInMediaType (url: string) : Result = mediaTypeWithIds url - // if result.IsOk then Result.Ok result.ResultValue else Result.Fail result.ErrorValue /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. @@ -96,21 +95,21 @@ module Downloader = let rawUrls = extractDownloadUrls(mediaType) let urls = - { Primary = rawUrls.[0] - Supplementary = if rawUrls.Length = 2 then Some rawUrls.[1] else None } + { Primary = rawUrls[0] + Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } - // Placeholder for the download result; we'll overwrite as we try formats. let mutable downloadResult : Result = Error "" let mutable successfulFormat : string = "" - let mutable stopped = false + let mutable errors : string list = [] + for format in settings.AudioFormats do if not stopped then let args = GenerateDownloadArgs (Some format) settings (Some mediaType) (Some [| urls.Primary |]) let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory - let downloadResult = Runner.run downloadSettings [| 1 |] printer + downloadResult <- Runner.run downloadSettings [| 1 |] printer match downloadResult with | Ok (exitCode, warning) -> @@ -123,52 +122,45 @@ module Downloader = stopped <- true | Error e -> - printer.Debug(sprintf "Failure downloading \"%s\" format." format) + printer.Debug $"Failure downloading \"%s{format}\" format: %s{e}" // Collect error messages from the last download attempt - let mutable errors = match downloadResult with Error e -> [e] | Ok _ -> [] + errors <- match downloadResult with Error e -> [e] | Ok _ -> [] let audioFileCount = IoUtilities.Directories.audioFileCount settings.WorkingDirectory if audioFileCount = 0 then - let combined = + let combinedErrors = errors - |> Seq.append (seq { yield "No audio files were downloaded." }) + |> List.append ["No audio files were downloaded."] |> String.concat Environment.NewLine - Error combined + Error combinedErrors else - errors <- errors |> List.filter (fun x -> not (String.IsNullOrWhiteSpace x)) // TODO: Figure out why needed. - // If there were errors from the primary download attempts, print them and continue to post-processing. - if errors.Length <> 0 then + // If there were errors, print them and continue to post-processing. + if not (List.isEmpty errors) then errors |> List.iter printer.Error printer.Info("Post-processing will still be attempted.") else - // If no errors and there is a supplementary URL, attempt a metadata-only supplementary download. + // Attempt a metadata-only supplementary download. match urls.Supplementary with - | Some supp -> - let supplementaryArgs = GenerateDownloadArgs None settings None (Some [| supp |]) - let commandWithArgs = $"{ProgramName} {supplementaryArgs}" - let supplementaryDownloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory - - let supplementaryDownloadResult = - Runner.run supplementaryDownloadSettings [| 1 |] printer - - // if supplementaryDownloadResult.IsSuccess then - // printer.Info("Supplementary download completed OK.") - // else - // printer.Error("Supplementary download failed.") - // errors.AddRange(supplementaryDownloadResult.Errors.Select(fun e -> e.Message)) + | Some supplementaryUrl -> + let args = GenerateDownloadArgs None settings None (Some [| supplementaryUrl |]) + let commandWithArgs = $"{ProgramName} {args}" + let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory + + let supplementaryDownloadResult = Runner.run downloadSettings [| 1 |] printer + match supplementaryDownloadResult with | Ok _ -> printer.Info("Supplementary download completed OK.") - | Error e -> + | Error err -> printer.Error("Supplementary download failed.") - errors <- errors |> List.append [e] + errors <- errors |> List.append [err] | None -> () - if errors.Length > 0 then - Error (String.Join(" / ", errors)) - else + if List.isEmpty errors then Ok successfulFormat + else + Error (String.Join(" / ", errors)) From a481166bf16b118cf3ad3465dc1d6beeb6aca0d4 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:32:20 +0900 Subject: [PATCH 011/247] Minor cleanup --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 24 ++++++--------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 2731c057..38a8d322 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -1,16 +1,12 @@ namespace CCVTAC.Console.Downloading open CCVTAC.Console -open CCVTAC.Console.Settings -open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.ExternalTools +open CCVTAC.Console.Settings.Settings open System -open System.Collections.Generic open System.Linq -open CCVTAC.Console.ExternalTools -open CCVTAC.Console.Downloading.Downloading -// open CCVTAC.FSharp.Downloading -// open CCVTAC.Console.Settings.UserSettings +open System.Collections.Generic module Downloader = @@ -34,10 +30,6 @@ module Downloader = let trimFileNames = "--trim-filenames 250" let formatArg = - // if String.IsNullOrWhiteSpace audioFormat || audioFormat = "best" then - // String.Empty - // else - // $"-f {audioFormat}" match audioFormat with | None -> String.Empty | Some format when format = "best" -> String.Empty @@ -60,8 +52,7 @@ module Downloader = ] ) - // quiet vs verbose - args.Add(if settings.QuietMode then "--quiet --no-warnings" else String.Empty) |> ignore + if settings.QuietMode then args.Add "--quiet --no-warnings" |> ignore match mediaType with | Some mt -> @@ -116,15 +107,14 @@ module Downloader = successfulFormat <- format if exitCode <> 0 then - printer.Warning("Downloading completed with minor issues.") + printer.Warning "Downloading completed with minor issues." if not (String.IsNullOrWhiteSpace warning) then - printer.Warning(warning) + printer.Warning warning stopped <- true | Error e -> printer.Debug $"Failure downloading \"%s{format}\" format: %s{e}" - // Collect error messages from the last download attempt errors <- match downloadResult with Error e -> [e] | Ok _ -> [] let audioFileCount = IoUtilities.Directories.audioFileCount settings.WorkingDirectory @@ -135,7 +125,7 @@ module Downloader = |> String.concat Environment.NewLine Error combinedErrors else - // If there were errors, print them and continue to post-processing. + // Continue to post-processing if errors. if not (List.isEmpty errors) then errors |> List.iter printer.Error printer.Info("Post-processing will still be attempted.") From b23bf0be06d7dd476185bc7acf0acda25b55ff62 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:41:20 +0900 Subject: [PATCH 012/247] Replace HashSet with F# Set --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 35 ++++++++++----------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 38a8d322..8b41ef9e 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -5,8 +5,6 @@ open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.ExternalTools open CCVTAC.Console.Settings.Settings open System -open System.Linq -open System.Collections.Generic module Downloader = @@ -38,25 +36,24 @@ module Downloader = let args = match mediaType with | None -> - HashSet([ $"--flat-playlist {writeJson} {trimFileNames}" ]) + [ $"--flat-playlist {writeJson} {trimFileNames}" ] | Some _ -> - HashSet( - [ - "--extract-audio" - formatArg - $"--audio-quality {settings.AudioQuality}" - "--write-thumbnail --convert-thumbnails jpg" - writeJson - trimFileNames - "--retries 2" - ] - ) - - if settings.QuietMode then args.Add "--quiet --no-warnings" |> ignore + [ "--extract-audio" + formatArg + $"--audio-quality {settings.AudioQuality}" + "--write-thumbnail --convert-thumbnails jpg" + writeJson + trimFileNames + "--retries 2" ] + |> Set.ofList + + if settings.QuietMode then + args.Add "--quiet --no-warnings" |> ignore match mediaType with | Some mt -> - if settings.SplitChapters then args.Add("--split-chapters") |> ignore + if settings.SplitChapters then + args.Add("--split-chapters") |> ignore if not mt.IsVideo && not mt.IsPlaylistVideo then args.Add($"--sleep-interval {settings.SleepSecondsBetweenDownloads}") |> ignore @@ -67,8 +64,8 @@ module Downloader = ) |> ignore | None -> () - let extras = defaultArg additionalArgs [||] - String.Join(" ", args.Concat(extras)) + let extras = defaultArg additionalArgs [||] |> Set.ofArray + String.Join(" ", args |> Set.union extras) let internal WrapUrlInMediaType (url: string) : Result = mediaTypeWithIds url From 3695810cbdd5aeff179cc0f35465248fe6fbb06e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:43:54 +0900 Subject: [PATCH 013/247] Rename funcs --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 17 +++++++++-------- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 8b41ef9e..1a2346ae 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -11,13 +11,14 @@ module Downloader = [] let private ProgramName = "yt-dlp" - type Urls = { Primary: string; Supplementary: string option } + type Urls = { Primary: string + Supplementary: string option } /// Generate the entire argument string for the download tool. /// audioFormat: one of the supported audio format codes (or null for none) /// mediaType: Some MediaType for normal downloads, None for metadata-only supplementary downloads /// additionalArgs: optional extra args (e.g., the URL) - let GenerateDownloadArgs + let generateDownloadArgs (audioFormat: string option) (settings: UserSettings) (mediaType: MediaType option) @@ -30,8 +31,8 @@ module Downloader = let formatArg = match audioFormat with | None -> String.Empty - | Some format when format = "best" -> String.Empty - | Some format -> $"-f {format}" + | Some f when f = "best" -> String.Empty + | Some f -> $"-f {f}" let args = match mediaType with @@ -67,12 +68,12 @@ module Downloader = let extras = defaultArg additionalArgs [||] |> Set.ofArray String.Join(" ", args |> Set.union extras) - let internal WrapUrlInMediaType (url: string) : Result = + let internal wrapUrlInMediaType (url: string) : Result = mediaTypeWithIds url /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. - let internal Run + let internal run (mediaType: MediaType) (settings: UserSettings) (printer: Printer) @@ -93,7 +94,7 @@ module Downloader = for format in settings.AudioFormats do if not stopped then - let args = GenerateDownloadArgs (Some format) settings (Some mediaType) (Some [| urls.Primary |]) + let args = generateDownloadArgs (Some format) settings (Some mediaType) (Some [| urls.Primary |]) let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory @@ -130,7 +131,7 @@ module Downloader = // Attempt a metadata-only supplementary download. match urls.Supplementary with | Some supplementaryUrl -> - let args = GenerateDownloadArgs None settings None (Some [| supplementaryUrl |]) + let args = generateDownloadArgs None settings None (Some [| supplementaryUrl |]) let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 579814e7..1c78be77 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -110,7 +110,7 @@ module Orchestrator = printer.Info(sprintf "%s URL '%s' detected." (mediaType.GetType().Name) url) history.Append(url, urlInputTime, printer) - let downloadResult = Downloader.Run mediaType settings printer + let downloadResult = Downloader.run mediaType settings printer resultTracker.RegisterResult(url, downloadResult) match downloadResult with From 33b7246aa22b3c99d1a85dc67947e002ca2d099b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:48:13 +0900 Subject: [PATCH 014/247] Replace array with list --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 6 +++--- src/CCVTAC.FSharp/Downloading/Updater.fs | 2 +- src/CCVTAC.FSharp/Downloading/Uploader.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 11 ++++------- src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs | 3 +-- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 1a2346ae..55980aa3 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -98,7 +98,7 @@ module Downloader = let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory - downloadResult <- Runner.run downloadSettings [| 1 |] printer + downloadResult <- Runner.run downloadSettings [1] printer match downloadResult with | Ok (exitCode, warning) -> @@ -135,14 +135,14 @@ module Downloader = let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory - let supplementaryDownloadResult = Runner.run downloadSettings [| 1 |] printer + let supplementaryDownloadResult = Runner.run downloadSettings [1] printer match supplementaryDownloadResult with | Ok _ -> printer.Info("Supplementary download completed OK.") | Error err -> printer.Error("Supplementary download failed.") - errors <- errors |> List.append [err] + errors <- List.append [err] errors | None -> () if List.isEmpty errors then diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 0c15e9bd..f41dad22 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -27,7 +27,7 @@ module Updater = } // Run the update process - match Runner.run args [||] printer with + match Runner.run args [] printer with | Ok (exitCode, warnings) -> // Handle successful run with potential warnings if exitCode <> 0 then diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index c890257b..5deed5e8 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -25,7 +25,7 @@ module Updater = let args = ToolSettings.create settings.DownloaderUpdateCommand settings.WorkingDirectory // Run the update process - match Runner.run args [||] printer with + match Runner.run args [] printer with | Ok (exitCode, warnings) -> // Handle non-zero exit code with potential warnings if exitCode <> 0 then diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 8a37c548..15f0a743 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -1,18 +1,15 @@ namespace CCVTAC.Console.ExternalTools -open System.Diagnostics -open System open CCVTAC.Console open Startwatch.Library +open System.Diagnostics module Runner = - /// Authentic success exit code [] let private AuthenticSuccessExitCode = 0 - /// Determines if the exit code is considered successful - let private isSuccessExitCode (otherSuccessExitCodes: int[]) (exitCode: int) = - Array.contains exitCode (Array.append otherSuccessExitCodes [|AuthenticSuccessExitCode|]) + let private isSuccessExitCode (otherSuccessExitCodes: int list) (exitCode: int) = + List.contains exitCode (List.append otherSuccessExitCodes [AuthenticSuccessExitCode]) /// Calls an external application. /// Tool settings for execution @@ -21,7 +18,7 @@ module Runner = /// A `Result` containing the exit code, if successful, or else an error message let internal run (settings: ToolSettings) - (otherSuccessExitCodes: int[]) + (otherSuccessExitCodes: int list) (printer: Printer) : Result = diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs index 384a592e..30109d60 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -1,6 +1,5 @@ namespace CCVTAC.Console.PostProcessing -open CCVTAC.Console.ExternalTools open CCVTAC.Console open CCVTAC.Console.ExternalTools @@ -13,4 +12,4 @@ module ImageProcessor = CommandWithArgs = $"{ProgramName} -trim -fuzz 10%% *.jpg" WorkingDirectory = workingDirectory } - Runner.run imageEditToolSettings [||] printer |> ignore + Runner.run imageEditToolSettings [] printer |> ignore From f4089d9575c4bf54e1278dcc850583d8e36f09a3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 21:17:39 +0900 Subject: [PATCH 015/247] Clean up Updater --- src/CCVTAC.FSharp/Downloading/Uploader.fs | 30 ++++++----------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index 5deed5e8..8a534f3f 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -1,42 +1,28 @@ -// module CCVTAC.FSharp.Downloading.Uploader namespace CCVTAC.Console +open System open CCVTAC.Console.ExternalTools -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.ExternalTools module Updater = - /// Represents download URLs - type private Urls = { - Primary: string - Supplementary: string option - } + type private Urls = + { Primary: string + Supplementary: string option } - /// Completes the actual download process. - /// A `Result` that, if successful, contains the name of the successfully downloaded format. - let internal run (settings: UserSettings) (printer: Printer) = - // Early return if no update command - if System.String.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand) then + let internal run (settings: UserSettings) (printer: Printer) : Result = + if String.IsNullOrWhiteSpace settings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() else - // Prepare tool settings let args = ToolSettings.create settings.DownloaderUpdateCommand settings.WorkingDirectory - // Run the update process match Runner.run args [] printer with | Ok (exitCode, warnings) -> - // Handle non-zero exit code with potential warnings if exitCode <> 0 then - printer.Warning("Update completed with minor issues.") - - if not (System.String.IsNullOrEmpty(warnings)) then + printer.Warning "Update completed with minor issues." + if not (String.IsNullOrEmpty warnings) then printer.Warning(warnings) - Ok() - | Error error -> - // Handle and log errors printer.Error($"Failure updating: %s{error}") Error error From 21368ab58dd49023d538c9f8620e215dacc6b228 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:05:02 +0900 Subject: [PATCH 016/247] Use F# lists in Printer, etc. --- src/CCVTAC.FSharp/Printer.fs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index 9482bd99..ba199983 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -23,21 +23,20 @@ type Printer(showDebug: bool) = static let colors : Dictionary = Dictionary() |> fun d -> - d.Add(Level.Critical, { Foreground = Some "white"; Background = Some "red3"; Bold = true }) - d.Add(Level.Error, { Foreground = Some "red"; Background = None; Bold = false }) - d.Add(Level.Warning, { Foreground = Some "yellow"; Background = None; Bold = false }) - d.Add(Level.Info, { Foreground = None; Background = None; Bold = false }) - d.Add(Level.Debug, { Foreground = Some "grey70"; Background = None; Bold = false }) + d.Add(Level.Critical, { Foreground = Some "white"; Background = Some "red3"; Bold = true }) + d.Add(Level.Error, { Foreground = Some "red"; Background = None; Bold = false }) + d.Add(Level.Warning, { Foreground = Some "yellow"; Background = None; Bold = false }) + d.Add(Level.Info, { Foreground = None; Background = None; Bold = false }) + d.Add(Level.Debug, { Foreground = Some "grey70"; Background = None; Bold = false }) d let mutable minimumLogLevel = if showDebug then Level.Debug else Level.Info - let extractedErrors result = + let extractedErrors (result: Result<'a,'b list>) : 'b list = match result with - | Ok _ -> [||] + | Ok _ -> [] | Error errors -> errors - :> ICollection /// Show or hide debug messages. member this.ShowDebug(show: bool) = @@ -102,21 +101,21 @@ type Printer(showDebug: bool) = member this.Error(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = this.Print(Level.Error, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) - member this.Errors(errors: ICollection, ?appendLines: byte) = - if errors.Count = 0 then raise (ArgumentException("No errors were provided!", "errors")) + member this.Errors(errors: string seq, ?appendLines: byte) = + if Seq.isEmpty errors then raise (ArgumentException("No errors were provided!", "errors")) for err in (errors |> Seq.filter (fun x -> hasText x false)) do this.Error(err) Printer.EmptyLines(defaultArg appendLines 0uy) - member private this.Errors(headerMessage: string, errors: IEnumerable) = + member private this.Errors(headerMessage: string, errors: string seq) = // Create an array with headerMessage followed by the items in errors let items = seq { yield headerMessage; yield! errors } |> Seq.toArray this.Errors(items, 0uy) - member this.Errors<'a>(failResult: Result<'a, string[]>, ?appendLines: byte) = + member this.Errors<'a>(failResult: Result<'a, string list>, ?appendLines: byte) = this.Errors(extractedErrors failResult, ?appendLines = appendLines) - member this.Errors<'a>(headerMessage: string, failingResult: Result<'a, string[]>) = + member this.Errors<'a>(headerMessage: string, failingResult: Result<'a, string list>) = this.Errors(headerMessage, extractedErrors failingResult) // member this.FirstError(failResult: Result<'a, string[]>, ?prepend: string) = @@ -145,8 +144,8 @@ type Printer(showDebug: bool) = Printer.EmptyLines(1uy) AnsiConsole.Ask($"[skyblue1]{prompt}[/]") - static member private Ask(title: string, options: string[]) : string = + static member private Ask(title: string, options: string list) : string = AnsiConsole.Prompt(SelectionPrompt().Title(title).AddChoices(options)) member this.AskToBool(title: string, trueAnswer: string, falseAnswer: string) : bool = - Printer.Ask(title, [| trueAnswer; falseAnswer |]) = trueAnswer + Printer.Ask(title, [ trueAnswer; falseAnswer ]) = trueAnswer From d756cd931e312b713da42a470a0c16c8b82d9d76 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:14:10 +0900 Subject: [PATCH 017/247] Printer code formatting --- src/CCVTAC.FSharp/Printer.fs | 37 +++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index ba199983..f005ce62 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -72,21 +72,21 @@ type Printer(showDebug: bool) = let processMarkup = defaultArg processMarkup true if int logLevel > int minimumLogLevel then - () // TODO: Can we remove altogether? + () else if String.IsNullOrWhiteSpace message then raise (ArgumentNullException("message", "Message cannot be empty.")) Printer.EmptyLines(prependLines) - let escapedMessage = Printer.EscapeText(message) + let escapedMessage = Printer.EscapeText message if processMarkup then let markedUp = Printer.AddMarkup(escapedMessage, colors[logLevel]) - AnsiConsole.Markup(markedUp) + AnsiConsole.Markup markedUp else // AnsiConsole.Write uses format strings internally; escapedMessage already duplicates braces - AnsiConsole.Write(escapedMessage) + AnsiConsole.Write escapedMessage if appendLineBreak then AnsiConsole.WriteLine() @@ -96,10 +96,12 @@ type Printer(showDebug: bool) = AnsiConsole.Write(table) member this.Critical(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = - this.Print(Level.Critical, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + this.Print(Level.Critical, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, + ?appendLines = appendLines, ?processMarkup = processMarkup) member this.Error(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = - this.Print(Level.Error, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + this.Print(Level.Error, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, + ?appendLines = appendLines, ?processMarkup = processMarkup) member this.Errors(errors: string seq, ?appendLines: byte) = if Seq.isEmpty errors then raise (ArgumentException("No errors were provided!", "errors")) @@ -118,27 +120,32 @@ type Printer(showDebug: bool) = member this.Errors<'a>(headerMessage: string, failingResult: Result<'a, string list>) = this.Errors(headerMessage, extractedErrors failingResult) - // member this.FirstError(failResult: Result<'a, string[]>, ?prepend: string) = member this.FirstError(message: string, ?prepend: string) = let prefix = match prepend with Some x -> x | None -> String.Empty this.Error($"{prefix}{message}") member this.Warning(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = - this.Print(Level.Warning, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + this.Print(Level.Warning, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, + ?appendLines = appendLines, ?processMarkup = processMarkup) member this.Info(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = - this.Print(Level.Info, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + this.Print(Level.Info, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, + ?appendLines = appendLines, ?processMarkup = processMarkup) member this.Debug(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = - this.Print(Level.Debug, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) + this.Print(Level.Debug, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, + ?appendLines = appendLines, ?processMarkup = processMarkup) /// Prints the requested number of blank lines. static member EmptyLines(count: byte) = - if count = 0uy then () else - // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. - let repeats = int count - 1 - if repeats <= 0 - then AnsiConsole.WriteLine() else AnsiConsole.WriteLine(String.Concat(Enumerable.Repeat(Environment.NewLine, repeats))) + if count = 0uy + then () + else + // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. + let repeats = int count - 1 + if repeats <= 0 + then AnsiConsole.WriteLine() + else Enumerable.Repeat(Environment.NewLine, repeats) |> String.Concat |> AnsiConsole.WriteLine member this.GetInput(prompt: string) : string = Printer.EmptyLines(1uy) From e615dd2c255c1ca88ce80959e3e0a1db2d5c280a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:20:25 +0900 Subject: [PATCH 018/247] Add exit code type to Program.fs --- src/CCVTAC.FSharp/Program.fs | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index c736f1f1..037e81da 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -1,17 +1,12 @@ namespace CCVTAC.Console open System -open System.Linq open Spectre.Console +open CCVTAC.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings open ExtensionMethods -open CCVTAC.Console -open CCVTAC.Console.Settings -open CCVTAC.Console.Settings.Settings.IO -open CCVTAC.Console.Settings.Settings.LiveUpdating -open CCVTAC.Console.Settings.Settings.Validation module Program = @@ -19,17 +14,22 @@ module Program = let private settingsFileFlags = [| "-s"; "--settings" |] let private defaultSettingsFileName = "settings.json" + type ExitCodes = + | Success = 0 + | ArgError = 1 + | OperationError = 2 + [] - let main (args: string[]) : int = + let main (args: string array) : int = let printer = Printer(showDebug = true) if args.Length > 0 && caseInsensitiveContains helpFlags args[0] then - Help.Print(printer) - 0 + Help.Print printer + int ExitCodes.Success else let maybeSettingsPath = if args.Length >= 2 && caseInsensitiveContains settingsFileFlags args[0] then - args.[1] // expected to be a settings file path + args[1] // Expected to be a settings file path else defaultSettingsFileName @@ -37,8 +37,8 @@ module Program = let readResult = Settings.IO.read (FilePath maybeSettingsPath) match readResult with | Error e -> - printer.Error(e) - 1 + printer.Error e + int ExitCodes.ArgError // | Ok None -> // // A new settings file was created; nothing more to do // 0 @@ -47,7 +47,7 @@ module Program = printer.ShowDebug(not settings.QuietMode) // Catch Ctrl-C (SIGINT) - Console.CancelKeyPress.Add(fun args -> + Console.CancelKeyPress.Add(fun _ -> printer.Warning("\nQuitting at user's request.") match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with @@ -56,18 +56,16 @@ module Program = printer.FirstError(warnResult) match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." - | Error delErr -> printer.FirstError(delErr) - // Do not set args.Cancel here; let default behavior terminate the process + | Error delErr -> printer.FirstError delErr ) - // Top-level try to catch unexpected exceptions and report them try Orchestrator.start settings printer - 0 + int ExitCodes.Success with ex -> printer.Critical $"Fatal error: %s{ex.Message}" AnsiConsole.WriteException(ex) printer.Info( "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." ) - 1 + int ExitCodes.OperationError From 5428cfd6e7716569d7478174f1f53c71b1d723ba Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:24:39 +0900 Subject: [PATCH 019/247] Remove CompiledName attributes --- src/CCVTAC.FSharp/Downloading/Downloading.fs | 3 +-- src/CCVTAC.FSharp/Settings/Settings.fs | 26 ++++++-------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloading.fs b/src/CCVTAC.FSharp/Downloading/Downloading.fs index 39cd4e33..848e2d9c 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloading.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloading.fs @@ -15,7 +15,7 @@ module public Downloading = | m when m.Success -> Some (List.tail [for g in m.Groups -> g.Value]) | _ -> None - [] + let mediaTypeWithIds url = match url with | Regex @"(?<=v=|v\=)([\w-]{11})(?:&list=([\w_-]+))" [videoId; playlistId] -> @@ -33,7 +33,6 @@ module public Downloading = | _ -> Error $"Unable to determine media type of URL \"{url}\". (Might it contain invalid characters?)" - [] let extractDownloadUrls mediaType = let fullUrl urlBase id = urlBase + id let videoUrl = fullUrl "https://www.youtube.com/watch?v=" diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 3b632d79..28a62446 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -1,10 +1,12 @@ namespace CCVTAC.Console.Settings +open System +open System.Text.Json.Serialization +open CCVTAC.Console +open ExtensionMethods +open Spectre.Console + module Settings = - open System - open System.Text.Json.Serialization - open CCVTAC.Console - open ExtensionMethods let newLine = Environment.NewLine @@ -18,7 +20,7 @@ module Settings = type TagDetectionPattern = { [] RegexPattern : string - [] MatchGroup : int // byte + [] MatchGroup : int [] SearchField : string [] Summary : string option } @@ -51,7 +53,6 @@ module Settings = [] DownloaderUpdateCommand : string } - [] let summarize settings = let onOrOff = function | true -> "ON" @@ -86,9 +87,6 @@ module Settings = ("Rename patterns", settings.RenamePatterns.Length |> pluralize "pattern") ] - open System - open Spectre.Console - /// Prints a summary of the given settings. let PrintSummary (settings: UserSettings) (printer: Printer) (header: string option) : unit = match header with @@ -110,7 +108,6 @@ module Settings = Printer.PrintTable(table) - module Validation = open System.IO @@ -162,13 +159,11 @@ module Settings = | null -> Error "Could not deserialize the JSON" | s -> Ok s - [] let fileExists (FilePath path) = match path |> File.Exists with | true -> Ok() | false -> Error $"File \"{path}\" does not exist." - [] let read (FilePath path) = try path @@ -180,7 +175,6 @@ module Settings = | :? JsonException as e -> Error $"Parse error in \"{path}\": {e.Message}" | e -> Error $"Unexpected error reading from \"{path}\": {e.Message}" - [] let private writeFile (FilePath file) settings = let unicodeEncoder = JavaScriptEncoder.Create UnicodeRanges.All let writeIndented = true @@ -195,7 +189,6 @@ module Settings = | :? JsonException -> Error "Failure parsing user settings to JSON." | e -> Error $"Failure writing \"{file}\": {e.Message}" - [] let writeDefaultFile (filePath: FilePath option) defaultFileName = let confirmedPath = match filePath with @@ -234,27 +227,22 @@ module Settings = module LiveUpdating = open Validation - [] let toggleSplitChapters settings = let toggledSetting = not settings.SplitChapters { settings with SplitChapters = toggledSetting } - [] let toggleEmbedImages settings = let toggledSetting = not settings.EmbedImages { settings with EmbedImages = toggledSetting } - [] let toggleQuietMode settings = let toggledSetting = not settings.QuietMode { settings with QuietMode = toggledSetting } - [] let updateAudioFormat settings (newFormat: string) = let updatedSettings = { settings with AudioFormats = newFormat.Split(',')} validate updatedSettings - [] let updateAudioQuality settings newQuality = let updatedSettings = { settings with AudioQuality = newQuality} validate updatedSettings From 70d0837845e4edc680395b08393a78960ced889a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:55:52 +0900 Subject: [PATCH 020/247] Remove unnecessary parentheses --- src/CCVTAC.FSharp/Commands.fs | 6 ++--- src/CCVTAC.FSharp/Downloading/Updater.fs | 4 +-- src/CCVTAC.FSharp/Downloading/Uploader.fs | 2 +- src/CCVTAC.FSharp/ExtensionMethods.fs | 4 +-- .../ExternalTools/ExternalTool.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 2 +- src/CCVTAC.FSharp/History.fs | 2 +- src/CCVTAC.FSharp/InputHelper.fs | 8 +++--- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 4 +-- src/CCVTAC.FSharp/Orchestrator.fs | 27 +++++++++---------- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 4 +-- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 10 +++---- .../PostProcessing/PostProcessing.fs | 16 +++++------ .../PostProcessing/Tagging/Tagger.fs | 8 +++--- src/CCVTAC.FSharp/Printer.fs | 10 +++---- src/CCVTAC.FSharp/Program.fs | 4 +-- src/CCVTAC.FSharp/Settings/Settings.fs | 4 +-- 17 files changed, 58 insertions(+), 59 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index be41d88f..f9fa2558 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -8,11 +8,11 @@ module internal Commands = let Prefix : char = '\\' let private MakeCommand (text: string) : string = - if String.IsNullOrWhiteSpace(text) then + if String.IsNullOrWhiteSpace text then raise (ArgumentException("The text cannot be null or white space.", "text")) - if text.Contains(' ') then + if text.Contains ' ' then raise (ArgumentException("The text should not contain any white space.", "text")) - sprintf "%c%s" Prefix text + $"%c{Prefix}%s{text}" let QuitCommands : string[] = [| MakeCommand "quit"; MakeCommand "q"; MakeCommand "exit" |] diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index f41dad22..c97bab51 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -33,8 +33,8 @@ module Updater = if exitCode <> 0 then printer.Warning("Update completed with minor issues.") - if not (System.String.IsNullOrEmpty(warnings)) then - printer.Warning(warnings) + if not (System.String.IsNullOrEmpty warnings) then + printer.Warning warnings Ok() diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index 8a534f3f..8ac526b0 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -21,7 +21,7 @@ module Updater = if exitCode <> 0 then printer.Warning "Update completed with minor issues." if not (String.IsNullOrEmpty warnings) then - printer.Warning(warnings) + printer.Warning warnings Ok() | Error error -> printer.Error($"Failure updating: %s{error}") diff --git a/src/CCVTAC.FSharp/ExtensionMethods.fs b/src/CCVTAC.FSharp/ExtensionMethods.fs index a1cd2659..f1d0538a 100644 --- a/src/CCVTAC.FSharp/ExtensionMethods.fs +++ b/src/CCVTAC.FSharp/ExtensionMethods.fs @@ -58,7 +58,7 @@ module ExtensionMethods = let invalidSet = HashSet(invalidCharsSeq) - if invalidSet.Contains(replaceWith) then + if invalidSet.Contains replaceWith then invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." // Replace each invalid char in the string using StringBuilder for efficiency @@ -97,7 +97,7 @@ module Utilities = let invalidSet = HashSet(invalidCharsSeq) - if invalidSet.Contains(replaceWith) + if invalidSet.Contains replaceWith then invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." // Replace each invalid char in the string. // TODO: `fold`/`reduce` this up and return a Result! diff --git a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs index 67e3b7f4..44f5d3eb 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs @@ -39,7 +39,7 @@ type ExternalTool = { ) try - use process' = Process.Start(processStartInfo) + use process' = Process.Start processStartInfo match process' with | null -> diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 15f0a743..e9d9cbe3 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -42,7 +42,7 @@ module Runner = processStartInfo.WorkingDirectory <- settings.WorkingDirectory // Start the process - match Process.Start(processStartInfo) with + match Process.Start processStartInfo with | null -> // Process failed to start Error $"Could not locate {splitCommandWithArgs.[0]}." diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 8317a2d1..0260b478 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -50,6 +50,6 @@ type History(filePath: string, displayCount: byte) = let joinedUrls = String.Join(Environment.NewLine, urls) table.AddRow(formattedTime, joinedUrls) |> ignore - Printer.PrintTable(table) + Printer.PrintTable table with ex -> printer.Error (sprintf "Could not display recent history: %s" ex.Message) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index b6832e1f..5fc4fdb5 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -24,9 +24,9 @@ module InputHelper = if matches.Length = 0 then ImmutableArray.Empty elif matches.Length = 1 then - ImmutableArray.Create(input) + ImmutableArray.Create input else - let startIndices = matches |> Array.map (fun m -> m.Index) + let startIndices = matches |> Array.map _.Index let indexPairs = startIndices @@ -40,7 +40,7 @@ module InputHelper = |> Array.map (fun p -> input.[p.Start..(p.End - 1)].Trim()) |> Array.distinct - ImmutableArray.CreateRange(splitInputs) + ImmutableArray.CreateRange splitInputs type InputCategory = | Url @@ -62,7 +62,7 @@ module InputHelper = type CategoryCounts(counts: Map) = member _.Item with get (category: InputCategory) = - match counts.TryGetValue(category) with + match counts.TryGetValue category with | true, v -> v | _ -> 0 diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index f993546e..7f8901df 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -13,7 +13,7 @@ module Directories = /// Counts the number of audio files in a directory let internal audioFileCount (directory: string) = - Directory.GetFiles(directory) + Directory.GetFiles directory |> Array.filter (fun f -> AudioExtensions |> Array.exists (fun ext -> @@ -64,7 +64,7 @@ module Directories = fileNames |> Array.iter (fun fileName -> try - File.Delete(fileName) + File.Delete fileName successCount <- successCount + 1 with | ex -> errors.Add(ex.Message) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 1c78be77..66bfcbf4 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -54,7 +54,7 @@ module Orchestrator = for input in categorizedInputs do printer.Info(sprintf " • %s" input.Text) - Printer.EmptyLines(1uy) + Printer.EmptyLines 1uy let sleep (sleepSeconds: uint16) : unit = // Use a mutable remainingSeconds to mirror the C# behavior @@ -68,9 +68,9 @@ module Orchestrator = ctx.SpinnerStyle(Style.Parse("blue")) |> ignore while remainingSeconds > 0us do - ctx.Status(sprintf "Sleeping for %d seconds..." remainingSeconds) |> ignore + ctx.Status $"Sleeping for %d{remainingSeconds} seconds..." |> ignore remainingSeconds <- remainingSeconds - 1us - Thread.Sleep(1000) + Thread.Sleep 1000 ) let processUrl @@ -87,7 +87,7 @@ module Orchestrator = match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Error firstErr -> - printer.FirstError(firstErr) + printer.FirstError firstErr Ok NextAction.QuitDueToErrors | Ok () -> // Don't sleep for the very first URL. @@ -104,7 +104,7 @@ module Orchestrator = match Downloading.mediaTypeWithIds url with | Error e -> let errorMsg = $"URL parse error: %s{e}" - printer.Error(errorMsg) + printer.Error errorMsg Error errorMsg | Ok mediaType -> printer.Info(sprintf "%s URL '%s' detected." (mediaType.GetType().Name) url) @@ -116,7 +116,7 @@ module Orchestrator = match downloadResult with | Error e -> let errorMsg = $"Download error: %s{e}" - printer.Error(errorMsg) + printer.Error errorMsg Error errorMsg | Ok s -> printer.Debug $"Successfully downloaded \"%s{s}\" format." @@ -165,7 +165,7 @@ module Orchestrator = // History elif seqContainsIgnoreCase Commands.History command then - history.ShowRecent(printer) + history.ShowRecent printer Ok NextAction.Continue // Update downloader @@ -222,8 +222,8 @@ module Orchestrator = if String.IsNullOrEmpty inputQuality then Error "You must enter a number representing an audio quality." else - match Byte.TryParse(inputQuality) with - | (true, quality) -> + match Byte.TryParse inputQuality with + | true, quality -> let updateResult = updateAudioQuality settings quality match updateResult with | Error e -> Error e @@ -297,15 +297,14 @@ module Orchestrator = // The working directory should start empty. Give the user a chance to empty it. match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Error firstErr -> - printer.FirstError(firstErr) + printer.FirstError firstErr match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." | Error err -> - printer.FirstError(err) + printer.FirstError err printer.Info "Aborting..." - // abort Start by returning () | Ok () -> let results = ResultTracker(printer) @@ -320,8 +319,8 @@ module Orchestrator = if splitInputs.IsEmpty then printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands.Prefix) else - let categorizedInputs = InputHelper.CategorizeInputs(splitInputs) - let categoryCounts = InputHelper.CountCategories(categorizedInputs) + let categorizedInputs = InputHelper.CategorizeInputs splitInputs + let categoryCounts = InputHelper.CountCategories categorizedInputs summarizeInput categorizedInputs categoryCounts printer diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index 47470a84..aedd2c5a 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -31,7 +31,7 @@ module Deleter = fileNames |> Array.iter (fun fileName -> try - File.Delete(fileName) + File.Delete fileName printer.Debug($"• Deleted \"{fileName}\"") with | ex -> printer.Error($"• Deletion error: {ex.Message}") @@ -52,7 +52,7 @@ module Deleter = printer.Debug($"Found {files.Length} collection files.") files | Error err -> - printer.Warning(err) + printer.Warning err [||] // Combine all file names diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index e80246ea..8b0816db 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -24,10 +24,10 @@ module Mover = let private ImageFileWildcard = "*.jp*" let private IsPlaylistImage (fileName: string) = - PlaylistImageRegex.IsMatch(fileName) + PlaylistImageRegex.IsMatch fileName let private GetCoverImage (workingDirInfo: DirectoryInfo) (audioFileCount: int) : FileInfo option = - let images = workingDirInfo.EnumerateFiles(ImageFileWildcard) |> Seq.toArray + let images = workingDirInfo.EnumerateFiles ImageFileWildcard |> Seq.toArray if images.Length = 0 then None else let playlistImages = images |> Seq.filter (fun i -> IsPlaylistImage(i.FullName)) |> Seq.toList @@ -37,12 +37,12 @@ module Mover = let private EnsureDirectoryExists (moveToDir: string) (printer: Printer) : Result = try - if Directory.Exists(moveToDir) then + if Directory.Exists moveToDir then printer.Debug (sprintf "Found move-to directory \"%s\"." moveToDir) Ok () else printer.Debug (sprintf "Creating move-to directory \"%s\" (based on playlist metadata)... " moveToDir, appendLineBreak = false) - Directory.CreateDirectory(moveToDir) |> ignore + Directory.CreateDirectory moveToDir |> ignore printer.Debug "OK." Ok () with ex -> @@ -119,7 +119,7 @@ module Mover = let safeName = workingName.ReplaceInvalidPathChars().Trim() let topicSuffix = " - Topic" - if safeName.EndsWith(topicSuffix) then safeName.Replace(topicSuffix, String.Empty) + if safeName.EndsWith topicSuffix then safeName.Replace(topicSuffix, String.Empty) else safeName let Run diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index b58cf18b..2ee4bfea 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -27,14 +27,13 @@ module PostProcessor = \.info.json)", RegexOptions.Compiled) let private getCollectionMetadataMatches (path: string) = - collectionMetadataRegex.IsMatch(path) + collectionMetadataRegex.IsMatch path let private GetCollectionJson (workingDirectory: string) : Result = try let fileNames = - Directory.GetFiles(workingDirectory) + Directory.GetFiles workingDirectory |> Seq.filter getCollectionMetadataMatches - // |> Seq.toImmutableHashSet |> Set.ofSeq if fileNames.Count = 0 then @@ -43,7 +42,7 @@ module PostProcessor = Error "Unexpectedly found more than one relevant file, so none will be processed." else let fileName = fileNames.Single() - let json = File.ReadAllText(fileName) + let json = File.ReadAllText fileName #nowarn 3265 let collectionData = JsonSerializer.Deserialize(json) #warnon 3265 @@ -56,9 +55,10 @@ module PostProcessor = let private GenerateTaggingSets (directory: string) : Result = try - let files = Directory.GetFiles(directory) - let taggingSets = TaggingSet.CreateSets(files) - if taggingSets.Any() then Ok taggingSets + let files = Directory.GetFiles directory + let taggingSets = TaggingSet.CreateSets files + if taggingSets.Any() + then Ok taggingSets else Error (sprintf "No tagging sets were created using working directory \"%s\"." directory) with | :? DirectoryNotFoundException -> @@ -111,7 +111,7 @@ module PostProcessor = printer.Info "Will delete the remaining files..." match Directories.deleteAllFiles workingDirectory 20 with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." - | Error e -> printer.FirstError(e) + | Error e -> printer.FirstError e | Error e -> printer.Error($"Tagging error(s) preventing further post-processing: {e}") diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 3b1aa4e4..4d94907a 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -62,7 +62,7 @@ module Tagger = else try let pics = Array.zeroCreate 1 - pics.[0] <- TagLib.Picture(imageFilePath) + pics.[0] <- TagLib.Picture imageFilePath taggedFile.Tag.Pictures <- pics printer.Debug "Image written to file tags OK." with ex -> @@ -85,7 +85,7 @@ module Tagger = None else let yearStr = videoData.UploadDate.Substring(0, 4) - match UInt32.TryParse(yearStr) with + match UInt32.TryParse yearStr with | true, parsed -> Some parsed | _ -> None @@ -100,7 +100,7 @@ module Tagger = let audioFileName = Path.GetFileName audioFilePath printer.Debug (sprintf "Current audio file: \"%s\"" audioFileName) - use taggedFile = TaggedFile.Create(audioFilePath) + use taggedFile = TaggedFile.Create audioFilePath let tagDetector = TagDetector(settings.TagDetectionPatterns) // Title @@ -152,7 +152,7 @@ module Tagger = taggedFile.Tag.Album <- album // Composers - match tagDetector.DetectComposers(videoData) with + match tagDetector.DetectComposers videoData with | None -> () | Some composers -> printer.Debug (sprintf "• Found composer(s) \"%s\"" composers) diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index f005ce62..daa5a64c 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -77,7 +77,7 @@ type Printer(showDebug: bool) = if String.IsNullOrWhiteSpace message then raise (ArgumentNullException("message", "Message cannot be empty.")) - Printer.EmptyLines(prependLines) + Printer.EmptyLines prependLines let escapedMessage = Printer.EscapeText message @@ -90,10 +90,10 @@ type Printer(showDebug: bool) = if appendLineBreak then AnsiConsole.WriteLine() - Printer.EmptyLines(appendLines) + Printer.EmptyLines appendLines static member PrintTable(table: Table) = - AnsiConsole.Write(table) + AnsiConsole.Write table member this.Critical(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = this.Print(Level.Critical, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, @@ -106,7 +106,7 @@ type Printer(showDebug: bool) = member this.Errors(errors: string seq, ?appendLines: byte) = if Seq.isEmpty errors then raise (ArgumentException("No errors were provided!", "errors")) for err in (errors |> Seq.filter (fun x -> hasText x false)) do - this.Error(err) + this.Error err Printer.EmptyLines(defaultArg appendLines 0uy) member private this.Errors(headerMessage: string, errors: string seq) = @@ -148,7 +148,7 @@ type Printer(showDebug: bool) = else Enumerable.Repeat(Environment.NewLine, repeats) |> String.Concat |> AnsiConsole.WriteLine member this.GetInput(prompt: string) : string = - Printer.EmptyLines(1uy) + Printer.EmptyLines 1uy AnsiConsole.Ask($"[skyblue1]{prompt}[/]") static member private Ask(title: string, options: string list) : string = diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 037e81da..ee842b3e 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -53,7 +53,7 @@ module Program = match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Ok () -> () | Error warnResult -> - printer.FirstError(warnResult) + printer.FirstError warnResult match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." | Error delErr -> printer.FirstError delErr @@ -64,7 +64,7 @@ module Program = int ExitCodes.Success with ex -> printer.Critical $"Fatal error: %s{ex.Message}" - AnsiConsole.WriteException(ex) + AnsiConsole.WriteException ex printer.Info( "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." ) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 28a62446..74c03b1a 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -91,7 +91,7 @@ module Settings = let PrintSummary (settings: UserSettings) (printer: Printer) (header: string option) : unit = match header with | Some h when hasText h false -> - printer.Info(h) + printer.Info h | _ -> () let table = Table() @@ -106,7 +106,7 @@ module Settings = for pair in settingPairs do table.AddRow(fst pair, snd pair) |> ignore - Printer.PrintTable(table) + Printer.PrintTable table module Validation = open System.IO From 1ab8d5b5e190a99274666494b2d6715b33f18f0b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:59:45 +0900 Subject: [PATCH 021/247] Remove superfluous indexing periods --- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 11 ++++++----- src/CCVTAC.FSharp/InputHelper.fs | 4 ++-- src/CCVTAC.FSharp/Orchestrator.fs | 8 ++++---- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 4 ++-- .../PostProcessing/Tagging/TaggingSet.fs | 2 +- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index e9d9cbe3..d64e8834 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -1,5 +1,6 @@ namespace CCVTAC.Console.ExternalTools +open System open CCVTAC.Console open Startwatch.Library open System.Diagnostics @@ -33,8 +34,8 @@ module Runner = // Prepare process start info let processStartInfo = ProcessStartInfo splitCommandWithArgs[0] - // processStartInfo.FileName <- splitCommandWithArgs.[0] - processStartInfo.Arguments <- if splitCommandWithArgs.Length > 1 then splitCommandWithArgs.[1] else "" + // processStartInfo.FileName <- splitCommandWithArgs[0] + processStartInfo.Arguments <- if splitCommandWithArgs.Length > 1 then splitCommandWithArgs[1] else String.Empty processStartInfo.UseShellExecute <- false processStartInfo.RedirectStandardOutput <- false processStartInfo.RedirectStandardError <- true @@ -45,7 +46,7 @@ module Runner = match Process.Start processStartInfo with | null -> // Process failed to start - Error $"Could not locate {splitCommandWithArgs.[0]}." + Error $"Could not locate {splitCommandWithArgs[0]}." | process' -> // Read errors before waiting for exit let error = process'.StandardError.ReadToEnd() @@ -53,11 +54,11 @@ module Runner = // Wait for process to complete process'.WaitForExit() - printer.Info($"{splitCommandWithArgs.[0]} finished in {watch.ElapsedFriendly}.") + printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}.") let trimmedErrors = error // TODO: Trim terminal line break? if isSuccessExitCode otherSuccessExitCodes process'.ExitCode then Ok (process'.ExitCode, trimmedErrors) else - Error $"{splitCommandWithArgs.[0]} exited with code {process'.ExitCode}: {trimmedErrors}." + Error $"{splitCommandWithArgs[0]} exited with code {process'.ExitCode}: {trimmedErrors}." diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 5fc4fdb5..a9b22758 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -32,12 +32,12 @@ module InputHelper = startIndices |> Array.mapi (fun i startIndex -> let endIndex = - if i = startIndices.Length - 1 then input.Length else startIndices.[i + 1] + if i = startIndices.Length - 1 then input.Length else startIndices[i + 1] { Start = startIndex; End = endIndex }) let splitInputs = indexPairs - |> Array.map (fun p -> input.[p.Start..(p.End - 1)].Trim()) + |> Array.map (fun p -> input[p.Start..(p.End - 1)].Trim()) |> Array.distinct ImmutableArray.CreateRange splitInputs diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 66bfcbf4..750abc68 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -31,8 +31,8 @@ module Orchestrator = = if categorizedInputs.Length > 1 then - let urlCount = counts.[InputCategory.Url] - let cmdCount = counts.[InputCategory.Command] + let urlCount = counts[InputCategory.Url] + let cmdCount = counts[InputCategory.Command] let urlSummary = match urlCount with @@ -281,10 +281,10 @@ module Orchestrator = if nextAction <> NextAction.Continue then stop <- true - if categoryCounts.[InputCategory.Url] > 1 then + if categoryCounts[InputCategory.Url] > 1 then printer.Info(sprintf "%sFinished with batch of %d URLs in %s." Environment.NewLine - categoryCounts.[InputCategory.Url] + categoryCounts[InputCategory.Url] watch.ElapsedFriendly) batchResults.PrintBatchFailures() diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 8b0816db..717fe98c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -32,7 +32,7 @@ module Mover = else let playlistImages = images |> Seq.filter (fun i -> IsPlaylistImage(i.FullName)) |> Seq.toList if playlistImages.Any() then Some (playlistImages.First()) - else if audioFileCount > 1 && images.Length = 1 then Some images.[0] + else if audioFileCount > 1 && images.Length = 1 then Some images[0] else None let private EnsureDirectoryExists (moveToDir: string) (printer: Printer) : Result = diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index a0e63dce..656bc1e9 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -70,8 +70,8 @@ module Renamer = |> Seq.mapi (fun i g -> let searchFor = sprintf "%%<%d>s" (i + 1) let replaceWith = - // group 0 is the whole match; we want groups starting at 1 - if i + 1 < m.Groups.Count then m.Groups.[i + 1].Value.Trim() else String.Empty + // Group 0 is the entire match, and we only want groups starting at 1. + if i + 1 < m.Groups.Count then m.Groups[i + 1].Value.Trim() else String.Empty (searchFor, replaceWith)) |> Seq.fold (fun (sbRep: StringBuilder) (searchFor, replaceWith) -> sbRep.Replace(searchFor, replaceWith)) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index c892ac03..3bc30caa 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -59,7 +59,7 @@ type TaggingSet = |> Seq.map fileNamesWithVideoIdsRegex.Match |> Seq.filter _.Success |> Seq.map (fun m -> m.Captures |> Seq.cast |> Seq.head) - |> Seq.groupBy (fun m -> m.Groups[1].Value) //(fun m -> m.Groups.[0].Value) + |> Seq.groupBy _.Groups[1].Value |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) |> Seq.filter (fun (_, files) -> let filesSeq = files |> Seq.toArray From 588eda6d333d96fc3299898b530be18e1223edd7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:26:05 +0900 Subject: [PATCH 022/247] Fix sleep animation --- src/CCVTAC.FSharp/Orchestrator.fs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 750abc68..88ca33d5 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -57,12 +57,11 @@ module Orchestrator = Printer.EmptyLines 1uy let sleep (sleepSeconds: uint16) : unit = - // Use a mutable remainingSeconds to mirror the C# behavior let mutable remainingSeconds = sleepSeconds AnsiConsole .Status() - .Start(sprintf "Sleeping for %d seconds..." sleepSeconds, + .Start($"Sleeping for %d{sleepSeconds} seconds...", fun ctx -> ctx.Spinner(Spinner.Known.Star) |> ignore ctx.SpinnerStyle(Style.Parse("blue")) |> ignore @@ -82,8 +81,7 @@ module Orchestrator = (batchSize: int) (urlIndex: int) (printer: Printer) - : Result - = + : Result = match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Error firstErr -> @@ -92,15 +90,14 @@ module Orchestrator = | Ok () -> // Don't sleep for the very first URL. if urlIndex > 1 then - Threading.Thread.Sleep(int settings.SleepSecondsBetweenURLs * 1000) - printer.Info(sprintf "Slept for %d second(s)." settings.SleepSecondsBetweenURLs, appendLines = 1uy) + sleep settings.SleepSecondsBetweenURLs + printer.Info($"Slept for %d{settings.SleepSecondsBetweenURLs} second(s).", appendLines = 1uy) if batchSize > 1 then printer.Info(sprintf "Processing group %d of %d..." urlIndex batchSize) let jobWatch = Watch() - match Downloading.mediaTypeWithIds url with | Error e -> let errorMsg = $"URL parse error: %s{e}" @@ -290,8 +287,6 @@ module Orchestrator = nextAction - - /// Ensures the download environment is ready, then initiates the UI input and download process. let start (settings: UserSettings) (printer: Printer) : unit = // The working directory should start empty. Give the user a chance to empty it. From 1f8cd253a56b6ffb62ab2c0e2465afbcbb9b061b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:45:18 +0900 Subject: [PATCH 023/247] Minor tweaks --- src/CCVTAC.FSharp/Orchestrator.fs | 3 +-- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 88ca33d5..5583bd8a 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -88,8 +88,7 @@ module Orchestrator = printer.FirstError firstErr Ok NextAction.QuitDueToErrors | Ok () -> - // Don't sleep for the very first URL. - if urlIndex > 1 then + if urlIndex > 1 then // Don't sleep for the first URL. sleep settings.SleepSecondsBetweenURLs printer.Info($"Slept for %d{settings.SleepSecondsBetweenURLs} second(s).", appendLines = 1uy) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 656bc1e9..e7cd29b6 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -53,11 +53,11 @@ module Renamer = if not settings.QuietMode then let matchedPatternSummary = // if isNull renamePattern.Summary then // TODO: Check on this. - sprintf "`%s` (no description)" renamePattern.RegexPattern + $"`%s{renamePattern.RegexPattern}` (no description)" // else // sprintf "\"%s\"" renamePattern.Summary - printer.Debug (sprintf "Rename pattern %s matched × %d." matchedPatternSummary matches.Length) + printer.Debug(sprintf "Rename pattern %s matched × %d." matchedPatternSummary matches.Length) for m in matches do // remove matched substring From 8683238e6c5e813bb2451fd09c2efc170512213d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:58:53 +0900 Subject: [PATCH 024/247] Update readme --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6dd712c8..aabce5ea 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # CCVTAC -CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI tool that acts as a wrapper around [yt-dlp](https://github.com/yt-dlp/yt-dlp) to enable easier download and extractions of audio from YouTube videos, playlists, and channels, plus do some automatic post-processing (tagging, renaming, and moving). +CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI tool written in F# that acts as a wrapper around [yt-dlp](https://github.com/yt-dlp/yt-dlp) to enable easier download and extractions of audio from YouTube videos, playlists, and channels, plus do some automatic post-processing (tagging, renaming, and moving). -While I maintain it for my own use, feel free to use it yourself! However, please note it's geared to my own personal use cases and that no warranties or guarantees are provided. +While I maintain it for my own use, feel free to use it yourself! However, please note that it's geared to my own personal use cases and that no warranties or guarantees are provided. [![Build and test](https://github.com/codeconscious/ccvtac/actions/workflows/build-test.yml/badge.svg)](https://github.com/codeconscious/ccvtac/actions/workflows/build-test.yml) @@ -203,3 +203,7 @@ List of commands: ## Reporting issues If you run into any issues, feel free to create an issue on GitHub. Please provide as much information as possible (i.e., entered URLs, system information, yt-dlp version, etc.). + +## History + +This application was originally written in C#, but I ported it to F# beginning in late 2025 to try out F#'s object-oriented programming capabilities and to move toward a more functional codebase. From fe1da2c7981ba8391ea2a7a1e869ad4f9d7c2be6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:49:19 +0900 Subject: [PATCH 025/247] Rename func --- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/Program.fs | 2 +- src/CCVTAC.FSharp/Settings/Settings.fs | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 5583bd8a..a71c8329 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -171,7 +171,7 @@ module Orchestrator = // Settings summary elif seqContainsIgnoreCase Commands.SettingsSummary command then - Settings.PrintSummary settings printer None + Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index ee842b3e..19ceb408 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -43,7 +43,7 @@ module Program = // // A new settings file was created; nothing more to do // 0 | Ok settings -> - Settings.PrintSummary settings printer (Some "Settings loaded OK.") + Settings.printSummary settings printer (Some "Settings loaded OK.") printer.ShowDebug(not settings.QuietMode) // Catch Ctrl-C (SIGINT) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 74c03b1a..7f8f80fc 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -87,8 +87,7 @@ module Settings = ("Rename patterns", settings.RenamePatterns.Length |> pluralize "pattern") ] - /// Prints a summary of the given settings. - let PrintSummary (settings: UserSettings) (printer: Printer) (header: string option) : unit = + let printSummary (settings: UserSettings) (printer: Printer) (header: string option) : unit = match header with | Some h when hasText h false -> printer.Info h From f0f8df1de05119dbca4e77d94762709fd92e5df6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:49:28 +0900 Subject: [PATCH 026/247] Use list over array --- src/CCVTAC.FSharp/Settings/Settings.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 7f8f80fc..29723f80 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -99,7 +99,7 @@ module Settings = table.BorderColor(Color.Grey27) |> ignore table.AddColumns("Name", "Value") |> ignore table.HideHeaders() |> ignore - table.Columns.[1].Width <- 100 // Ensure maximum width. + table.Columns[1].Width <- 100 // Ensure maximum width. let settingPairs = summarize(settings) for pair in settingPairs do @@ -116,10 +116,10 @@ module Settings = // Source: https://github.com/yt-dlp/yt-dlp/?tab=readme-ov-file#post-processing-options // TODO: Check similar item in Shared module. - let supportedAudioFormats = [| "best"; "aac"; "alac"; "flac"; "m4a"; "mp3"; "opus"; "vorbis"; "wav" |] - let supportedNormalizationForms = [| "C"; "D"; "KC"; "KD" |] + let supportedAudioFormats = [ "best"; "aac"; "alac"; "flac"; "m4a"; "mp3"; "opus"; "vorbis"; "wav" ] + let supportedNormalizationForms = [ "C"; "D"; "KC"; "KD" ] - let validAudioFormat fmt = supportedAudioFormats |> Array.contains fmt + let validAudioFormat fmt = supportedAudioFormats |> List.contains fmt match settings with | { WorkingDirectory = d } when d |> isEmpty -> @@ -132,7 +132,7 @@ module Settings = Error $"Move-to directory \"{d}\" is missing." | { AudioQuality = q } when q > 10uy -> Error "Audio quality must be in the range 10 (lowest) and 0 (highest)." - | { NormalizationForm = nf } when not(supportedNormalizationForms |> Array.contains (nf.ToUpperInvariant())) -> + | { NormalizationForm = nf } when not(supportedNormalizationForms |> List.contains (nf.ToUpperInvariant())) -> let okFormats = String.Join(", ", supportedNormalizationForms) Error $"\"{nf}\" is an invalid normalization form. Use one of the following: {okFormats}." | { AudioFormats = fmt } when not (fmt |> Array.forall (fun f -> f |> validAudioFormat)) -> From e3d44a690ce508f0b858ab0bf82365817a8be9f9 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:57:20 +0900 Subject: [PATCH 027/247] Various Settings tweaks --- src/CCVTAC.FSharp/Settings/Settings.fs | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 29723f80..d5954a2d 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -53,7 +53,7 @@ module Settings = [] DownloaderUpdateCommand : string } - let summarize settings = + let summarize settings : (string * string) list = let onOrOff = function | true -> "ON" | false -> "OFF" @@ -61,7 +61,7 @@ module Settings = let pluralize (label: string) count = if count = 1 then $"{count} {label}" - else $"{count} {label}s" // Intentionally naive implementation + else $"{count} {label}s" // Intentionally naive implementation. let tagDetectionPatternCount (patterns: TagDetectionPatterns) = patterns.Title.Length + @@ -87,10 +87,9 @@ module Settings = ("Rename patterns", settings.RenamePatterns.Length |> pluralize "pattern") ] - let printSummary (settings: UserSettings) (printer: Printer) (header: string option) : unit = - match header with - | Some h when hasText h false -> - printer.Info h + let printSummary settings (printer: Printer) headerOpt : unit = + match headerOpt with + | Some h when hasText h false -> printer.Info h | _ -> () let table = Table() @@ -101,9 +100,8 @@ module Settings = table.HideHeaders() |> ignore table.Columns[1].Width <- 100 // Ensure maximum width. - let settingPairs = summarize(settings) - for pair in settingPairs do - table.AddRow(fst pair, snd pair) |> ignore + for description, value in summarize settings do + table.AddRow(description, value) |> ignore Printer.PrintTable table @@ -115,21 +113,20 @@ module Settings = let dirMissing str = not (Directory.Exists str) // Source: https://github.com/yt-dlp/yt-dlp/?tab=readme-ov-file#post-processing-options - // TODO: Check similar item in Shared module. let supportedAudioFormats = [ "best"; "aac"; "alac"; "flac"; "m4a"; "mp3"; "opus"; "vorbis"; "wav" ] let supportedNormalizationForms = [ "C"; "D"; "KC"; "KD" ] let validAudioFormat fmt = supportedAudioFormats |> List.contains fmt match settings with - | { WorkingDirectory = d } when d |> isEmpty -> + | { WorkingDirectory = dir } when isEmpty dir -> Error "No working directory was specified." - | { WorkingDirectory = d } when d |> dirMissing -> - Error $"Working directory \"{d}\" is missing." - | { MoveToDirectory = d } when d |> isEmpty -> + | { WorkingDirectory = dir } when dirMissing dir -> + Error $"Working directory \"{dir}\" is missing." + | { MoveToDirectory = dir } when isEmpty dir -> Error "No move-to directory was specified." - | { MoveToDirectory = d } when d |> dirMissing -> - Error $"Move-to directory \"{d}\" is missing." + | { MoveToDirectory = dir } when dirMissing dir -> + Error $"Move-to directory \"{dir}\" is missing." | { AudioQuality = q } when q > 10uy -> Error "Audio quality must be in the range 10 (lowest) and 0 (highest)." | { NormalizationForm = nf } when not(supportedNormalizationForms |> List.contains (nf.ToUpperInvariant())) -> From ae0cb391dedf054b2afcb6e4f078fe33c35a0e38 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:59:51 +0900 Subject: [PATCH 028/247] AutoOpen extensions --- src/CCVTAC.FSharp/ExtensionMethods.fs | 1 + src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 -- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 3 --- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 1 - src/CCVTAC.FSharp/Program.fs | 1 - src/CCVTAC.FSharp/Settings/Settings.fs | 1 - 6 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/ExtensionMethods.fs b/src/CCVTAC.FSharp/ExtensionMethods.fs index f1d0538a..2e5d5c95 100644 --- a/src/CCVTAC.FSharp/ExtensionMethods.fs +++ b/src/CCVTAC.FSharp/ExtensionMethods.fs @@ -5,6 +5,7 @@ open System.IO open System.Text open System.Collections.Generic +[] module ExtensionMethods = /// Determines whether a string contains any text. diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index e7cd29b6..146507a2 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -5,10 +5,8 @@ open System.IO open System.Text open System.Text.RegularExpressions open CCVTAC.Console -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings open Startwatch.Library -open ExtensionMethods module Renamer = diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 4d94907a..d920916f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -5,12 +5,9 @@ open System.IO open System.Text.Json open System.Linq open CCVTAC.Console -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing -open CCVTAC.Console.Downloading open CCVTAC.Console.Downloading.Downloading -open ExtensionMethods type TaggedFile = TagLib.File diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 3bc30caa..8fc2005d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -6,7 +6,6 @@ open System.Text.RegularExpressions open System.Collections.Generic open System.Collections.Immutable open CCVTAC.Console -open ExtensionMethods /// Contains all the data necessary for tagging a related set of files. [] diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 19ceb408..4d3d79d3 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -6,7 +6,6 @@ open CCVTAC.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings -open ExtensionMethods module Program = diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index d5954a2d..3742b23b 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -3,7 +3,6 @@ namespace CCVTAC.Console.Settings open System open System.Text.Json.Serialization open CCVTAC.Console -open ExtensionMethods open Spectre.Console module Settings = From c453eceb4314fe033d1f7441846a2f9fa607e81d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:07:50 +0900 Subject: [PATCH 029/247] Rename extensions to utilities, etc. --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 4 ++-- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 4 ++-- .../PostProcessing/Tagging/Tagger.fs | 4 ++-- src/CCVTAC.FSharp/Printer.fs | 4 ++-- src/CCVTAC.FSharp/Settings/Settings.fs | 2 +- .../{ExtensionMethods.fs => Utilities.fs} | 15 ++++++--------- 7 files changed, 16 insertions(+), 19 deletions(-) rename src/CCVTAC.FSharp/{ExtensionMethods.fs => Utilities.fs} (92%) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 28a64d09..581a13be 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -8,7 +8,7 @@ - + diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index a71c8329..aabeea44 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -14,7 +14,7 @@ open CCVTAC.Console.Settings.Settings.IO open CCVTAC.Console.Settings.Settings.LiveUpdating open Spectre.Console open CCVTAC.Console.InputHelper -open ExtensionMethods +open Utilities open Startwatch.Library module Orchestrator = @@ -47,7 +47,7 @@ module Orchestrator = | _ -> String.Empty let connector = - if hasText urlSummary false && hasText commandSummary false then " and " else String.Empty + if hasNonWhitespaceText urlSummary && hasNonWhitespaceText commandSummary then " and " else String.Empty printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 717fe98c..cb4af3e0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -5,7 +5,7 @@ open System.IO open System.Linq open System.Text.Json open System.Text.RegularExpressions -open CCVTAC.Console.ExtensionMethods +open CCVTAC.Console.Utilities open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings @@ -111,7 +111,7 @@ module Mover = let private GetSafeSubDirectoryName (collectionData: CollectionMetadata option) (taggingSet: TaggingSet) : string = let workingName = match collectionData with - | Some metadata when hasText metadata.Uploader false && hasText metadata.Title false -> metadata.Uploader + | Some metadata when hasNonWhitespaceText metadata.Uploader && hasNonWhitespaceText metadata.Title -> metadata.Uploader | _ -> match GetParsedVideoJson taggingSet with | Ok v -> v.Uploader diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index d920916f..e1d27d45 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -120,7 +120,7 @@ module Tagger = | None -> printer.Debug "No title was found." // Artist / Performers - if hasText videoData.Artist false then + if hasNonWhitespaceText videoData.Artist then let metadataArtists = videoData.Artist let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None)[0] let diffSummary = @@ -137,7 +137,7 @@ module Tagger = taggedFile.Tag.Performers <- [| artist |] // Album - if hasText videoData.Album false then + if hasNonWhitespaceText videoData.Album then printer.Debug $"• Using metadata album \"%s{videoData.Album}\"" taggedFile.Tag.Album <- videoData.Album else diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index daa5a64c..d316b548 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -4,7 +4,7 @@ open System open System.Collections.Generic open System.Linq open Spectre.Console -open ExtensionMethods +open Utilities type private Level = | Critical = 0 @@ -105,7 +105,7 @@ type Printer(showDebug: bool) = member this.Errors(errors: string seq, ?appendLines: byte) = if Seq.isEmpty errors then raise (ArgumentException("No errors were provided!", "errors")) - for err in (errors |> Seq.filter (fun x -> hasText x false)) do + for err in errors |> Seq.filter hasNonWhitespaceText do this.Error err Printer.EmptyLines(defaultArg appendLines 0uy) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 3742b23b..a32ba94c 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -88,7 +88,7 @@ module Settings = let printSummary settings (printer: Printer) headerOpt : unit = match headerOpt with - | Some h when hasText h false -> printer.Info h + | Some h when hasNonWhitespaceText h -> printer.Info h | _ -> () let table = Table() diff --git a/src/CCVTAC.FSharp/ExtensionMethods.fs b/src/CCVTAC.FSharp/Utilities.fs similarity index 92% rename from src/CCVTAC.FSharp/ExtensionMethods.fs rename to src/CCVTAC.FSharp/Utilities.fs index 2e5d5c95..1f107093 100644 --- a/src/CCVTAC.FSharp/ExtensionMethods.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -6,18 +6,17 @@ open System.Text open System.Collections.Generic [] -module ExtensionMethods = +module Utilities = /// Determines whether a string contains any text. /// allowWhiteSpace = true allows whitespace to count as text. - let hasText (maybeText: string) (allowWhiteSpace: bool) = + let hasText text allowWhiteSpace = if allowWhiteSpace then - not (String.IsNullOrEmpty maybeText) + not (String.IsNullOrEmpty text) else - not (String.IsNullOrWhiteSpace maybeText) + not (String.IsNullOrWhiteSpace text) - /// Overload with default parameter for F# callers. - let HasTextDefault (maybeText: string) = hasText maybeText false + let hasNonWhitespaceText text = hasText text false /// Collection helpers (similar to the original extension members). module SeqEx = @@ -70,13 +69,11 @@ module ExtensionMethods = /// Trims trailing newline characters (Environment.NewLine) from the end of the string. member this.TrimTerminalLineBreak() : string = - if HasTextDefault this then + if hasNonWhitespaceText this then this.TrimEnd(Environment.NewLine.ToCharArray()) else this - -module Utilities = /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. /// Throws if the replacement character is an invalid path character. From 8a09b9fa6ccf3279f5f9ba0fc3e0291a89e455f2 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:14:18 +0900 Subject: [PATCH 030/247] Remove unused Seq module --- src/CCVTAC.FSharp/Utilities.fs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 1f107093..b71d0405 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -18,16 +18,6 @@ module Utilities = let hasNonWhitespaceText text = hasText text false - /// Collection helpers (similar to the original extension members). - module SeqEx = - /// Determines whether a sequence is empty. - let None (collection: seq<'a>) : bool = - Seq.isEmpty collection - - /// Determines whether no elements of a sequence satisfy a given condition. - let NoneBy (predicate: 'a -> bool) (collection: seq<'a>) : bool = - not (Seq.exists predicate collection) - /// Case-insensitive contains for a sequence of strings. let caseInsensitiveContains (collection: seq) (text: string) : bool = collection From 2659d0646370d3d496293ef31c45be43bbbc013e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:17:43 +0900 Subject: [PATCH 031/247] Func brush-up --- src/CCVTAC.FSharp/Utilities.fs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index b71d0405..2baf82cb 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -10,18 +10,14 @@ module Utilities = /// Determines whether a string contains any text. /// allowWhiteSpace = true allows whitespace to count as text. - let hasText text allowWhiteSpace = - if allowWhiteSpace then - not (String.IsNullOrEmpty text) - else - not (String.IsNullOrWhiteSpace text) + let hasText text whiteSpaceCounts = + let f = if whiteSpaceCounts then String.IsNullOrEmpty else String.IsNullOrWhiteSpace + not (f text) let hasNonWhitespaceText text = hasText text false - /// Case-insensitive contains for a sequence of strings. - let caseInsensitiveContains (collection: seq) (text: string) : bool = - collection - |> Seq.exists (fun s -> String.Equals(s, text, StringComparison.OrdinalIgnoreCase)) + let caseInsensitiveContains (xs: string seq) text : bool = + xs |> Seq.exists (fun s -> String.Equals(s, text, StringComparison.OrdinalIgnoreCase)) /// String instance helpers as an F# type extension for System.String. type System.String with From aa4d6d72e2ec2f64098bedbf6d83dcdc41f88aef Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:28:54 +0900 Subject: [PATCH 032/247] More Utility cleanup --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/Settings/Settings.fs | 2 - src/CCVTAC.FSharp/Utilities.fs | 61 ++++++----------------- 3 files changed, 17 insertions(+), 48 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index cb4af3e0..e1362375 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -117,7 +117,7 @@ module Mover = | Ok v -> v.Uploader | Error _ -> String.Empty - let safeName = workingName.ReplaceInvalidPathChars().Trim() + let safeName = workingName |> replaceInvalidPathChars None None |> _.Trim() let topicSuffix = " - Topic" if safeName.EndsWith topicSuffix then safeName.Replace(topicSuffix, String.Empty) else safeName diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index a32ba94c..aef1a4b2 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -7,8 +7,6 @@ open Spectre.Console module Settings = - let newLine = Environment.NewLine - type FilePath = FilePath of string type RenamePattern = { diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 2baf82cb..1eab4542 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -8,6 +8,8 @@ open System.Collections.Generic [] module Utilities = + let newLine = Environment.NewLine + /// Determines whether a string contains any text. /// allowWhiteSpace = true allows whitespace to count as text. let hasText text whiteSpaceCounts = @@ -17,55 +19,19 @@ module Utilities = let hasNonWhitespaceText text = hasText text false let caseInsensitiveContains (xs: string seq) text : bool = - xs |> Seq.exists (fun s -> String.Equals(s, text, StringComparison.OrdinalIgnoreCase)) - - /// String instance helpers as an F# type extension for System.String. - type System.String with - - /// Returns a new string in which all invalid path characters for the current OS - /// have been replaced by the specified replacement character. - /// Throws if the replacement character is an invalid path character. - member this.ReplaceInvalidPathChars(?replaceWith: char, ?customInvalidChars: char[]) : string = - let replaceWith = defaultArg replaceWith '_' - let custom = defaultArg customInvalidChars [||] - - // Collect invalid characters - let invalidCharsSeq = - seq { - yield! Path.GetInvalidFileNameChars() - yield! Path.GetInvalidPathChars() - yield Path.PathSeparator - yield Path.DirectorySeparatorChar - yield Path.AltDirectorySeparatorChar - yield Path.VolumeSeparatorChar - yield! custom - } - |> Seq.distinct - - let invalidSet = HashSet(invalidCharsSeq) - - if invalidSet.Contains replaceWith then - invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." - - // Replace each invalid char in the string using StringBuilder for efficiency - let sb = StringBuilder(this) - for ch in invalidSet do - sb.Replace(ch, replaceWith) |> ignore - sb.ToString() - - /// Trims trailing newline characters (Environment.NewLine) from the end of the string. - member this.TrimTerminalLineBreak() : string = - if hasNonWhitespaceText this then - this.TrimEnd(Environment.NewLine.ToCharArray()) - else - this + xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. /// Throws if the replacement character is an invalid path character. - let replaceInvalidPathChars (replaceWith: char option) (customInvalidChars: char array option) (text: string) : string = + let replaceInvalidPathChars + (replaceWith: char option) + (customInvalidChars: char list option) + (text: string) + : string = + let replaceWith = defaultArg replaceWith '_' - let custom = defaultArg customInvalidChars [||] + let custom = defaultArg customInvalidChars [] let invalidCharsSeq = seq { @@ -79,7 +45,7 @@ module Utilities = } |> Seq.distinct - let invalidSet = HashSet(invalidCharsSeq) + let invalidSet = invalidCharsSeq |> Set.ofSeq if invalidSet.Contains replaceWith then invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." @@ -90,3 +56,8 @@ module Utilities = sb.Replace(ch, replaceWith) |> ignore sb.ToString() + let trimTerminalLineBreak (text: string) : string = + if hasNonWhitespaceText text then + text.TrimEnd(newLine.ToCharArray()) + else + text From 8feeef90785f0832bfa08960767c6ff7ee3601a7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:40:41 +0900 Subject: [PATCH 033/247] Refactor replaceInvalidPathChars func --- src/CCVTAC.FSharp/Utilities.fs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 1eab4542..bce03e4c 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -3,7 +3,6 @@ namespace CCVTAC.Console open System open System.IO open System.Text -open System.Collections.Generic [] module Utilities = @@ -33,7 +32,7 @@ module Utilities = let replaceWith = defaultArg replaceWith '_' let custom = defaultArg customInvalidChars [] - let invalidCharsSeq = + let invalidChars = seq { yield! Path.GetInvalidFileNameChars() yield! Path.GetInvalidPathChars() @@ -43,18 +42,16 @@ module Utilities = yield Path.VolumeSeparatorChar yield! custom } - |> Seq.distinct + |> Set.ofSeq - let invalidSet = invalidCharsSeq |> Set.ofSeq + if invalidChars.Contains replaceWith then + invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." - if invalidSet.Contains replaceWith - then invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." - - // Replace each invalid char in the string. // TODO: `fold`/`reduce` this up and return a Result! - let sb = StringBuilder text - for ch in invalidSet do - sb.Replace(ch, replaceWith) |> ignore - sb.ToString() + Set.fold + (fun (sb: StringBuilder) ch -> sb.Replace(ch, replaceWith)) + (StringBuilder text) + invalidChars + |> _.ToString() let trimTerminalLineBreak (text: string) : string = if hasNonWhitespaceText text then From 5bbc10e3c720dcd99c1b5aa3e60359403d19a9e5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:41:24 +0900 Subject: [PATCH 034/247] Postprocessing code tweaks --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- .../PostProcessing/PostProcessing.fs | 42 ++++++++----------- src/CCVTAC.FSharp/Shared.fs | 2 +- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 7f8901df..2d98215c 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -16,7 +16,7 @@ module Directories = Directory.GetFiles directory |> Array.filter (fun f -> AudioExtensions - |> Array.exists (fun ext -> + |> List.exists (fun ext -> // let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. // let ext' = match Path.GetExtension (ext: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. // Path.GetExtension(f).Equals(ext, StringComparison.OrdinalIgnoreCase) // Error occurs here. diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index aabeea44..c27088e5 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -116,7 +116,7 @@ module Orchestrator = Error errorMsg | Ok s -> printer.Debug $"Successfully downloaded \"%s{s}\" format." - PostProcessor.Run settings mediaType printer + PostProcessor.run settings mediaType printer let groupClause = if batchSize > 1 diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 2ee4bfea..ec7aa799 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -4,21 +4,15 @@ open System.IO open System.Linq open System.Text.Json open System.Text.RegularExpressions -open System.Collections.Immutable +open CCVTAC.Console open CCVTAC.Console.IoUtilities +open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings -open CCVTAC.Console -open CCVTAC.Console.Downloading -open CCVTAC.Console.Downloading.Downloading open Startwatch.Library module PostProcessor = - // let internal AudioExtensions = - // [| ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" |] - let private collectionMetadataRegex = Regex(@"(?<= @@ -26,14 +20,14 @@ module PostProcessor = \.info.json)", RegexOptions.Compiled) - let private getCollectionMetadataMatches (path: string) = + let private isCollectionMetadataMatch (path: string) : bool = collectionMetadataRegex.IsMatch path - let private GetCollectionJson (workingDirectory: string) : Result = + let private getCollectionJson workingDirectoryName : Result = try let fileNames = - Directory.GetFiles workingDirectory - |> Seq.filter getCollectionMetadataMatches + Directory.GetFiles workingDirectoryName + |> Seq.filter isCollectionMetadataMatch |> Set.ofSeq if fileNames.Count = 0 then @@ -47,36 +41,36 @@ module PostProcessor = let collectionData = JsonSerializer.Deserialize(json) #warnon 3265 if isNull (box collectionData) then - Error "Deserialized collection metadata was null." + Error $"Deserialized collection metadata for \"%s{fileName}\" was null." else Ok collectionData with ex -> Error ex.Message - let private GenerateTaggingSets (directory: string) : Result = + let private generateTaggingSets directoryName : Result = try - let files = Directory.GetFiles directory + let files = Directory.GetFiles directoryName let taggingSets = TaggingSet.CreateSets files - if taggingSets.Any() - then Ok taggingSets - else Error (sprintf "No tagging sets were created using working directory \"%s\"." directory) + if List.isEmpty taggingSets + then Error $"No tagging sets were created using working directory \"%s{directoryName}\"." + else Ok taggingSets with | :? DirectoryNotFoundException -> - Error (sprintf "Directory \"%s\" does not exist." directory) + Error $"Directory \"%s{directoryName}\" does not exist." | ex -> - Error (sprintf "Error reading working directory files: %s" ex.Message) + Error $"Error reading working directory files: %s{ex.Message}" - let Run (settings: UserSettings) (mediaType: MediaType) (printer: Printer) : unit = + let run settings mediaType (printer: Printer) : unit = let watch = Watch() let workingDirectory = settings.WorkingDirectory printer.Info "Starting post-processing..." - match GenerateTaggingSets workingDirectory with + match generateTaggingSets workingDirectory with | Error _ -> - printer.Error "No tagging sets were generated, so tagging cannot be done." + printer.Error $"No tagging sets were generated for directory {workingDirectory}, so tagging cannot be done." | Ok taggingSets -> - let collectionJsonResult = GetCollectionJson workingDirectory + let collectionJsonResult = getCollectionJson workingDirectory let collectionJsonOpt = match collectionJsonResult with diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index ceae0912..b4332a77 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -4,4 +4,4 @@ namespace CCVTAC.Console module Shared = let AudioExtensions = - [| ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" |] + [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] From 58cfc088b048b4b4297eb6986c83e22b56901615 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:13:50 +0900 Subject: [PATCH 035/247] More Directories updates --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 95 +++++++++----------- 1 file changed, 40 insertions(+), 55 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 2d98215c..8ee71068 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -13,61 +13,37 @@ module Directories = /// Counts the number of audio files in a directory let internal audioFileCount (directory: string) = - Directory.GetFiles directory - |> Array.filter (fun f -> - AudioExtensions - |> List.exists (fun ext -> - // let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. - // let ext' = match Path.GetExtension (ext: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. - // Path.GetExtension(f).Equals(ext, StringComparison.OrdinalIgnoreCase) // Error occurs here. - StringComparer.OrdinalIgnoreCase.Equals(Path.GetExtension(f), ext) - ) - ) - |> Array.length - - /// Warns if any files are present in the directory - let internal warnIfAnyFiles (directory: string) (showMax: int) = - let fileNames = - Directory.GetFiles(directory, AllFilesSearchPattern, enumerationOptions) - |> Array.map Path.GetFileName - - if fileNames.Length = 0 then - Ok() - else - let fileLabel = if fileNames.Length = 1 then "file" else "files" - let report = StringBuilder() - - report.AppendLine( - $"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{directory}\":" - ) |> ignore - - fileNames - |> Array.truncate showMax - |> Array.iter (fun fileName -> - report.AppendLine($"• {fileName}") |> ignore - ) + DirectoryInfo(directory).EnumerateFiles() + |> Seq.filter (fun f -> caseInsensitiveContains AudioExtensions f.Extension) + |> Seq.length - if fileNames.Length > showMax then - report.AppendLine($"... plus {fileNames.Length - showMax} more.") |> ignore + /// Returns the filenames in a given directory, optionally ignoring specific filenames + let private getDirectoryFileNames + (directoryName: string) + (customIgnoreFiles: string seq option) = - report.AppendLine("This generally occurs due to the same video appearing twice in playlists.") |> ignore + let ignoreFiles = + customIgnoreFiles + |> Option.defaultValue Seq.empty + |> Seq.distinct + |> Seq.toArray - Error (report.ToString()) + Directory.GetFiles(directoryName, AllFilesSearchPattern, enumerationOptions) + |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) /// Deletes all files in the working directory let internal deleteAllFiles (workingDirectory: string) (showMaxErrors: int) = - let fileNames = - Directory.GetFiles(workingDirectory, AllFilesSearchPattern, enumerationOptions) + let fileNames = getDirectoryFileNames workingDirectory None let mutable successCount = 0 - let errors = ResizeArray() + let errors = ResizeArray() // TODO: Use an F# list. fileNames |> Array.iter (fun fileName -> try File.Delete fileName successCount <- successCount + 1 with - | ex -> errors.Add(ex.Message) + | ex -> errors.Add ex.Message ) if errors.Count = 0 then @@ -90,23 +66,32 @@ module Directories = /// Asks user if they want to delete all files let internal askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = - let doDelete = printer.AskToBool("Delete all temporary files?", "Yes", "No") - - if doDelete + if printer.AskToBool("Delete all temporary files?", "Yes", "No") then deleteAllFiles workingDirectory 10 else Error "Will not delete the files." - /// Returns the filenames in a given directory, optionally ignoring specific filenames - let private getDirectoryFileNames - (directoryName: string) - (customIgnoreFiles: string seq option) = + let internal warnIfAnyFiles (directory: string) (showMax: int) = + let fileNames = getDirectoryFileNames directory None - let ignoreFiles = - customIgnoreFiles - |> Option.defaultValue Seq.empty - |> Seq.distinct - |> Seq.toArray + if fileNames.Length = 0 then + Ok() + else + let fileLabel = if fileNames.Length = 1 then "file" else "files" + let report = StringBuilder() - Directory.GetFiles(directoryName, AllFilesSearchPattern, enumerationOptions) - |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) + report.AppendLine( + $"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{directory}\":" + ) |> ignore + fileNames + |> Array.truncate showMax + |> Array.iter (fun fileName -> + report.AppendLine($"• {fileName}") |> ignore + ) + + if fileNames.Length > showMax then + report.AppendLine($"... plus {fileNames.Length - showMax} more.") |> ignore + + report.AppendLine("This generally occurs due to the same video appearing twice in playlists.") |> ignore + + Error (report.ToString()) From 9fa56990056ccc53cef0e23dec9d8e1dcada8718 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:41:27 +0900 Subject: [PATCH 036/247] Fix file-handling error causing leftover files --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 86 ++++++++++--------- .../PostProcessing/PostProcessing.fs | 8 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index e1362375..f5755bc2 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -16,59 +16,57 @@ open CCVTAC.Console.PostProcessing module Mover = - let private PlaylistImageRegex = Regex(@" - -\[[OP]L[\w\d_-]{12,}\] - -", RegexOptions.Compiled) + let private PlaylistImageRegex = Regex(@"\[[OP]L[\w\d_-]{12,}\]", RegexOptions.Compiled) let private ImageFileWildcard = "*.jp*" - let private IsPlaylistImage (fileName: string) = + let private isPlaylistImage (fileName: string) = PlaylistImageRegex.IsMatch fileName - let private GetCoverImage (workingDirInfo: DirectoryInfo) (audioFileCount: int) : FileInfo option = + let private getCoverImage (workingDirInfo: DirectoryInfo) audioFileCount : FileInfo option = let images = workingDirInfo.EnumerateFiles ImageFileWildcard |> Seq.toArray if images.Length = 0 then None else - let playlistImages = images |> Seq.filter (fun i -> IsPlaylistImage(i.FullName)) |> Seq.toList + let playlistImages = images |> Array.filter (fun i -> isPlaylistImage i.FullName) |> Array.toList if playlistImages.Any() then Some (playlistImages.First()) else if audioFileCount > 1 && images.Length = 1 then Some images[0] else None - let private EnsureDirectoryExists (moveToDir: string) (printer: Printer) : Result = + let private ensureDirectoryExists (moveToDir: string) (printer: Printer) : Result = try if Directory.Exists moveToDir then - printer.Debug (sprintf "Found move-to directory \"%s\"." moveToDir) + printer.Debug $"Found move-to directory \"%s{moveToDir}\"." Ok () else - printer.Debug (sprintf "Creating move-to directory \"%s\" (based on playlist metadata)... " moveToDir, appendLineBreak = false) + printer.Debug ($"Creating move-to directory \"%s{moveToDir}\" (based on playlist metadata)... ", appendLineBreak = false) Directory.CreateDirectory moveToDir |> ignore printer.Debug "OK." Ok () with ex -> - printer.Error (sprintf "Error creating move-to directory \"%s\": %s" moveToDir ex.Message) - Error String.Empty + printer.Error $"Error creating move-to directory \"%s{moveToDir}\": %s{ex.Message}" + Error String.Empty // TODO: Update. - let private MoveAudioFiles + let private moveAudioFiles (audioFiles: FileInfo list) (moveToDir: string) (overwrite: bool) (printer: Printer) - : uint32 * uint32 = + : uint32 * uint32 = // TODO: Need a custom type for clarity. let mutable successCount = 0u let mutable failureCount = 0u + for file in audioFiles do try File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) successCount <- successCount + 1u - printer.Debug (sprintf "• Moved \"%s\"" file.Name) + printer.Debug $"• Moved \"%s{file.Name}\"" with ex -> failureCount <- failureCount + 1u - printer.Error (sprintf "• Error moving file \"%s\": %s" file.Name ex.Message) + printer.Error $"• Error moving file \"%s{file.Name}\": %s{ex.Message}" + (successCount, failureCount) - let private MoveImageFile + let private moveImageFile (maybeCollectionName: string) (subFolderName: string) (workingDirInfo: DirectoryInfo) @@ -77,74 +75,80 @@ module Mover = (overwrite: bool) (printer: Printer) : unit = + try let baseFileName = if String.IsNullOrWhiteSpace maybeCollectionName then subFolderName else - sprintf "%s - %s" subFolderName (replaceInvalidPathChars None None maybeCollectionName) + $"%s{subFolderName} - %s{replaceInvalidPathChars None None maybeCollectionName}" - match GetCoverImage workingDirInfo audioFileCount with + match getCoverImage workingDirInfo audioFileCount with | None -> () | Some image -> - let dest = Path.Combine(moveToDir, sprintf "%s.jpg" (baseFileName.Trim())) + let dest = Path.Combine(moveToDir, $"%s{baseFileName.Trim()}.jpg") image.MoveTo(dest, overwrite = overwrite) printer.Info "Moved image file." with ex -> - printer.Warning (sprintf "Error copying the image file: %s" ex.Message) + printer.Warning $"Error copying the image file: %s{ex.Message}" - let private GetParsedVideoJson (taggingSet: TaggingSet) : Result = + let private getParsedVideoJson (taggingSet: TaggingSet) : Result = try let json = File.ReadAllText(taggingSet.JsonFilePath) + try #nowarn 3265 let videoData = JsonSerializer.Deserialize(json) #warnon 3265 - if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) + if isNull (box videoData) + then Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"" else Ok videoData with :? JsonException as ex -> - Error (sprintf "Error deserializing JSON from file \"%s\": %s" taggingSet.JsonFilePath ex.Message) + Error $"Error deserializing JSON from file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}" with ex -> - Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) + Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}." - let private GetSafeSubDirectoryName (collectionData: CollectionMetadata option) (taggingSet: TaggingSet) : string = + let private getSafeSubDirectoryName (collectionData: CollectionMetadata option) taggingSet : string = let workingName = match collectionData with - | Some metadata when hasNonWhitespaceText metadata.Uploader && hasNonWhitespaceText metadata.Title -> metadata.Uploader + | Some metadata when hasNonWhitespaceText metadata.Uploader && + hasNonWhitespaceText metadata.Title -> metadata.Uploader | _ -> - match GetParsedVideoJson taggingSet with + match getParsedVideoJson taggingSet with | Ok v -> v.Uploader - | Error _ -> String.Empty + | Error _ -> "COLLECTION_DATA_NOT_FOUND" let safeName = workingName |> replaceInvalidPathChars None None |> _.Trim() let topicSuffix = " - Topic" - if safeName.EndsWith topicSuffix then safeName.Replace(topicSuffix, String.Empty) + if safeName.EndsWith topicSuffix + then safeName.Replace(topicSuffix, String.Empty) else safeName - let Run + let run (taggingSets: seq) (maybeCollectionData: CollectionMetadata option) (settings: UserSettings) (overwrite: bool) (printer: Printer) : unit = + printer.Debug "Starting move..." - let watch = Watch() // assumes Watch type with ElapsedFriendly exists + let watch = Watch() - let workingDirInfo = DirectoryInfo(settings.WorkingDirectory) + let workingDirInfo = DirectoryInfo settings.WorkingDirectory let firstTaggingSet = taggingSets |> Seq.tryHead - |> Option.defaultWith (fun () -> failwith "No tagging sets provided") + |> Option.defaultWith (fun () -> failwith "No tagging sets provided") // TODO: Improve. - let subFolderName = GetSafeSubDirectoryName maybeCollectionData firstTaggingSet - let collectionName = maybeCollectionData |> Option.map (fun c -> c.Title) |> Option.defaultValue String.Empty + let subFolderName = getSafeSubDirectoryName maybeCollectionData firstTaggingSet + let collectionName = maybeCollectionData |> Option.map _.Title |> Option.defaultValue String.Empty let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) - match EnsureDirectoryExists fullMoveToDir printer with - | Error _ -> () // error already printed + match ensureDirectoryExists fullMoveToDir printer with + | Error _ -> () // Error was already printed. | Ok () -> let audioFileNames = workingDirInfo.EnumerateFiles() @@ -157,9 +161,9 @@ module Mover = printer.Debug $"Moving %d{audioFileNames.Length} audio file(s) to \"%s{fullMoveToDir}\"..." let successCount, failureCount = - MoveAudioFiles audioFileNames fullMoveToDir overwrite printer + moveAudioFiles audioFileNames fullMoveToDir overwrite printer - MoveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite printer + moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite printer let fileLabel = if successCount = 1u then "file" else "files" printer.Info $"Moved %d{successCount} audio %s{fileLabel} in %s{watch.ElapsedFriendly}." diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index ec7aa799..8f7ea9c1 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -14,11 +14,7 @@ open Startwatch.Library module PostProcessor = let private collectionMetadataRegex = - Regex(@"(?<= - -\[)[\w\-]{17,}(?=\] - -\.info.json)", RegexOptions.Compiled) + Regex(@"(?<=\[)[\w\-]{17,}(?=\]\.info.json)", RegexOptions.Compiled) let private isCollectionMetadataMatch (path: string) : bool = collectionMetadataRegex.IsMatch path @@ -89,7 +85,7 @@ module PostProcessor = | Ok msg -> printer.Info msg Renamer.Run settings workingDirectory printer - Mover.Run taggingSets collectionJsonOpt settings true printer + Mover.run taggingSets collectionJsonOpt settings true printer let taggingSetFileNames = taggingSets From baa41deaa2087f5204746becd9587dab60533dc7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 13:59:19 +0900 Subject: [PATCH 037/247] Add func comment --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 55980aa3..8e2f04e3 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -14,17 +14,12 @@ module Downloader = type Urls = { Primary: string Supplementary: string option } + // TODO: Is the audioFormat not in the settings? /// Generate the entire argument string for the download tool. /// audioFormat: one of the supported audio format codes (or null for none) /// mediaType: Some MediaType for normal downloads, None for metadata-only supplementary downloads /// additionalArgs: optional extra args (e.g., the URL) - let generateDownloadArgs - (audioFormat: string option) - (settings: UserSettings) - (mediaType: MediaType option) - (additionalArgs: string[] option) - : string = - + let generateDownloadArgs audioFormat settings (mediaType: MediaType option) additionalArgs : string = let writeJson = "--write-info-json" let trimFileNames = "--trim-filenames 250" @@ -134,7 +129,6 @@ module Downloader = let args = generateDownloadArgs None settings None (Some [| supplementaryUrl |]) let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory - let supplementaryDownloadResult = Runner.run downloadSettings [1] printer match supplementaryDownloadResult with From 44c53b0dad872f1525236a3ca68d2e2273046d2b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:20:25 +0900 Subject: [PATCH 038/247] Simplify func signatures --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 8e2f04e3..b13bffa3 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -55,25 +55,20 @@ module Downloader = args.Add($"--sleep-interval {settings.SleepSecondsBetweenDownloads}") |> ignore if mt.IsStandardPlaylist then - args.Add( + args.Add """-o "%(uploader).80B - %(playlist).80B - %(playlist_autonumber)s - %(title).150B [%(id)s].%(ext)s" --playlist-reverse""" - ) |> ignore + |> ignore | None -> () let extras = defaultArg additionalArgs [||] |> Set.ofArray String.Join(" ", args |> Set.union extras) - let internal wrapUrlInMediaType (url: string) : Result = + let internal wrapUrlInMediaType url : Result = mediaTypeWithIds url /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. - let internal run - (mediaType: MediaType) - (settings: UserSettings) - (printer: Printer) - : Result = - + let internal run (mediaType: MediaType) settings (printer: Printer) : Result = if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") @@ -82,8 +77,8 @@ module Downloader = { Primary = rawUrls[0] Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } - let mutable downloadResult : Result = Error "" - let mutable successfulFormat : string = "" + let mutable downloadResult : Result = Error String.Empty + let mutable successfulFormat = String.Empty let mutable stopped = false let mutable errors : string list = [] From 1813fbab47e3b8db378de198484d2c3db35c7858 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:13:13 +0900 Subject: [PATCH 039/247] Make download warnings options --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 15 ++++++++------- src/CCVTAC.FSharp/Downloading/Updater.fs | 5 +++-- src/CCVTAC.FSharp/Downloading/Uploader.fs | 5 +++-- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 8 ++------ src/CCVTAC.FSharp/Utilities.fs | 15 +++++++++++---- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index b13bffa3..62cd6c39 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -1,6 +1,7 @@ namespace CCVTAC.Console.Downloading open CCVTAC.Console +open CCVTAC.Console.IoUtilities.Directories open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.ExternalTools open CCVTAC.Console.Settings.Settings @@ -77,7 +78,7 @@ module Downloader = { Primary = rawUrls[0] Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } - let mutable downloadResult : Result = Error String.Empty + let mutable downloadResult : Result = Error String.Empty let mutable successfulFormat = String.Empty let mutable stopped = false let mutable errors : string list = [] @@ -96,8 +97,9 @@ module Downloader = if exitCode <> 0 then printer.Warning "Downloading completed with minor issues." - if not (String.IsNullOrWhiteSpace warning) then - printer.Warning warning + match warning with + | Some w -> printer.Warning w + | None -> () stopped <- true | Error e -> @@ -105,8 +107,7 @@ module Downloader = errors <- match downloadResult with Error e -> [e] | Ok _ -> [] - let audioFileCount = IoUtilities.Directories.audioFileCount settings.WorkingDirectory - if audioFileCount = 0 then + if audioFileCount settings.WorkingDirectory = 0 then let combinedErrors = errors |> List.append ["No audio files were downloaded."] @@ -114,9 +115,9 @@ module Downloader = Error combinedErrors else // Continue to post-processing if errors. - if not (List.isEmpty errors) then + if List.isNotEmpty errors then errors |> List.iter printer.Error - printer.Info("Post-processing will still be attempted.") + printer.Info "Post-processing will still be attempted." else // Attempt a metadata-only supplementary download. match urls.Supplementary with diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index c97bab51..8ea8b8e2 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -33,8 +33,9 @@ module Updater = if exitCode <> 0 then printer.Warning("Update completed with minor issues.") - if not (System.String.IsNullOrEmpty warnings) then - printer.Warning warnings + match warnings with + | Some w -> printer.Warning w + | None -> () Ok() diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index 8ac526b0..284491ce 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -20,8 +20,9 @@ module Updater = | Ok (exitCode, warnings) -> if exitCode <> 0 then printer.Warning "Update completed with minor issues." - if not (String.IsNullOrEmpty warnings) then - printer.Warning warnings + match warnings with + | Some w -> printer.Warning w + | None -> () Ok() | Error error -> printer.Error($"Failure updating: %s{error}") diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index d64e8834..1ae1aff3 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -21,7 +21,7 @@ module Runner = (settings: ToolSettings) (otherSuccessExitCodes: int list) (printer: Printer) - : Result = + : Result = let watch = Watch() @@ -42,21 +42,17 @@ module Runner = processStartInfo.CreateNoWindow <- true processStartInfo.WorkingDirectory <- settings.WorkingDirectory - // Start the process match Process.Start processStartInfo with | null -> - // Process failed to start Error $"Could not locate {splitCommandWithArgs[0]}." | process' -> - // Read errors before waiting for exit let error = process'.StandardError.ReadToEnd() - // Wait for process to complete process'.WaitForExit() printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}.") - let trimmedErrors = error // TODO: Trim terminal line break? + let trimmedErrors = if hasNonWhitespaceText error then Some (trimTerminalLineBreak error) else None if isSuccessExitCode otherSuccessExitCodes process'.ExitCode then Ok (process'.ExitCode, trimmedErrors) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index bce03e4c..e7f017b7 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -4,6 +4,8 @@ open System open System.IO open System.Text +type SB = StringBuilder + [] module Utilities = @@ -15,7 +17,8 @@ module Utilities = let f = if whiteSpaceCounts then String.IsNullOrEmpty else String.IsNullOrWhiteSpace not (f text) - let hasNonWhitespaceText text = hasText text false + let hasNonWhitespaceText text = + hasText text false let caseInsensitiveContains (xs: string seq) text : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) @@ -48,13 +51,17 @@ module Utilities = invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." Set.fold - (fun (sb: StringBuilder) ch -> sb.Replace(ch, replaceWith)) - (StringBuilder text) + (fun (sb: SB) ch -> sb.Replace(ch, replaceWith)) + (SB text) invalidChars |> _.ToString() - let trimTerminalLineBreak (text: string) : string = + let trimTerminalLineBreak text : string = if hasNonWhitespaceText text then text.TrimEnd(newLine.ToCharArray()) else text + +module List = + let isNotEmpty l = not (List.isEmpty l) + From 3179fd9450a01e55cae09f81b78abe048b0c81ee Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:20:37 +0900 Subject: [PATCH 040/247] Change array to list --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 62cd6c39..1f22af49 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -61,7 +61,7 @@ module Downloader = |> ignore | None -> () - let extras = defaultArg additionalArgs [||] |> Set.ofArray + let extras = defaultArg additionalArgs [] |> Set.ofList String.Join(" ", args |> Set.union extras) let internal wrapUrlInMediaType url : Result = @@ -85,7 +85,7 @@ module Downloader = for format in settings.AudioFormats do if not stopped then - let args = generateDownloadArgs (Some format) settings (Some mediaType) (Some [| urls.Primary |]) + let args = generateDownloadArgs (Some format) settings (Some mediaType) (Some [urls.Primary]) let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory @@ -111,7 +111,7 @@ module Downloader = let combinedErrors = errors |> List.append ["No audio files were downloaded."] - |> String.concat Environment.NewLine + |> String.concat newLine Error combinedErrors else // Continue to post-processing if errors. @@ -122,16 +122,16 @@ module Downloader = // Attempt a metadata-only supplementary download. match urls.Supplementary with | Some supplementaryUrl -> - let args = generateDownloadArgs None settings None (Some [| supplementaryUrl |]) + let args = generateDownloadArgs None settings None (Some [supplementaryUrl]) let commandWithArgs = $"{ProgramName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory let supplementaryDownloadResult = Runner.run downloadSettings [1] printer match supplementaryDownloadResult with | Ok _ -> - printer.Info("Supplementary download completed OK.") + printer.Info("Supplementary metadata download completed OK.") | Error err -> - printer.Error("Supplementary download failed.") + printer.Error("Supplementary metadata download failed.") errors <- List.append [err] errors | None -> () From ff5c29b6ea4a2e1f0cd9d3843853d071d527213b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:37:22 +0900 Subject: [PATCH 041/247] Runner type tweaks --- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 39 ++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 1ae1aff3..e77ae568 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -6,41 +6,36 @@ open Startwatch.Library open System.Diagnostics module Runner = + [] let private AuthenticSuccessExitCode = 0 let private isSuccessExitCode (otherSuccessExitCodes: int list) (exitCode: int) = - List.contains exitCode (List.append otherSuccessExitCodes [AuthenticSuccessExitCode]) + // List.contains exitCode (List.append otherSuccessExitCodes [AuthenticSuccessExitCode]) + List.contains exitCode (AuthenticSuccessExitCode :: otherSuccessExitCodes) /// Calls an external application. /// Tool settings for execution /// Additional exit codes, other than 0, that can be treated as non-failures /// Printer for logging - /// A `Result` containing the exit code, if successful, or else an error message - let internal run - (settings: ToolSettings) - (otherSuccessExitCodes: int list) - (printer: Printer) + /// A Result instance containing the exit code and any warnings or else an error message. + let internal run toolSettings (otherSuccessExitCodes: int list) (printer: Printer) : Result = let watch = Watch() + printer.Info $"Running {toolSettings.CommandWithArgs}..." - // Log start of execution - printer.Info($"Running {settings.CommandWithArgs}...") - - // Split command and arguments - let splitCommandWithArgs = - settings.CommandWithArgs.Split([|' '|], 2) + let splitCommandWithArgs = toolSettings.CommandWithArgs.Split([|' '|], 2) - // Prepare process start info let processStartInfo = ProcessStartInfo splitCommandWithArgs[0] - // processStartInfo.FileName <- splitCommandWithArgs[0] - processStartInfo.Arguments <- if splitCommandWithArgs.Length > 1 then splitCommandWithArgs[1] else String.Empty + processStartInfo.Arguments <- if splitCommandWithArgs.Length > 1 + then splitCommandWithArgs[1] + else String.Empty processStartInfo.UseShellExecute <- false processStartInfo.RedirectStandardOutput <- false processStartInfo.RedirectStandardError <- true processStartInfo.CreateNoWindow <- true - processStartInfo.WorkingDirectory <- settings.WorkingDirectory + processStartInfo.WorkingDirectory <- toolSettings.WorkingDirectory match Process.Start processStartInfo with | null -> @@ -49,12 +44,12 @@ module Runner = let error = process'.StandardError.ReadToEnd() process'.WaitForExit() - printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}.") - let trimmedErrors = if hasNonWhitespaceText error then Some (trimTerminalLineBreak error) else None + let trimmedErrors = if hasNonWhitespaceText error + then Some (trimTerminalLineBreak error) + else None - if isSuccessExitCode otherSuccessExitCodes process'.ExitCode then - Ok (process'.ExitCode, trimmedErrors) - else - Error $"{splitCommandWithArgs[0]} exited with code {process'.ExitCode}: {trimmedErrors}." + if isSuccessExitCode otherSuccessExitCodes process'.ExitCode + then Ok (process'.ExitCode, trimmedErrors) + else Error $"{splitCommandWithArgs[0]} exited with code {process'.ExitCode}: {trimmedErrors}." From ae50ca7d798cb21c6fef5dc03f4bbb68da949979 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:59:43 +0900 Subject: [PATCH 042/247] Update ToolSettings --- src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs b/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs index 29adc045..056cee54 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs +++ b/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs @@ -11,9 +11,6 @@ type ToolSettings = { [] module ToolSettings = - /// Creates a new ToolSettings instance - let create (commandWithArgs: string) (workingDirectory: string) = - { - CommandWithArgs = commandWithArgs - WorkingDirectory = workingDirectory - } + let create commandWithArgs workingDirectory = + { CommandWithArgs = commandWithArgs + WorkingDirectory = workingDirectory } From 02d22aa3611740a0cabe95f482d1984d178f956b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:34:55 +0900 Subject: [PATCH 043/247] ToolSettings type updates --- src/CCVTAC.FSharp/Downloading/Updater.fs | 11 +++-------- src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs | 7 ++----- .../PostProcessing/ImageProcessor.fs | 16 ++++++++-------- .../PostProcessing/PostProcessing.fs | 2 +- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 8ea8b8e2..548a68d4 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -21,15 +21,10 @@ module Updater = printer.Info("No downloader update command provided, so will skip.") Ok() else - let args : ToolSettings = { - CommandWithArgs = settings.DownloaderUpdateCommand - WorkingDirectory = settings.WorkingDirectory - } + let settings = ToolSettings.create settings.DownloaderUpdateCommand settings.WorkingDirectory - // Run the update process - match Runner.run args [] printer with + match Runner.run settings [] printer with | Ok (exitCode, warnings) -> - // Handle successful run with potential warnings if exitCode <> 0 then printer.Warning("Update completed with minor issues.") @@ -40,7 +35,7 @@ module Updater = Ok() | Error error -> - printer.Error($"Failure updating: {error}") + printer.Error $"Failure updating: {error}" Error error diff --git a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs b/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs index 056cee54..c1d5f4e3 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs +++ b/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs @@ -1,16 +1,13 @@ namespace CCVTAC.Console.ExternalTools /// Settings to govern the behavior of an external program. -type ToolSettings = { - /// The full command with its arguments +type ToolSettings = private { CommandWithArgs: string - - /// The working directory for the tool's execution WorkingDirectory: string } -[] module ToolSettings = let create commandWithArgs workingDirectory = { CommandWithArgs = commandWithArgs WorkingDirectory = workingDirectory } + diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs index 30109d60..2ed408f8 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -1,15 +1,15 @@ namespace CCVTAC.Console.PostProcessing -open CCVTAC.Console open CCVTAC.Console.ExternalTools module ImageProcessor = - let internal ProgramName = "mogrify" + let private programName = "mogrify" - let internal Run (workingDirectory: string) (printer: Printer) : unit = - let imageEditToolSettings = { - CommandWithArgs = $"{ProgramName} -trim -fuzz 10%% *.jpg" - WorkingDirectory = workingDirectory - } - Runner.run imageEditToolSettings [] printer |> ignore + let internal run workingDirectory printer : unit = + let toolSettings = + ToolSettings.create + $"{programName} -trim -fuzz 10%% *.jpg" + workingDirectory + + Runner.run toolSettings [] printer |> ignore diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 8f7ea9c1..a491a7e7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -78,7 +78,7 @@ module PostProcessor = Some cm if settings.EmbedImages - then ImageProcessor.Run workingDirectory printer + then ImageProcessor.run workingDirectory printer else () match Tagger.Run settings taggingSets collectionJsonOpt mediaType printer with From 9d5f93552fbd9a45edee91c54ae414060d95dafa Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:46:41 +0900 Subject: [PATCH 044/247] ExternalTool cleanup --- .../ExternalTools/ExternalTool.fs | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs index 44f5d3eb..e163ee99 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs @@ -2,51 +2,40 @@ namespace CCVTAC.Console.ExternalTools open System.Diagnostics -type ExternalTool = { - /// The name of the program. This should be the exact text used to call it - /// on the command line, excluding any arguments. +type ExternalTool = private { Name: string - - /// The URL of the program, from which users should install it if needed. Url: string - - /// A brief summary of the purpose of the program within the context of this program. - /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). Purpose: string -} with +} + +module ExternalTool = /// Creates a new ExternalTool instance /// The name of the program. This should be the exact text used to call it /// on the command line, excluding any arguments. /// The URL of the program, from which users should install it if needed. /// A brief summary of the purpose of the program within the context of this program. /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). - static member Create(name: string, url: string, purpose: string) = - { - Name = name.Trim() - Url = url.Trim() - Purpose = purpose.Trim() - } + let create (name: string) (url: string) (purpose: string) = + { Name = name.Trim() + Url = url.Trim() + Purpose = purpose.Trim() } /// Attempts a dry run of the program to determine if it is installed and available on this system. /// A Result indicating whether the program is available or not. - member this.ProgramExists() = + let programExists name = let processStartInfo = ProcessStartInfo( - FileName = this.Name, + FileName = name, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, - CreateNoWindow = true - ) + CreateNoWindow = true) try - use process' = Process.Start processStartInfo - - match process' with + match Process.Start processStartInfo with | null -> - Error $"The program \"{this.Name}\" was not found. (The process was null.)" - | process'' -> - process''.WaitForExit() + Error $"The program \"{name}\" was not found. (The process was null.)" + | process' -> + process'.WaitForExit() Ok() with - | _ -> - Error $"The program \"{this.Name}\" was not found." + | exn -> Error $"The program \"{name}\" was not found or could not be run: {exn.Message}." From 4d22954d61c0401895847062c95b5d36940651dc Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:47:52 +0900 Subject: [PATCH 045/247] Remove unused code --- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index e77ae568..d3fcb77d 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -1,8 +1,8 @@ namespace CCVTAC.Console.ExternalTools -open System open CCVTAC.Console open Startwatch.Library +open System open System.Diagnostics module Runner = @@ -11,7 +11,6 @@ module Runner = let private AuthenticSuccessExitCode = 0 let private isSuccessExitCode (otherSuccessExitCodes: int list) (exitCode: int) = - // List.contains exitCode (List.append otherSuccessExitCodes [AuthenticSuccessExitCode]) List.contains exitCode (AuthenticSuccessExitCode :: otherSuccessExitCodes) /// Calls an external application. From c535fb306049b8ed2782c1f01764eaa4d9959440 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 28 Nov 2025 21:00:50 +0900 Subject: [PATCH 046/247] Move list instantiation down --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 1f22af49..84afc82f 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -81,7 +81,6 @@ module Downloader = let mutable downloadResult : Result = Error String.Empty let mutable successfulFormat = String.Empty let mutable stopped = false - let mutable errors : string list = [] for format in settings.AudioFormats do if not stopped then @@ -105,7 +104,7 @@ module Downloader = | Error e -> printer.Debug $"Failure downloading \"%s{format}\" format: %s{e}" - errors <- match downloadResult with Error e -> [e] | Ok _ -> [] + let mutable errors = match downloadResult with Error e -> [e] | Ok _ -> [] if audioFileCount settings.WorkingDirectory = 0 then let combinedErrors = From 63878c199f6d79ab523202d1ba2c564cc25eaa02 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:37:58 +0900 Subject: [PATCH 047/247] Add hasText and hasNoText funcs + Array.doesNotContain --- src/CCVTAC.FSharp/Commands.fs | 2 +- src/CCVTAC.FSharp/Downloading/Updater.fs | 2 +- src/CCVTAC.FSharp/Downloading/Uploader.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 8 ++--- .../PostProcessing/Tagging/Tagger.fs | 14 ++++----- .../PostProcessing/Tagging/TaggingSet.fs | 6 ++-- .../PostProcessing/VideoMetadata.fs | 19 ++++++------ .../YouTubeMetadataExtensionMethods.fs | 31 ++++++++++--------- src/CCVTAC.FSharp/Printer.fs | 4 +-- src/CCVTAC.FSharp/Settings/Settings.fs | 7 ++--- src/CCVTAC.FSharp/Utilities.fs | 20 +++++------- 13 files changed, 59 insertions(+), 60 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index f9fa2558..492418a0 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -8,7 +8,7 @@ module internal Commands = let Prefix : char = '\\' let private MakeCommand (text: string) : string = - if String.IsNullOrWhiteSpace text then + if hasNoText text then raise (ArgumentException("The text cannot be null or white space.", "text")) if text.Contains ' ' then raise (ArgumentException("The text should not contain any white space.", "text")) diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 548a68d4..c2b5b955 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -17,7 +17,7 @@ module Updater = /// A `Result` that, if successful, contains the name of the successfully downloaded format. let internal run (settings: UserSettings) (printer: Printer) = // Check if update command is provided - if System.String.IsNullOrWhiteSpace(settings.DownloaderUpdateCommand) then + if hasNoText settings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() else diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index 284491ce..eb2ba9f5 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -10,7 +10,7 @@ module Updater = Supplementary: string option } let internal run (settings: UserSettings) (printer: Printer) : Result = - if String.IsNullOrWhiteSpace settings.DownloaderUpdateCommand then + if hasNoText settings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() else diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index d3fcb77d..7958c540 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -45,7 +45,7 @@ module Runner = process'.WaitForExit() printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}.") - let trimmedErrors = if hasNonWhitespaceText error + let trimmedErrors = if hasText error then Some (trimTerminalLineBreak error) else None diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index c27088e5..96d5e961 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -47,7 +47,7 @@ module Orchestrator = | _ -> String.Empty let connector = - if hasNonWhitespaceText urlSummary && hasNonWhitespaceText commandSummary then " and " else String.Empty + if hasText urlSummary && hasText commandSummary then " and " else String.Empty printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index f5755bc2..4d83bc7c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -78,10 +78,10 @@ module Mover = try let baseFileName = - if String.IsNullOrWhiteSpace maybeCollectionName then + if hasNoText maybeCollectionName then subFolderName else - $"%s{subFolderName} - %s{replaceInvalidPathChars None None maybeCollectionName}" + $"%s{subFolderName} - %s{replaceInvalidPathChars None None maybeCollectionName}" match getCoverImage workingDirInfo audioFileCount with | None -> () @@ -112,8 +112,8 @@ module Mover = let private getSafeSubDirectoryName (collectionData: CollectionMetadata option) taggingSet : string = let workingName = match collectionData with - | Some metadata when hasNonWhitespaceText metadata.Uploader && - hasNonWhitespaceText metadata.Title -> metadata.Uploader + | Some metadata when hasText metadata.Uploader && + hasText metadata.Title -> metadata.Uploader | _ -> match getParsedVideoJson taggingSet with | Ok v -> v.Uploader diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index e1d27d45..6710bae6 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -3,7 +3,7 @@ namespace CCVTAC.Console.PostProcessing.Tagging open System open System.IO open System.Text.Json -open System.Linq +// open System.Linq open CCVTAC.Console open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing @@ -54,12 +54,12 @@ module Tagger = taggingSet let private WriteImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = - if String.IsNullOrWhiteSpace imageFilePath then + if hasNoText imageFilePath then printer.Error "No image file path was provided, so cannot add an image to the file." else try let pics = Array.zeroCreate 1 - pics.[0] <- TagLib.Picture imageFilePath + pics[0] <- TagLib.Picture imageFilePath taggedFile.Tag.Pictures <- pics printer.Debug "Image written to file tags OK." with ex -> @@ -109,7 +109,7 @@ module Tagger = // let title = tagDetector.DetectTitle(videoData, videoData.Title) // printer.Debug (sprintf "• Found title \"%s\"" title) // taggedFile.Tag.Title <- title - if not (String.IsNullOrWhiteSpace videoData.Track) then + if hasText videoData.Track then printer.Debug $"• Using metadata title \"%s{videoData.Track}\"" taggedFile.Tag.Title <- videoData.Track else @@ -120,7 +120,7 @@ module Tagger = | None -> printer.Debug "No title was found." // Artist / Performers - if hasNonWhitespaceText videoData.Artist then + if hasText videoData.Artist then let metadataArtists = videoData.Artist let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None)[0] let diffSummary = @@ -137,7 +137,7 @@ module Tagger = taggedFile.Tag.Performers <- [| artist |] // Album - if hasNonWhitespaceText videoData.Album then + if hasText videoData.Album then printer.Debug $"• Using metadata album \"%s{videoData.Album}\"" taggedFile.Tag.Album <- videoData.Album else @@ -195,7 +195,7 @@ module Tagger = match imageFilePath with | Some path -> if settings.EmbedImages && - (not (settings.DoNotEmbedImageUploaders.Contains(videoData.Uploader))) + Array.doesNotContain videoData.Uploader settings.DoNotEmbedImageUploaders then printer.Info "Embedding artwork." WriteImage taggedFile path printer diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 8fc2005d..d1f97262 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -22,11 +22,11 @@ type TaggingSet = // Private constructor helper to perform validation (not directly callable from outside) static member private CreateValidated(resourceId: string, audioFilePaths: ICollection, jsonFilePath: string, imageFilePath: string) = - if String.IsNullOrWhiteSpace resourceId then + if hasNoText resourceId then invalidArg "resourceId" "The resource ID must be provided." - if String.IsNullOrWhiteSpace jsonFilePath then + if hasNoText jsonFilePath then invalidArg "jsonFilePath" "The JSON file path must be provided." - if String.IsNullOrWhiteSpace imageFilePath then + if hasNoText imageFilePath then invalidArg "imageFilePath" "The image file path must be provided." if audioFilePaths.Count = 0 then invalidArg "audioFilePaths" "At least one audio file path must be provided." diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index 96d81fa4..69a963d5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -4,6 +4,7 @@ open System open System.Collections.Generic open System.Text open System.Text.Json.Serialization +open CCVTAC.Console [] type VideoMetadata = @@ -83,11 +84,11 @@ type VideoMetadata = /// Returns a string summarizing video uploader information. member private this.UploaderSummary() : string = let uploaderLinkOrIdOrEmpty = - if not (String.IsNullOrWhiteSpace this.UploaderUrl) then this.UploaderUrl - elif not (String.IsNullOrWhiteSpace this.UploaderId) then this.UploaderId + if hasText this.UploaderUrl then this.UploaderUrl + elif hasText this.UploaderId then this.UploaderId else String.Empty - let suffix = if not (String.IsNullOrWhiteSpace uploaderLinkOrIdOrEmpty) then sprintf " (%s)" uploaderLinkOrIdOrEmpty else String.Empty + let suffix = if hasText uploaderLinkOrIdOrEmpty then $" (%s{uploaderLinkOrIdOrEmpty})" else String.Empty this.Uploader + suffix /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") @@ -108,22 +109,22 @@ type VideoMetadata = sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - if not (String.IsNullOrWhiteSpace this.Creator) && this.Creator <> this.Uploader then + if hasText this.Creator && this.Creator <> this.Uploader then sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore - if not (String.IsNullOrWhiteSpace this.Artist) then + if hasText this.Artist then sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore - if not (String.IsNullOrWhiteSpace this.Album) then + if hasText this.Album then sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore - if not (String.IsNullOrWhiteSpace this.Title) && this.Title <> this.Fulltitle then + if hasText this.Title && this.Title <> this.Fulltitle then sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore let description = - if String.IsNullOrWhiteSpace this.Description then "None." else this.Description + if hasNoText this.Description then "None." else this.Description sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore @@ -135,7 +136,7 @@ type VideoMetadata = match this.PlaylistIndex with | NonNullV (index: uint32) -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore | NullV -> () - sb.AppendLine(sprintf "■ Playlist description: %s" (if String.IsNullOrWhiteSpace collectionData.Description then String.Empty else collectionData.Description)) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore | None -> () sb.ToString() diff --git a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs index 0b167d5f..46ad670e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs +++ b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs @@ -2,6 +2,7 @@ namespace CCVTAC.Console.PostProcessing open System open System.Text +open CCVTAC.Console module YouTubeMetadataExtensionMethods = type VideoMetadata with @@ -9,11 +10,13 @@ module YouTubeMetadataExtensionMethods = /// Returns a string summarizing video uploader information. member private this.UploaderSummary() : string = let uploaderLinkOrIdOrEmpty = - if not (String.IsNullOrWhiteSpace this.UploaderUrl) then this.UploaderUrl - elif not (String.IsNullOrWhiteSpace this.UploaderId) then this.UploaderId + if hasText this.UploaderUrl then this.UploaderUrl + elif hasText this.UploaderId then this.UploaderId else String.Empty - let suffix = if not (String.IsNullOrWhiteSpace uploaderLinkOrIdOrEmpty) then sprintf " (%s)" uploaderLinkOrIdOrEmpty else String.Empty + let suffix = if hasText uploaderLinkOrIdOrEmpty then + $" (%s{uploaderLinkOrIdOrEmpty})" + else String.Empty this.Uploader + suffix /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") @@ -34,22 +37,22 @@ module YouTubeMetadataExtensionMethods = sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - if not (String.IsNullOrWhiteSpace this.Creator) && this.Creator <> this.Uploader then - sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore + if hasText this.Creator && this.Creator <> this.Uploader then + sb.AppendLine $"■ Creator: %s{this.Creator}" |> ignore - if not (String.IsNullOrWhiteSpace this.Artist) then - sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore + if hasText this.Artist then + sb.AppendLine $"■ Artist: %s{this.Artist}" |> ignore - if not (String.IsNullOrWhiteSpace this.Album) then - sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore + if hasText this.Album then + sb.AppendLine $"■ Album: %s{this.Album}" |> ignore - if not (String.IsNullOrWhiteSpace this.Title) && this.Title <> this.Fulltitle then - sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore + if hasText this.Title && this.Title <> this.Fulltitle then + sb.AppendLine $"■ Track Title: %s{this.Title}" |> ignore - sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore + sb.AppendLine $"■ Uploaded: %s{this.FormattedUploadDate()}" |> ignore let description = - if String.IsNullOrWhiteSpace this.Description then "None." else this.Description + if hasNoText this.Description then "None." else this.Description sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore @@ -61,7 +64,7 @@ module YouTubeMetadataExtensionMethods = match this.PlaylistIndex with | NullV -> () | NonNullV index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore - sb.AppendLine(sprintf "■ Playlist description: %s" (if String.IsNullOrWhiteSpace collectionData.Description then String.Empty else collectionData.Description)) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore | None -> () sb.ToString() diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index d316b548..d3cf8088 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -74,7 +74,7 @@ type Printer(showDebug: bool) = if int logLevel > int minimumLogLevel then () else - if String.IsNullOrWhiteSpace message then + if hasNoText message then raise (ArgumentNullException("message", "Message cannot be empty.")) Printer.EmptyLines prependLines @@ -105,7 +105,7 @@ type Printer(showDebug: bool) = member this.Errors(errors: string seq, ?appendLines: byte) = if Seq.isEmpty errors then raise (ArgumentException("No errors were provided!", "errors")) - for err in errors |> Seq.filter hasNonWhitespaceText do + for err in errors |> Seq.filter hasText do this.Error err Printer.EmptyLines(defaultArg appendLines 0uy) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index aef1a4b2..1dc62727 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -86,7 +86,7 @@ module Settings = let printSummary settings (printer: Printer) headerOpt : unit = match headerOpt with - | Some h when hasNonWhitespaceText h -> printer.Info h + | Some h when hasText h -> printer.Info h | _ -> () let table = Table() @@ -106,7 +106,6 @@ module Settings = open System.IO let validate settings = - let isEmpty str = String.IsNullOrWhiteSpace str let dirMissing str = not (Directory.Exists str) // Source: https://github.com/yt-dlp/yt-dlp/?tab=readme-ov-file#post-processing-options @@ -116,11 +115,11 @@ module Settings = let validAudioFormat fmt = supportedAudioFormats |> List.contains fmt match settings with - | { WorkingDirectory = dir } when isEmpty dir -> + | { WorkingDirectory = dir } when hasNoText dir -> Error "No working directory was specified." | { WorkingDirectory = dir } when dirMissing dir -> Error $"Working directory \"{dir}\" is missing." - | { MoveToDirectory = dir } when isEmpty dir -> + | { MoveToDirectory = dir } when hasNoText dir -> Error "No move-to directory was specified." | { MoveToDirectory = dir } when dirMissing dir -> Error $"Move-to directory \"{dir}\" is missing." diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index e7f017b7..e37d6c33 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -11,14 +11,11 @@ module Utilities = let newLine = Environment.NewLine - /// Determines whether a string contains any text. - /// allowWhiteSpace = true allows whitespace to count as text. - let hasText text whiteSpaceCounts = - let f = if whiteSpaceCounts then String.IsNullOrEmpty else String.IsNullOrWhiteSpace - not (f text) + let hasNoText text = + String.IsNullOrWhiteSpace text - let hasNonWhitespaceText text = - hasText text false + let hasText text = + not (hasNoText text) let caseInsensitiveContains (xs: string seq) text : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) @@ -56,12 +53,11 @@ module Utilities = invalidChars |> _.ToString() - let trimTerminalLineBreak text : string = - if hasNonWhitespaceText text then - text.TrimEnd(newLine.ToCharArray()) - else - text + let trimTerminalLineBreak (text: string) = + text.TrimEnd(newLine.ToCharArray()) module List = let isNotEmpty l = not (List.isEmpty l) +module Array = + let doesNotContain x arr = Array.contains x arr |> not From c57e3a8cb1ea24713cf8ef67b57ebd5aa1e83159 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:16:40 +0900 Subject: [PATCH 048/247] Reorder parameters for caseInsensitiveContains --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- .../PostProcessing/PostProcessing.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- .../PostProcessing/Tagging/Tagger.fs | 58 +++++++++---------- .../PostProcessing/Tagging/TaggingSet.fs | 4 +- src/CCVTAC.FSharp/Program.fs | 4 +- src/CCVTAC.FSharp/Utilities.fs | 7 ++- 8 files changed, 39 insertions(+), 42 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 8ee71068..5efa2856 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -14,7 +14,7 @@ module Directories = /// Counts the number of audio files in a directory let internal audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() - |> Seq.filter (fun f -> caseInsensitiveContains AudioExtensions f.Extension) + |> Seq.filter (fun f -> caseInsensitiveContains f.Extension AudioExtensions) |> Seq.length /// Returns the filenames in a given directory, optionally ignoring specific filenames diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 4d83bc7c..0c5c5fa4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -152,7 +152,7 @@ module Mover = | Ok () -> let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> caseInsensitiveContains AudioExtensions f.Extension) + |> Seq.filter (fun f -> caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index a491a7e7..d0adb673 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -81,7 +81,7 @@ module PostProcessor = then ImageProcessor.run workingDirectory printer else () - match Tagger.Run settings taggingSets collectionJsonOpt mediaType printer with + match Tagger.run settings taggingSets collectionJsonOpt mediaType printer with | Ok msg -> printer.Info msg Renamer.Run settings workingDirectory printer diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 146507a2..fb8095bb 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -24,7 +24,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> caseInsensitiveContains AudioExtensions f.Extension) + |> Seq.filter (fun f -> caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq if audioFiles.Length = 0 then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 6710bae6..07ab622c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -20,7 +20,7 @@ module Tagger = elif ts.TotalMinutes >= 1.0 then sprintf "%d:%02d" ts.Minutes ts.Seconds else sprintf "%A ms" ts.TotalMilliseconds // TODO: %d didn't work - let private ParseVideoJson (taggingSet: TaggingSet) : Result = + let private parseVideoJson (taggingSet: TaggingSet) : Result = try let json = File.ReadAllText taggingSet.JsonFilePath try @@ -68,25 +68,18 @@ module Tagger = /// If the supplied video uploader is specified in the settings, returns the video's upload year. /// Otherwise, returns null (Nullable). - let GetAppropriateReleaseDateIfAny (settings: UserSettings) (videoData: VideoMetadata) : uint32 option = - // If an ignore list exists and contains the uploader (case-insensitive), return null - if Seq.exists - (fun u -> String.Equals(u, videoData.Uploader, StringComparison.OrdinalIgnoreCase)) - settings.IgnoreUploadYearUploaders - then - None + let releaseYear (settings: UserSettings) (videoData: VideoMetadata) : uint32 option = + if settings.IgnoreUploadYearUploaders |> caseInsensitiveContains videoData.Uploader + then None + else if videoData.UploadDate.Length < 4 + then None else - // Try to parse the first 4 characters of UploadDate as a year - if videoData.UploadDate.Length < 4 - then - None - else - let yearStr = videoData.UploadDate.Substring(0, 4) - match UInt32.TryParse yearStr with - | true, parsed -> Some parsed - | _ -> None + let yearStr = videoData.UploadDate.Substring(0, 4) + match UInt32.TryParse yearStr with + | true, parsed -> Some parsed + | _ -> None - let private TagSingleFile + let private tagSingleFile (settings: UserSettings) (videoData: VideoMetadata) (audioFilePath: string) @@ -95,10 +88,10 @@ module Tagger = (printer: Printer) = let audioFileName = Path.GetFileName audioFilePath - printer.Debug (sprintf "Current audio file: \"%s\"" audioFileName) + printer.Debug $"Current audio file: \"%s{audioFileName}\"" use taggedFile = TaggedFile.Create audioFilePath - let tagDetector = TagDetector(settings.TagDetectionPatterns) + let tagDetector = TagDetector settings.TagDetectionPatterns // Title // match videoData.Track with @@ -180,7 +173,7 @@ module Tagger = // match UInt16.TryParse prefix with // | true, parsed -> Some parsed // | _ -> None - GetAppropriateReleaseDateIfAny settings videoData + releaseYear settings videoData match tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) with | None -> () @@ -195,18 +188,19 @@ module Tagger = match imageFilePath with | Some path -> if settings.EmbedImages && - Array.doesNotContain videoData.Uploader settings.DoNotEmbedImageUploaders + settings.DoNotEmbedImageUploaders |> Array.doesNotContain videoData.Uploader then printer.Info "Embedding artwork." WriteImage taggedFile path printer else printer.Debug "Skipping artwork embedding." - | None -> printer.Debug "Skipping artwork embedding." + | None -> + printer.Debug "Skipping artwork embedding." taggedFile.Save() printer.Debug $"Wrote tags to \"%s{audioFileName}\"." - let private ProcessSingleTaggingSet + let private processTaggingSet (settings: UserSettings) (taggingSet: TaggingSet) (collectionJson: CollectionMetadata option) @@ -217,25 +211,25 @@ module Tagger = printer.Debug $"%d{taggingSet.AudioFilePaths.Length} audio file(s) with resource ID \"%s{taggingSet.ResourceId}\"" - match ParseVideoJson taggingSet with - | Error err -> - printer.Error $"Error deserializing video metadata from \"%s{taggingSet.JsonFilePath}\": {err}" + match parseVideoJson taggingSet with | Ok videoData -> let finalTaggingSet = DeleteSourceFile taggingSet printer let imagePath = - if embedImages && finalTaggingSet.AudioFilePaths.Length = 1 then + if embedImages && List.isNotEmpty finalTaggingSet.AudioFilePaths then Some finalTaggingSet.ImageFilePath else None for audioPath in finalTaggingSet.AudioFilePaths do try - TagSingleFile settings videoData audioPath imagePath collectionJson printer + tagSingleFile settings videoData audioPath imagePath collectionJson printer with ex -> - printer.Error (sprintf "Error tagging file: %s" ex.Message) + printer.Error $"Error tagging file: %s{ex.Message}" + | Error err -> + printer.Error $"Error deserializing video metadata from \"%s{taggingSet.JsonFilePath}\": {err}" - let Run + let run (settings: UserSettings) (taggingSets: seq) (collectionJson: CollectionMetadata option) @@ -247,6 +241,6 @@ module Tagger = let embedImages = settings.EmbedImages && (mediaType.IsVideo || mediaType.IsPlaylistVideo) for taggingSet in taggingSets do - ProcessSingleTaggingSet settings taggingSet collectionJson embedImages printer + processTaggingSet settings taggingSet collectionJson embedImages printer Ok (sprintf "Tagging done in %s." (watchFriendly watch)) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index d1f97262..a2d2dd7c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -66,7 +66,7 @@ type TaggingSet = filesSeq |> Seq.exists (fun f -> let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. - caseInsensitiveContains AudioExtensions f') + caseInsensitiveContains f' AudioExtensions) let jsonCount = filesSeq |> Seq.filter (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length let imageCount = @@ -78,7 +78,7 @@ type TaggingSet = filesArr |> Seq.filter (fun f -> let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. - caseInsensitiveContains AudioExtensions f') + caseInsensitiveContains f' AudioExtensions) |> Seq.toList let jsonFile = filesArr |> Seq.find (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) let imageFile = filesArr |> Seq.find (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 4d3d79d3..359e976c 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -22,12 +22,12 @@ module Program = let main (args: string array) : int = let printer = Printer(showDebug = true) - if args.Length > 0 && caseInsensitiveContains helpFlags args[0] then + if args.Length > 0 && caseInsensitiveContains args[0] helpFlags then Help.Print printer int ExitCodes.Success else let maybeSettingsPath = - if args.Length >= 2 && caseInsensitiveContains settingsFileFlags args[0] then + if args.Length >= 2 && caseInsensitiveContains args[0] settingsFileFlags then args[1] // Expected to be a settings file path else defaultSettingsFileName diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index e37d6c33..01ce2509 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -17,7 +17,10 @@ module Utilities = let hasText text = not (hasNoText text) - let caseInsensitiveContains (xs: string seq) text : bool = + let equalIgnoringCase x y = + String.Equals(x, y, StringComparison.OrdinalIgnoreCase) + + let caseInsensitiveContains text (xs: string seq) : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) /// Returns a new string in which all invalid path characters for the current OS @@ -44,7 +47,7 @@ module Utilities = } |> Set.ofSeq - if invalidChars.Contains replaceWith then + if invalidChars |> Set.contains replaceWith then invalidArg "replaceWith" $"The replacement char ('%c{replaceWith}') must be a valid path character." Set.fold From e723cab92a7b47d8db48df56c6d52e3569ae3ce6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:22:03 +0900 Subject: [PATCH 049/247] Use Startwatch --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 07ab622c..5df1b81a 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -3,11 +3,11 @@ namespace CCVTAC.Console.PostProcessing.Tagging open System open System.IO open System.Text.Json -// open System.Linq open CCVTAC.Console open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing open CCVTAC.Console.Downloading.Downloading +open Startwatch.Library type TaggedFile = TagLib.File @@ -236,11 +236,13 @@ module Tagger = (mediaType: MediaType) (printer: Printer) : Result = + printer.Debug "Adding file tags..." - let watch = System.Diagnostics.Stopwatch.StartNew() + let watch = Watch() + let embedImages = settings.EmbedImages && (mediaType.IsVideo || mediaType.IsPlaylistVideo) for taggingSet in taggingSets do processTaggingSet settings taggingSet collectionJson embedImages printer - Ok (sprintf "Tagging done in %s." (watchFriendly watch)) + Ok $"Tagging done in %s{watch.ElapsedFriendly}." From e32d29c204780c2e4ec03c1c3a84ea0cc3567108 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:22:33 +0900 Subject: [PATCH 050/247] Use string interpolation --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 5df1b81a..93a57381 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -145,14 +145,14 @@ module Tagger = match tagDetector.DetectComposers videoData with | None -> () | Some composers -> - printer.Debug (sprintf "• Found composer(s) \"%s\"" composers) + printer.Debug $"• Found composer(s) \"%s{composers}\"" taggedFile.Tag.Composers <- [| composers |] // Track number match videoData.PlaylistIndex with | NullV -> () | NonNullV (trackNo: uint32) -> - printer.Debug (sprintf "• Using playlist index of %d for track number" trackNo) + printer.Debug $"• Using playlist index of %d{trackNo} for track number" taggedFile.Tag.Track <- uint32 trackNo // Year From 50ad69c6405b7453eb6629d68d2c2143cfe09585 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:23:23 +0900 Subject: [PATCH 051/247] Remove commented-out code --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 93a57381..3b23e2ab 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -161,19 +161,7 @@ module Tagger = printer.Debug $"• Using metadata release year \"%d{year}\"" taggedFile.Tag.Year <- year | NullV -> - let maybeDefaultYear = - // let rec GetAppropriateReleaseDateIfAny (settings: UserSettings) (videoData: VideoMetadata) = - // if settings.IgnoreUploadYearUploaders.Contains(videoData.Uploader, StringComparer.OrdinalIgnoreCase) - // then - // None - // else - // if String.IsNullOrEmpty videoData.UploadDate then None - // else - // let prefix = if videoData.UploadDate.Length >= 4 then videoData.UploadDate.Substring(0,4) else "" - // match UInt16.TryParse prefix with - // | true, parsed -> Some parsed - // | _ -> None - releaseYear settings videoData + let maybeDefaultYear = releaseYear settings videoData match tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) with | None -> () From f10ffd59628fc9eb833cea0a7d70e8da7e84f41b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:38:38 +0900 Subject: [PATCH 052/247] Delete unused using --- src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs index e2c78aaf..ef26c5c9 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -1,7 +1,6 @@ namespace CCVTAC.Console.PostProcessing.Tagging open System -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing From 792919e1e060e9b5463126ef7f7a8c0a6b7410fd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:40:53 +0900 Subject: [PATCH 053/247] Minor TaggingSet cleanup --- .../PostProcessing/Tagging/TaggingSet.fs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index a2d2dd7c..c1c6bd6b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -1,11 +1,11 @@ namespace CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.Console open System open System.IO open System.Text.RegularExpressions open System.Collections.Generic open System.Collections.Immutable -open CCVTAC.Console /// Contains all the data necessary for tagging a related set of files. [] @@ -14,13 +14,10 @@ type TaggingSet = AudioFilePaths: string list JsonFilePath: string ImageFilePath: string } - /// Expose all related files as a read-only list - member this.AllFiles : IReadOnlyList = - // combine the immutable hash set with json and image paths preserving as a list - let audio = this.AudioFilePaths |> Seq.toList - List.concat [ audio; [ this.JsonFilePath; this.ImageFilePath ] ] :> IReadOnlyList - // Private constructor helper to perform validation (not directly callable from outside) + member this.AllFiles : string list = + List.concat [this.AudioFilePaths; [this.JsonFilePath; this.ImageFilePath]] + static member private CreateValidated(resourceId: string, audioFilePaths: ICollection, jsonFilePath: string, imageFilePath: string) = if hasNoText resourceId then invalidArg "resourceId" "The resource ID must be provided." @@ -42,7 +39,6 @@ type TaggingSet = /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. /// Files that don't match the requirements will be ignored. - // static member CreateSets (filePaths: ICollection) : TaggingSet list = static member CreateSets (filePaths: ICollection) : TaggingSet list = if Seq.isEmpty filePaths then [] From f27f11750a0751c735423aa61603622a125b573d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:45:15 +0900 Subject: [PATCH 054/247] Add and use endsWithIgnoringCase func --- .../PostProcessing/Tagging/TaggingSet.fs | 15 ++++++--------- src/CCVTAC.FSharp/Utilities.fs | 3 +++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index c1c6bd6b..4a496680 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -56,17 +56,14 @@ type TaggingSet = |> Seq.map (fun m -> m.Captures |> Seq.cast |> Seq.head) |> Seq.groupBy _.Groups[1].Value |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) - |> Seq.filter (fun (_, files) -> - let filesSeq = files |> Seq.toArray + |> Seq.filter (fun (_, fileNames) -> let isSupportedExtension = - filesSeq + fileNames |> Seq.exists (fun f -> let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. caseInsensitiveContains f' AudioExtensions) - let jsonCount = - filesSeq |> Seq.filter (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length - let imageCount = - filesSeq |> Seq.filter (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) |> Seq.length + let jsonCount = fileNames |> Seq.filter (endsWithIgnoringCase jsonFileExt) |> Seq.length + let imageCount = fileNames |> Seq.filter (endsWithIgnoringCase imageFileExt) |> Seq.length isSupportedExtension && jsonCount = 1 && imageCount = 1) |> Seq.map (fun (key, files) -> let filesArr = files |> Seq.toArray @@ -76,8 +73,8 @@ type TaggingSet = let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. caseInsensitiveContains f' AudioExtensions) |> Seq.toList - let jsonFile = filesArr |> Seq.find (fun f -> f.EndsWith(jsonFileExt, StringComparison.OrdinalIgnoreCase)) - let imageFile = filesArr |> Seq.find (fun f -> f.EndsWith(imageFileExt, StringComparison.OrdinalIgnoreCase)) + let jsonFile = filesArr |> Seq.find (endsWithIgnoringCase jsonFileExt) + let imageFile = filesArr |> Seq.find (endsWithIgnoringCase imageFileExt) { ResourceId = key AudioFilePaths = audioFiles diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 01ce2509..d6d46f03 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -20,6 +20,9 @@ module Utilities = let equalIgnoringCase x y = String.Equals(x, y, StringComparison.OrdinalIgnoreCase) + let endsWithIgnoringCase endingText (text: string) = + text.EndsWith(endingText, StringComparison.InvariantCultureIgnoreCase) + let caseInsensitiveContains text (xs: string seq) : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) From 54b98a70a84b60eb04d7ad6144bb66f45e0f4e74 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:55:37 +0900 Subject: [PATCH 055/247] Divide utility functions into modules --- src/CCVTAC.FSharp/Utilities.fs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index d6d46f03..90a1ce27 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -7,7 +7,7 @@ open System.Text type SB = StringBuilder [] -module Utilities = +module String = let newLine = Environment.NewLine @@ -23,9 +23,6 @@ module Utilities = let endsWithIgnoringCase endingText (text: string) = text.EndsWith(endingText, StringComparison.InvariantCultureIgnoreCase) - let caseInsensitiveContains text (xs: string seq) : bool = - xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) - /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. /// Throws if the replacement character is an invalid path character. @@ -62,8 +59,15 @@ module Utilities = let trimTerminalLineBreak (text: string) = text.TrimEnd(newLine.ToCharArray()) +[] +module Seq = + let caseInsensitiveContains text (xs: string seq) : bool = + xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) + +[] module List = let isNotEmpty l = not (List.isEmpty l) +[] module Array = let doesNotContain x arr = Array.contains x arr |> not From ba13a731ea4f9435e5037780e3ed43cb974f8b80 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:55:55 +0900 Subject: [PATCH 056/247] Update and remove usings --- src/CCVTAC.FSharp/Orchestrator.fs | 11 +++-------- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 11 ++++------- src/CCVTAC.FSharp/Printer.fs | 1 - 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 96d5e961..a3d16ffc 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -1,21 +1,16 @@ namespace CCVTAC.Console -open System -open System.Threading open CCVTAC.Console.Downloading -open CCVTAC.Console.Downloading.Downloading +open CCVTAC.Console.InputHelper open CCVTAC.Console.IoUtilities open CCVTAC.Console.PostProcessing open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.Settings.TagFormat -open CCVTAC.Console.Settings.Settings.Validation -open CCVTAC.Console.Settings.Settings.IO open CCVTAC.Console.Settings.Settings.LiveUpdating open Spectre.Console -open CCVTAC.Console.InputHelper -open Utilities open Startwatch.Library +open System +open System.Threading module Orchestrator = type NextAction = diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 0c5c5fa4..0f2bba7f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -1,18 +1,15 @@ namespace CCVTAC.Console.PostProcessing +open CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console +open CCVTAC.Console.PostProcessing open System open System.IO open System.Linq open System.Text.Json open System.Text.RegularExpressions -open CCVTAC.Console.Utilities -open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.Console.Settings -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console open Startwatch.Library -open Utilities -open CCVTAC.Console.PostProcessing module Mover = diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index d3cf8088..ab2f7c88 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -4,7 +4,6 @@ open System open System.Collections.Generic open System.Linq open Spectre.Console -open Utilities type private Level = | Critical = 0 From c5b5c9ee91cc815d3460b3b719bf58eb5640c97d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:00:57 +0900 Subject: [PATCH 057/247] String interpolation --- src/CCVTAC.FSharp/Orchestrator.fs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index a3d16ffc..380baf1c 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -32,13 +32,13 @@ module Orchestrator = let urlSummary = match urlCount with | 1 -> "1 URL" - | n when n > 1 -> sprintf "%d URLs" n + | n when n > 1 -> $"%d{n} URLs" | _ -> String.Empty let commandSummary = match cmdCount with | 1 -> "1 command" - | n when n > 1 -> sprintf "%d commands" n + | n when n > 1 -> $"%d{n} commands" | _ -> String.Empty let connector = @@ -47,7 +47,7 @@ module Orchestrator = printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." for input in categorizedInputs do - printer.Info(sprintf " • %s" input.Text) + printer.Info $" • %s{input.Text}" Printer.EmptyLines 1uy From ab252934aeaf16d576d51f2dad76d5fdc65912b8 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:05:33 +0900 Subject: [PATCH 058/247] Add and use String.allHaveText func --- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/Utilities.fs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 380baf1c..26109605 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -42,7 +42,7 @@ module Orchestrator = | _ -> String.Empty let connector = - if hasText urlSummary && hasText commandSummary then " and " else String.Empty + if allHaveText [urlSummary; commandSummary] then " and " else String.Empty printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 90a1ce27..5e77b405 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -17,6 +17,9 @@ module String = let hasText text = not (hasNoText text) + let allHaveText xs = + xs |> List.forall hasText + let equalIgnoringCase x y = String.Equals(x, y, StringComparison.OrdinalIgnoreCase) From 6ee4ac160636a6d704fef0f1067bc7fe79eca926 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:12:12 +0900 Subject: [PATCH 059/247] Reorg and use utility funcs --- src/CCVTAC.FSharp/Orchestrator.fs | 35 +++++++++++++------------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 26109605..580145ba 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -88,7 +88,7 @@ module Orchestrator = printer.Info($"Slept for %d{settings.SleepSecondsBetweenURLs} second(s).", appendLines = 1uy) if batchSize > 1 then - printer.Info(sprintf "Processing group %d of %d..." urlIndex batchSize) + printer.Info $"Processing group %d{urlIndex} of %d{batchSize}..." let jobWatch = Watch() @@ -98,7 +98,7 @@ module Orchestrator = printer.Error errorMsg Error errorMsg | Ok mediaType -> - printer.Info(sprintf "%s URL '%s' detected." (mediaType.GetType().Name) url) + printer.Info $"%s{mediaType.GetType().Name} URL '%s{url}' detected." history.Append(url, urlInputTime, printer) let downloadResult = Downloader.run mediaType settings printer @@ -121,11 +121,6 @@ module Orchestrator = printer.Info $"Processed '%s{url}'%s{groupClause} in %s{jobWatch.ElapsedFriendly}." Ok NextAction.Continue - let equalsIgnoreCase (a: string) (b: string) = - String.Equals(a, b, StringComparison.InvariantCultureIgnoreCase) - - let seqContainsIgnoreCase (seq: seq) (value: string) = - seq |> Seq.exists (fun s -> equalsIgnoreCase s value) let startsWithIgnoreCase (text: string) (prefix: string) = text.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) @@ -144,45 +139,45 @@ module Orchestrator = : Result = // Help - if equalsIgnoreCase Commands.HelpCommand command then + if equalIgnoringCase Commands.HelpCommand command then for kvp in Commands.Summary do printer.Info(kvp.Key) - printer.Info(sprintf " %s" kvp.Value) + printer.Info $" %s{kvp.Value}" Ok NextAction.Continue // Quit - elif seqContainsIgnoreCase Commands.QuitCommands command then + elif caseInsensitiveContains command Commands.QuitCommands then Ok NextAction.QuitAtUserRequest // History - elif seqContainsIgnoreCase Commands.History command then + elif caseInsensitiveContains command Commands.History then history.ShowRecent printer Ok NextAction.Continue // Update downloader - elif seqContainsIgnoreCase Commands.UpdateDownloader command then + elif caseInsensitiveContains command Commands.UpdateDownloader then Updater.run settings printer |> ignore Ok NextAction.Continue // Settings summary - elif seqContainsIgnoreCase Commands.SettingsSummary command then + elif caseInsensitiveContains command Commands.SettingsSummary then Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters - elif seqContainsIgnoreCase Commands.SplitChapterToggles command then + elif caseInsensitiveContains command Commands.SplitChapterToggles then settings <- toggleSplitChapters(settings) printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) Ok NextAction.Continue // Toggle embed images - elif seqContainsIgnoreCase Commands.EmbedImagesToggles command then + elif caseInsensitiveContains command Commands.EmbedImagesToggles then settings <- toggleEmbedImages(settings) printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) Ok NextAction.Continue // Toggle quiet mode - elif seqContainsIgnoreCase Commands.QuietModeToggles command then + elif caseInsensitiveContains command Commands.QuietModeToggles then settings <- toggleQuietMode(settings) printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) printer.ShowDebug(not settings.QuietMode) @@ -228,7 +223,7 @@ module Orchestrator = // printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) // Ok NextAction.Continue | _ -> - Error (sprintf "\"%s\" is an invalid quality value." inputQuality) + Error $"\"%s{inputQuality}\" is an invalid quality value." // Unknown command else @@ -245,15 +240,15 @@ module Orchestrator = (history: History) (printer: Printer) : NextAction = + let inputTime = DateTime.Now - let mutable nextAction = NextAction.Continue let watch = Watch() let batchResults = ResultTracker(printer) + let mutable nextAction = NextAction.Continue let mutable inputIndex = 0 for input in categorizedInputs do let mutable stop = false - // increment input index before passing to ProcessUrl to mirror ++inputIndex inputIndex <- inputIndex + 1 let result = @@ -274,7 +269,7 @@ module Orchestrator = if categoryCounts[InputCategory.Url] > 1 then printer.Info(sprintf "%sFinished with batch of %d URLs in %s." - Environment.NewLine + newLine categoryCounts[InputCategory.Url] watch.ElapsedFriendly) batchResults.PrintBatchFailures() From 555eebc2fefe1f883da88ca27bfa86eff8e45836 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:16:35 +0900 Subject: [PATCH 060/247] Lowercase initial letters for let bindings --- src/CCVTAC.FSharp/Commands.fs | 56 ++++++++++++++++--------------- src/CCVTAC.FSharp/InputHelper.fs | 8 ++--- src/CCVTAC.FSharp/Orchestrator.fs | 38 +++++++++++---------- 3 files changed, 54 insertions(+), 48 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index 492418a0..43bf0654 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -5,48 +5,50 @@ open System.Collections.Generic module internal Commands = - let Prefix : char = '\\' + let prefix: char = '\\' - let private MakeCommand (text: string) : string = + let private makeCommand (text: string) : string = if hasNoText text then raise (ArgumentException("The text cannot be null or white space.", "text")) if text.Contains ' ' then raise (ArgumentException("The text should not contain any white space.", "text")) - $"%c{Prefix}%s{text}" + $"%c{prefix}%s{text}" - let QuitCommands : string[] = - [| MakeCommand "quit"; MakeCommand "q"; MakeCommand "exit" |] + let quitCommands: string[] = + [| makeCommand "quit"; makeCommand "q"; makeCommand "exit" |] - let HelpCommand : string = MakeCommand "help" + let helpCommand: string = makeCommand "help" - let SettingsSummary : string[] = [| MakeCommand "settings" |] + let settingsSummary: string[] = [| makeCommand "settings" |] - let History : string[] = [| MakeCommand "history" |] + let history: string[] = [| makeCommand "history" |] - let UpdateDownloader : string[] = - [| MakeCommand "update-downloader"; MakeCommand "update-dl" |] + let updateDownloader: string[] = + [| makeCommand "update-downloader"; makeCommand "update-dl" |] - let SplitChapterToggles : string[] = [| MakeCommand "split"; MakeCommand "toggle-split" |] + let splitChapterToggles: string[] = [| makeCommand "split"; makeCommand "toggle-split" |] - let EmbedImagesToggles : string[] = [| MakeCommand "images"; MakeCommand "toggle-images" |] + let embedImagesToggles: string[] = [| makeCommand "images"; makeCommand "toggle-images" |] - let QuietModeToggles : string[] = [| MakeCommand "quiet"; MakeCommand "toggle-quiet" |] + let quietModeToggles: string[] = [| makeCommand "quiet"; makeCommand "toggle-quiet" |] - let UpdateAudioFormatPrefix : string = MakeCommand "format-" + let updateAudioFormatPrefix: string = makeCommand "format-" - let UpdateAudioQualityPrefix : string = MakeCommand "quality-" + let updateAudioQualityPrefix: string = makeCommand "quality-" - let Summary : Dictionary = + let summary: Dictionary = let d = Dictionary() - d.Add(String.Join(" or ", History), "See the most recently entered URLs") - d.Add(String.Join(" or ", SplitChapterToggles), "Toggles chapter splitting for the current session only") - d.Add(String.Join(" or ", EmbedImagesToggles), "Toggles image embedding for the current session only") - d.Add(String.Join(" or ", QuietModeToggles), "Toggles quiet mode for the current session only") - d.Add(String.Join(" or ", UpdateDownloader), "Updates the downloader using the command specified in the settings") - d.Add(UpdateAudioFormatPrefix, - sprintf "Followed by a supported audio format (e.g., %sm4a), changes the audio format for the current session only" UpdateAudioFormatPrefix) - d.Add(UpdateAudioQualityPrefix, - sprintf "Followed by a supported audio quality (e.g., %s0), changes the audio quality for the current session only" UpdateAudioQualityPrefix) - d.Add(String.Join(" or ", QuitCommands), "Quit the application") - d.Add(HelpCommand, "See this help message") + d.Add(String.Join(" or ", history), "See the most recently entered URLs") + d.Add(String.Join(" or ", splitChapterToggles), "Toggles chapter splitting for the current session only") + d.Add(String.Join(" or ", embedImagesToggles), "Toggles image embedding for the current session only") + d.Add(String.Join(" or ", quietModeToggles), "Toggles quiet mode for the current session only") + d.Add(String.Join(" or ", updateDownloader), "Updates the downloader using the command specified in the settings") + d.Add(updateAudioFormatPrefix, + sprintf "Followed by a supported audio format (e.g., %sm4a), changes the audio format for the current session only" + updateAudioFormatPrefix) + d.Add(updateAudioQualityPrefix, + sprintf "Followed by a supported audio quality (e.g., %s0), changes the audio quality for the current session only" + updateAudioQualityPrefix) + d.Add(String.Join(" or ", quitCommands), "Quit the application") + d.Add(helpCommand, "See this help message") d diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index a9b22758..fd9565ee 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -9,7 +9,7 @@ open System.Collections.Immutable module InputHelper = let internal Prompt = - sprintf "Enter one or more YouTube media URLs or commands (or \"%s\"):\n▶︎" Commands.HelpCommand + sprintf "Enter one or more YouTube media URLs or commands (or \"%s\"):\n▶︎" Commands.helpCommand /// A regular expression that detects where commands and URLs begin in input strings. let private userInputRegex = Regex(@"(?:https:|\\)", RegexOptions.Compiled) @@ -18,7 +18,7 @@ module InputHelper = /// Takes a user input string and splits it into a collection of inputs /// based upon substrings detected by the class's regular expression pattern. - let SplitInput (input: string) : ImmutableArray = + let splitInput (input: string) : ImmutableArray = let matches = userInputRegex.Matches(input) |> Seq.cast |> Seq.toArray if matches.Length = 0 then @@ -48,12 +48,12 @@ module InputHelper = type CategorizedInput = { Text: string; Category: InputCategory } - let CategorizeInputs (inputs: ICollection) : CategorizedInput list = + let categorizeInputs (inputs: ICollection) : CategorizedInput list = inputs |> Seq.cast |> Seq.map (fun input -> let category = - if input.StartsWith(string Commands.Prefix) + if input.StartsWith(string Commands.prefix) then InputCategory.Command else InputCategory.Url { Text = input; Category = category }) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 580145ba..f253b566 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -139,53 +139,53 @@ module Orchestrator = : Result = // Help - if equalIgnoringCase Commands.HelpCommand command then - for kvp in Commands.Summary do + if equalIgnoringCase Commands.helpCommand command then + for kvp in Commands.summary do printer.Info(kvp.Key) printer.Info $" %s{kvp.Value}" Ok NextAction.Continue // Quit - elif caseInsensitiveContains command Commands.QuitCommands then + elif caseInsensitiveContains command Commands.quitCommands then Ok NextAction.QuitAtUserRequest // History - elif caseInsensitiveContains command Commands.History then + elif caseInsensitiveContains command Commands.history then history.ShowRecent printer Ok NextAction.Continue // Update downloader - elif caseInsensitiveContains command Commands.UpdateDownloader then + elif caseInsensitiveContains command Commands.updateDownloader then Updater.run settings printer |> ignore Ok NextAction.Continue // Settings summary - elif caseInsensitiveContains command Commands.SettingsSummary then + elif caseInsensitiveContains command Commands.settingsSummary then Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters - elif caseInsensitiveContains command Commands.SplitChapterToggles then + elif caseInsensitiveContains command Commands.splitChapterToggles then settings <- toggleSplitChapters(settings) printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) Ok NextAction.Continue // Toggle embed images - elif caseInsensitiveContains command Commands.EmbedImagesToggles then + elif caseInsensitiveContains command Commands.embedImagesToggles then settings <- toggleEmbedImages(settings) printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) Ok NextAction.Continue // Toggle quiet mode - elif caseInsensitiveContains command Commands.QuietModeToggles then + elif caseInsensitiveContains command Commands.quietModeToggles then settings <- toggleQuietMode(settings) printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) printer.ShowDebug(not settings.QuietMode) Ok NextAction.Continue // Update audio formats prefix - elif startsWithIgnoreCase command Commands.UpdateAudioFormatPrefix then - let format = command.Replace(Commands.UpdateAudioFormatPrefix, "").ToLowerInvariant() + elif startsWithIgnoreCase command Commands.updateAudioFormatPrefix then + let format = command.Replace(Commands.updateAudioFormatPrefix, "").ToLowerInvariant() if String.IsNullOrEmpty format then Error "You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." else @@ -203,8 +203,8 @@ module Orchestrator = // Ok NextAction.Continue // Update audio quality prefix - elif startsWithIgnoreCase command Commands.UpdateAudioQualityPrefix then - let inputQuality = command.Replace(Commands.UpdateAudioQualityPrefix, "") + elif startsWithIgnoreCase command Commands.updateAudioQualityPrefix then + let inputQuality = command.Replace(Commands.updateAudioQualityPrefix, "") if String.IsNullOrEmpty inputQuality then Error "You must enter a number representing an audio quality." else @@ -227,7 +227,9 @@ module Orchestrator = // Unknown command else - Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." command (string Commands.Prefix)) + Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." command (string Commands. + prefix + )) /// Processes a single user request, from input to downloading and file post-processing. @@ -298,12 +300,14 @@ module Orchestrator = while nextAction = NextAction.Continue do let input = printer.GetInput InputHelper.Prompt - let splitInputs = InputHelper.SplitInput input + let splitInputs = InputHelper.splitInput input if splitInputs.IsEmpty then - printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands.Prefix) + printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands. + prefix + ) else - let categorizedInputs = InputHelper.CategorizeInputs splitInputs + let categorizedInputs = InputHelper.categorizeInputs splitInputs let categoryCounts = InputHelper.CountCategories categorizedInputs summarizeInput categorizedInputs categoryCounts printer From fb1eda3ea6a708228a21e5ade099ca33328d5351 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:13:01 +0900 Subject: [PATCH 061/247] Remove unused CreateValidated method --- .../PostProcessing/Tagging/TaggingSet.fs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 4a496680..d84e78ad 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -18,25 +18,6 @@ type TaggingSet = member this.AllFiles : string list = List.concat [this.AudioFilePaths; [this.JsonFilePath; this.ImageFilePath]] - static member private CreateValidated(resourceId: string, audioFilePaths: ICollection, jsonFilePath: string, imageFilePath: string) = - if hasNoText resourceId then - invalidArg "resourceId" "The resource ID must be provided." - if hasNoText jsonFilePath then - invalidArg "jsonFilePath" "The JSON file path must be provided." - if hasNoText imageFilePath then - invalidArg "imageFilePath" "The image file path must be provided." - if audioFilePaths.Count = 0 then - invalidArg "audioFilePaths" "At least one audio file path must be provided." - - let resourceIdTrimmed = resourceId.Trim() - let jsonTrimmed = jsonFilePath.Trim() - let imageTrimmed = imageFilePath.Trim() - let audioSet = ImmutableHashSet.CreateRange(StringComparer.OrdinalIgnoreCase, audioFilePaths) - { ResourceId = resourceIdTrimmed - AudioFilePaths = audioSet |> Seq.toList - JsonFilePath = jsonTrimmed - ImageFilePath = imageTrimmed } - /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. /// Files that don't match the requirements will be ignored. static member CreateSets (filePaths: ICollection) : TaggingSet list = From 2b12cf530b06dcf29d56c63622c4796669210ef0 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:15:06 +0900 Subject: [PATCH 062/247] Remove pointless cast --- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index d84e78ad..47ffd468 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -47,15 +47,14 @@ type TaggingSet = let imageCount = fileNames |> Seq.filter (endsWithIgnoringCase imageFileExt) |> Seq.length isSupportedExtension && jsonCount = 1 && imageCount = 1) |> Seq.map (fun (key, files) -> - let filesArr = files |> Seq.toArray let audioFiles = - filesArr + files |> Seq.filter (fun f -> let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. caseInsensitiveContains f' AudioExtensions) |> Seq.toList - let jsonFile = filesArr |> Seq.find (endsWithIgnoringCase jsonFileExt) - let imageFile = filesArr |> Seq.find (endsWithIgnoringCase imageFileExt) + let jsonFile = files |> Seq.find (endsWithIgnoringCase jsonFileExt) + let imageFile = files |> Seq.find (endsWithIgnoringCase imageFileExt) { ResourceId = key AudioFilePaths = audioFiles From 178ce889ec0306b83a85def32030fce666c19fcd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:37:03 +0900 Subject: [PATCH 063/247] Clean up createSets --- .../PostProcessing/PostProcessing.fs | 2 +- .../PostProcessing/Tagging/TaggingSet.fs | 26 +++++++------------ 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index d0adb673..7dd00d08 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -46,7 +46,7 @@ module PostProcessor = let private generateTaggingSets directoryName : Result = try let files = Directory.GetFiles directoryName - let taggingSets = TaggingSet.CreateSets files + let taggingSets = TaggingSet.createSets files if List.isEmpty taggingSets then Error $"No tagging sets were created using working directory \"%s{directoryName}\"." else Ok taggingSets diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 47ffd468..d9252268 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -1,11 +1,8 @@ namespace CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console -open System open System.IO open System.Text.RegularExpressions -open System.Collections.Generic -open System.Collections.Immutable /// Contains all the data necessary for tagging a related set of files. [] @@ -20,7 +17,7 @@ type TaggingSet = /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. /// Files that don't match the requirements will be ignored. - static member CreateSets (filePaths: ICollection) : TaggingSet list = + static member createSets (filePaths: string seq) : TaggingSet list = if Seq.isEmpty filePaths then [] else @@ -31,6 +28,11 @@ type TaggingSet = let fileNamesWithVideoIdsRegex = Regex(@".+\[([\w_\-]{11})\](?:.*)?\.(\w+)", RegexOptions.Compiled) + let fileHasSupportedExtension (f: string) = + match Path.GetExtension f with + | Null -> false + | NonNull (x: string) -> caseInsensitiveContains x AudioExtensions + filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match |> Seq.filter _.Success @@ -38,26 +40,16 @@ type TaggingSet = |> Seq.groupBy _.Groups[1].Value |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) |> Seq.filter (fun (_, fileNames) -> - let isSupportedExtension = - fileNames - |> Seq.exists (fun f -> - let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. - caseInsensitiveContains f' AudioExtensions) + let isSupportedExtension = fileNames |> Seq.exists fileHasSupportedExtension let jsonCount = fileNames |> Seq.filter (endsWithIgnoringCase jsonFileExt) |> Seq.length let imageCount = fileNames |> Seq.filter (endsWithIgnoringCase imageFileExt) |> Seq.length isSupportedExtension && jsonCount = 1 && imageCount = 1) |> Seq.map (fun (key, files) -> - let audioFiles = - files - |> Seq.filter (fun f -> - let f' = match Path.GetExtension (f: string) with Null -> "" | NonNull (x: string) -> x // TODO: Improve. - caseInsensitiveContains f' AudioExtensions) - |> Seq.toList + let audioFiles = files |> Seq.filter fileHasSupportedExtension let jsonFile = files |> Seq.find (endsWithIgnoringCase jsonFileExt) let imageFile = files |> Seq.find (endsWithIgnoringCase imageFileExt) - { ResourceId = key - AudioFilePaths = audioFiles + AudioFilePaths = audioFiles |> Seq.toList JsonFilePath = jsonFile ImageFilePath = imageFile }) |> List.ofSeq From 2b6503abe732615532cb4b9c83db8f8577d9d9fd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:52:01 +0900 Subject: [PATCH 064/247] Create TaggingSets module --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 1 + .../PostProcessing/PostProcessing.fs | 13 ++++-------- .../PostProcessing/Tagging/Tagger.fs | 1 + .../PostProcessing/Tagging/TaggingSet.fs | 20 ++++++++++--------- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 0f2bba7f..e73205c0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -10,6 +10,7 @@ open System.Linq open System.Text.Json open System.Text.RegularExpressions open Startwatch.Library +open TaggingSets module Mover = diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 7dd00d08..e7a098f6 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -6,10 +6,10 @@ open System.Text.Json open System.Text.RegularExpressions open CCVTAC.Console open CCVTAC.Console.IoUtilities -open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.Settings.Settings open Startwatch.Library +open TaggingSets module PostProcessor = @@ -45,16 +45,11 @@ module PostProcessor = let private generateTaggingSets directoryName : Result = try - let files = Directory.GetFiles directoryName - let taggingSets = TaggingSet.createSets files + let taggingSets = createSets <| Directory.GetFiles directoryName if List.isEmpty taggingSets then Error $"No tagging sets were created using working directory \"%s{directoryName}\"." else Ok taggingSets - with - | :? DirectoryNotFoundException -> - Error $"Directory \"%s{directoryName}\" does not exist." - | ex -> - Error $"Error reading working directory files: %s{ex.Message}" + with ex -> Error $"Error reading working files in \"{directoryName}\": %s{ex.Message}" let run settings mediaType (printer: Printer) : unit = let watch = Watch() @@ -89,7 +84,7 @@ module PostProcessor = let taggingSetFileNames = taggingSets - |> Seq.collect _.AllFiles + |> Seq.collect allFiles |> Seq.toList Deleter.run taggingSetFileNames collectionJsonOpt workingDirectory printer diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 3b23e2ab..87dc9a7f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -8,6 +8,7 @@ open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing open CCVTAC.Console.Downloading.Downloading open Startwatch.Library +open TaggingSets type TaggedFile = TagLib.File diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index d9252268..cec864f8 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -5,19 +5,21 @@ open System.IO open System.Text.RegularExpressions /// Contains all the data necessary for tagging a related set of files. -[] -type TaggingSet = - { ResourceId: string - AudioFilePaths: string list - JsonFilePath: string - ImageFilePath: string } +module TaggingSets = - member this.AllFiles : string list = - List.concat [this.AudioFilePaths; [this.JsonFilePath; this.ImageFilePath]] + [] + type TaggingSet = + { ResourceId: string + AudioFilePaths: string list + JsonFilePath: string + ImageFilePath: string } + + let allFiles taggingSet = + List.concat [taggingSet.AudioFilePaths; [taggingSet.JsonFilePath; taggingSet.ImageFilePath]] /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. /// Files that don't match the requirements will be ignored. - static member createSets (filePaths: string seq) : TaggingSet list = + let createSets (filePaths: string seq) : TaggingSet list = if Seq.isEmpty filePaths then [] else From 04879fb04622b475206809b23e9f2873232de47f Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:03:24 +0900 Subject: [PATCH 065/247] Postprocessing cleanup --- .../PostProcessing/PostProcessing.fs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index e7a098f6..5549c2d4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -61,10 +61,8 @@ module PostProcessor = | Error _ -> printer.Error $"No tagging sets were generated for directory {workingDirectory}, so tagging cannot be done." | Ok taggingSets -> - let collectionJsonResult = getCollectionJson workingDirectory - - let collectionJsonOpt = - match collectionJsonResult with + let collectionJson = + match getCollectionJson workingDirectory with | Error e -> printer.Debug $"No playlist or channel metadata found: %s{e}" None @@ -76,18 +74,14 @@ module PostProcessor = then ImageProcessor.run workingDirectory printer else () - match Tagger.run settings taggingSets collectionJsonOpt mediaType printer with + match Tagger.run settings taggingSets collectionJson mediaType printer with | Ok msg -> printer.Info msg Renamer.Run settings workingDirectory printer - Mover.run taggingSets collectionJsonOpt settings true printer - - let taggingSetFileNames = - taggingSets - |> Seq.collect allFiles - |> Seq.toList + Mover.run taggingSets collectionJson settings true printer - Deleter.run taggingSetFileNames collectionJsonOpt workingDirectory printer + let allTaggingSetFiles = taggingSets |> Seq.collect allFiles + Deleter.run allTaggingSetFiles collectionJson workingDirectory printer match Directories.warnIfAnyFiles workingDirectory 20 with | Ok _ -> () From 19a32fdb694bcc6b97de7a7c4c7bf814b94bef01 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:07:43 +0900 Subject: [PATCH 066/247] Remove FirstError method and uses --- src/CCVTAC.FSharp/Orchestrator.fs | 10 +++++----- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 6 +++--- src/CCVTAC.FSharp/Printer.fs | 4 ---- src/CCVTAC.FSharp/Program.fs | 4 ++-- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index f253b566..3b7b9746 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -79,8 +79,8 @@ module Orchestrator = : Result = match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with - | Error firstErr -> - printer.FirstError firstErr + | Error err -> + printer.Error err Ok NextAction.QuitDueToErrors | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. @@ -282,14 +282,14 @@ module Orchestrator = let start (settings: UserSettings) (printer: Printer) : unit = // The working directory should start empty. Give the user a chance to empty it. match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with - | Error firstErr -> - printer.FirstError firstErr + | Error err -> + printer.Error err match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." | Error err -> - printer.FirstError err + printer.Error err printer.Info "Aborting..." () | Ok () -> diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 5549c2d4..c6222dae 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -85,12 +85,12 @@ module PostProcessor = match Directories.warnIfAnyFiles workingDirectory 20 with | Ok _ -> () - | Error firstErr -> - printer.FirstError firstErr + | Error err -> + printer.Error err printer.Info "Will delete the remaining files..." match Directories.deleteAllFiles workingDirectory 20 with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." - | Error e -> printer.FirstError e + | Error e -> printer.Error e | Error e -> printer.Error($"Tagging error(s) preventing further post-processing: {e}") diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index ab2f7c88..a8fd45e3 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -119,10 +119,6 @@ type Printer(showDebug: bool) = member this.Errors<'a>(headerMessage: string, failingResult: Result<'a, string list>) = this.Errors(headerMessage, extractedErrors failingResult) - member this.FirstError(message: string, ?prepend: string) = - let prefix = match prepend with Some x -> x | None -> String.Empty - this.Error($"{prefix}{message}") - member this.Warning(message: string, ?appendLineBreak: bool, ?prependLines: byte, ?appendLines: byte, ?processMarkup: bool) = this.Print(Level.Warning, message, ?appendLineBreak = appendLineBreak, ?prependLines = prependLines, ?appendLines = appendLines, ?processMarkup = processMarkup) diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 359e976c..ef3446e2 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -52,10 +52,10 @@ module Program = match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with | Ok () -> () | Error warnResult -> - printer.FirstError warnResult + printer.Error warnResult match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." - | Error delErr -> printer.FirstError delErr + | Error delErr -> printer.Error delErr ) try From 1661254b55861d57a9157f5bf73d0172cda26e74 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:10:00 +0900 Subject: [PATCH 067/247] Remove useless auto-generated func --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 87dc9a7f..ae787813 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -14,13 +14,6 @@ type TaggedFile = TagLib.File module Tagger = - let private watchFriendly (watch: System.Diagnostics.Stopwatch) = - if watch.IsRunning then watch.Stop() - let ts = watch.Elapsed - if ts.TotalHours >= 1.0 then sprintf "%d:%02d:%02d" (int ts.TotalHours) ts.Minutes ts.Seconds - elif ts.TotalMinutes >= 1.0 then sprintf "%d:%02d" ts.Minutes ts.Seconds - else sprintf "%A ms" ts.TotalMilliseconds // TODO: %d didn't work - let private parseVideoJson (taggingSet: TaggingSet) : Result = try let json = File.ReadAllText taggingSet.JsonFilePath From 71cdea39e07650ac40c4dd4a90126a678b2e5d3d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:12:59 +0900 Subject: [PATCH 068/247] Rename items to lowercase --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 6 +++--- src/CCVTAC.FSharp/Downloading/Updater.fs | 3 +-- src/CCVTAC.FSharp/Downloading/Uploader.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 4 ++-- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 5 +++-- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 8 ++++---- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 8 ++++---- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 84afc82f..bb37c147 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -10,7 +10,7 @@ open System module Downloader = [] - let private ProgramName = "yt-dlp" + let private programName = "yt-dlp" type Urls = { Primary: string Supplementary: string option } @@ -85,7 +85,7 @@ module Downloader = for format in settings.AudioFormats do if not stopped then let args = generateDownloadArgs (Some format) settings (Some mediaType) (Some [urls.Primary]) - let commandWithArgs = $"{ProgramName} {args}" + let commandWithArgs = $"{programName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory downloadResult <- Runner.run downloadSettings [1] printer @@ -122,7 +122,7 @@ module Downloader = match urls.Supplementary with | Some supplementaryUrl -> let args = generateDownloadArgs None settings None (Some [supplementaryUrl]) - let commandWithArgs = $"{ProgramName} {args}" + let commandWithArgs = $"{programName} {args}" let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory let supplementaryDownloadResult = Runner.run downloadSettings [1] printer diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index c2b5b955..95f451a1 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -2,12 +2,11 @@ namespace CCVTAC.Console.Downloading open CCVTAC.Console.ExternalTools open CCVTAC.Console -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings /// Manages downloader updates module Updater = - /// Represents download URLs + type private Urls = { Primary: string Supplementary: string option diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index eb2ba9f5..3ec1b8f3 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -1,10 +1,10 @@ namespace CCVTAC.Console -open System open CCVTAC.Console.ExternalTools open CCVTAC.Console.Settings.Settings module Updater = + type private Urls = { Primary: string Supplementary: string option } diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 7958c540..1994b03d 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -8,10 +8,10 @@ open System.Diagnostics module Runner = [] - let private AuthenticSuccessExitCode = 0 + let private authenticSuccessExitCode = 0 let private isSuccessExitCode (otherSuccessExitCodes: int list) (exitCode: int) = - List.contains exitCode (AuthenticSuccessExitCode :: otherSuccessExitCodes) + List.contains exitCode (authenticSuccessExitCode :: otherSuccessExitCodes) /// Calls an external application. /// Tool settings for execution diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 5efa2856..92c7a3fe 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -6,8 +6,9 @@ open System.Text open CCVTAC.Console module Directories = + [] - let private AllFilesSearchPattern = "*" + let private allFilesSearchPattern = "*" let private enumerationOptions = EnumerationOptions() @@ -28,7 +29,7 @@ module Directories = |> Seq.distinct |> Seq.toArray - Directory.GetFiles(directoryName, AllFilesSearchPattern, enumerationOptions) + Directory.GetFiles(directoryName, allFilesSearchPattern, enumerationOptions) |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) /// Deletes all files in the working directory diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index e73205c0..e2dd511d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -14,14 +14,14 @@ open TaggingSets module Mover = - let private PlaylistImageRegex = Regex(@"\[[OP]L[\w\d_-]{12,}\]", RegexOptions.Compiled) - let private ImageFileWildcard = "*.jp*" + let private playlistImageRegex = Regex(@"\[[OP]L[\w\d_-]{12,}\]", RegexOptions.Compiled) + let private imageFileWildcard = "*.jp*" let private isPlaylistImage (fileName: string) = - PlaylistImageRegex.IsMatch fileName + playlistImageRegex.IsMatch fileName let private getCoverImage (workingDirInfo: DirectoryInfo) audioFileCount : FileInfo option = - let images = workingDirInfo.EnumerateFiles ImageFileWildcard |> Seq.toArray + let images = workingDirInfo.EnumerateFiles imageFileWildcard |> Seq.toArray if images.Length = 0 then None else let playlistImages = images |> Array.filter (fun i -> isPlaylistImage i.FullName) |> Array.toList diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index ae787813..b910ab9d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -30,7 +30,7 @@ module Tagger = Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) - let private DeleteSourceFile (taggingSet: TaggingSet) (printer: Printer) : TaggingSet = + let private deleteSourceFile (taggingSet: TaggingSet) (printer: Printer) : TaggingSet = if taggingSet.AudioFilePaths.Length <= 1 then taggingSet else let largestFileInfo = @@ -47,7 +47,7 @@ module Tagger = printer.Error (sprintf "Error deleting pre-split source file \"%s\": %s" largestFileInfo.Name ex.Message) taggingSet - let private WriteImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = + let private writeImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = if hasNoText imageFilePath then printer.Error "No image file path was provided, so cannot add an image to the file." else @@ -173,7 +173,7 @@ module Tagger = settings.DoNotEmbedImageUploaders |> Array.doesNotContain videoData.Uploader then printer.Info "Embedding artwork." - WriteImage taggedFile path printer + writeImage taggedFile path printer else printer.Debug "Skipping artwork embedding." | None -> @@ -195,7 +195,7 @@ module Tagger = match parseVideoJson taggingSet with | Ok videoData -> - let finalTaggingSet = DeleteSourceFile taggingSet printer + let finalTaggingSet = deleteSourceFile taggingSet printer let imagePath = if embedImages && List.isNotEmpty finalTaggingSet.AudioFilePaths then From 21e4df891909b6a75eacd5d458d80e4d8af82229 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:27:02 +0900 Subject: [PATCH 069/247] Tagger cleanup --- .../PostProcessing/Tagging/Tagger.fs | 57 ++++++++----------- 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index b910ab9d..d907e3a7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -14,7 +14,7 @@ type TaggedFile = TagLib.File module Tagger = - let private parseVideoJson (taggingSet: TaggingSet) : Result = + let private parseVideoJson taggingSet : Result = try let json = File.ReadAllText taggingSet.JsonFilePath try @@ -22,32 +22,34 @@ module Tagger = let videoData = JsonSerializer.Deserialize(json) #warnon 3265 - if isNull (box videoData) then Error (sprintf "Deserialized JSON was null for \"%s\"" taggingSet.JsonFilePath) + // TODO: Make this more idiomatic. + if isNull (box videoData) then Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"" else Ok videoData with - | :? JsonException as ex -> Error (sprintf "%s%s%s" ex.Message Environment.NewLine ex.StackTrace) + | :? JsonException as ex -> Error $"%s{ex.Message}%s{newLine}%s{ex.StackTrace}" with ex -> - Error (sprintf "Error reading JSON file \"%s\": %s." taggingSet.JsonFilePath ex.Message) + Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}." - - let private deleteSourceFile (taggingSet: TaggingSet) (printer: Printer) : TaggingSet = + let private deleteSourceFile taggingSet (printer: Printer) : TaggingSet = if taggingSet.AudioFilePaths.Length <= 1 then taggingSet else let largestFileInfo = taggingSet.AudioFilePaths - |> Seq.map (fun fn -> FileInfo(fn)) - |> Seq.sortByDescending (fun fi -> fi.Length) + |> Seq.map FileInfo + |> Seq.sortByDescending _.Length |> Seq.head try File.Delete largestFileInfo.FullName - printer.Debug (sprintf "Deleted pre-split source file \"%s\"" largestFileInfo.Name) - { taggingSet with AudioFilePaths = taggingSet.AudioFilePaths |> List.except [largestFileInfo.FullName] } + printer.Debug $"Deleted pre-split source file \"%s{largestFileInfo.Name}\"" + { taggingSet with + AudioFilePaths = taggingSet.AudioFilePaths + |> List.except [largestFileInfo.FullName] } with ex -> - printer.Error (sprintf "Error deleting pre-split source file \"%s\": %s" largestFileInfo.Name ex.Message) + printer.Error $"Error deleting pre-split source file \"%s{largestFileInfo.Name}\": %s{ex.Message}" taggingSet - let private writeImage (taggedFile: TaggedFile) (imageFilePath: string) (printer: Printer) = + let private writeImage (taggedFile: TaggedFile) imageFilePath (printer: Printer) = if hasNoText imageFilePath then printer.Error "No image file path was provided, so cannot add an image to the file." else @@ -57,19 +59,15 @@ module Tagger = taggedFile.Tag.Pictures <- pics printer.Debug "Image written to file tags OK." with ex -> - printer.Error (sprintf "Error writing image to the audio file: %s" ex.Message) - + printer.Error $"Error writing image to the audio file: %s{ex.Message}" - /// If the supplied video uploader is specified in the settings, returns the video's upload year. - /// Otherwise, returns null (Nullable). - let releaseYear (settings: UserSettings) (videoData: VideoMetadata) : uint32 option = - if settings.IgnoreUploadYearUploaders |> caseInsensitiveContains videoData.Uploader + let private releaseYear userSettings videoMetadata : uint32 option = + if userSettings.IgnoreUploadYearUploaders |> caseInsensitiveContains videoMetadata.Uploader then None - else if videoData.UploadDate.Length < 4 + else if videoMetadata.UploadDate.Length <> 4 then None else - let yearStr = videoData.UploadDate.Substring(0, 4) - match UInt32.TryParse yearStr with + match UInt32.TryParse(videoMetadata.UploadDate.Substring(0, 4)) with | true, parsed -> Some parsed | _ -> None @@ -81,6 +79,7 @@ module Tagger = (collectionData: CollectionMetadata option) (printer: Printer) = + let audioFileName = Path.GetFileName audioFilePath printer.Debug $"Current audio file: \"%s{audioFileName}\"" @@ -88,14 +87,6 @@ module Tagger = let tagDetector = TagDetector settings.TagDetectionPatterns // Title - // match videoData.Track with - // | NonNull (metadataTitle: string) -> - // printer.Debug (sprintf "• Using metadata title \"%s\"" metadataTitle) - // taggedFile.Tag.Title <- metadataTitle - // | Null -> - // let title = tagDetector.DetectTitle(videoData, videoData.Title) - // printer.Debug (sprintf "• Found title \"%s\"" title) - // taggedFile.Tag.Title <- title if hasText videoData.Track then printer.Debug $"• Using metadata title \"%s{videoData.Track}\"" taggedFile.Tag.Title <- videoData.Track @@ -106,7 +97,7 @@ module Tagger = taggedFile.Tag.Title <- title | None -> printer.Debug "No title was found." - // Artist / Performers + // Artists if hasText videoData.Artist then let metadataArtists = videoData.Artist let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None)[0] @@ -156,7 +147,6 @@ module Tagger = taggedFile.Tag.Year <- year | NullV -> let maybeDefaultYear = releaseYear settings videoData - match tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) with | None -> () | Some year -> @@ -169,8 +159,7 @@ module Tagger = // Artwork embedding match imageFilePath with | Some path -> - if settings.EmbedImages && - settings.DoNotEmbedImageUploaders |> Array.doesNotContain videoData.Uploader + if settings.EmbedImages && settings.DoNotEmbedImageUploaders |> Array.doesNotContain videoData.Uploader then printer.Info "Embedding artwork." writeImage taggedFile path printer @@ -191,7 +180,7 @@ module Tagger = : unit = - printer.Debug $"%d{taggingSet.AudioFilePaths.Length} audio file(s) with resource ID \"%s{taggingSet.ResourceId}\"" + printer.Debug $"%d{taggingSet.AudioFilePaths.Length} audio file(s) with resource ID %s{taggingSet.ResourceId}" match parseVideoJson taggingSet with | Ok videoData -> From 242887543ceae7a6db6484e697b046b06dec164b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:36:01 +0900 Subject: [PATCH 070/247] Stop auto-opening utility collection modules --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 14 +++++++------- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 2 +- .../PostProcessing/Tagging/TaggingSet.fs | 2 +- src/CCVTAC.FSharp/Program.fs | 4 ++-- src/CCVTAC.FSharp/Utilities.fs | 3 --- 8 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 92c7a3fe..410ff26d 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -15,7 +15,7 @@ module Directories = /// Counts the number of audio files in a directory let internal audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() - |> Seq.filter (fun f -> caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) |> Seq.length /// Returns the filenames in a given directory, optionally ignoring specific filenames diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 3b7b9746..ecc2adb3 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -146,38 +146,38 @@ module Orchestrator = Ok NextAction.Continue // Quit - elif caseInsensitiveContains command Commands.quitCommands then + elif Seq.caseInsensitiveContains command Commands.quitCommands then Ok NextAction.QuitAtUserRequest // History - elif caseInsensitiveContains command Commands.history then + elif Seq.caseInsensitiveContains command Commands.history then history.ShowRecent printer Ok NextAction.Continue // Update downloader - elif caseInsensitiveContains command Commands.updateDownloader then + elif Seq.caseInsensitiveContains command Commands.updateDownloader then Updater.run settings printer |> ignore Ok NextAction.Continue // Settings summary - elif caseInsensitiveContains command Commands.settingsSummary then + elif Seq.caseInsensitiveContains command Commands.settingsSummary then Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters - elif caseInsensitiveContains command Commands.splitChapterToggles then + elif Seq.caseInsensitiveContains command Commands.splitChapterToggles then settings <- toggleSplitChapters(settings) printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) Ok NextAction.Continue // Toggle embed images - elif caseInsensitiveContains command Commands.embedImagesToggles then + elif Seq.caseInsensitiveContains command Commands.embedImagesToggles then settings <- toggleEmbedImages(settings) printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) Ok NextAction.Continue // Toggle quiet mode - elif caseInsensitiveContains command Commands.quietModeToggles then + elif Seq.caseInsensitiveContains command Commands.quietModeToggles then settings <- toggleQuietMode(settings) printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) printer.ShowDebug(not settings.QuietMode) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index e2dd511d..0cb7e216 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -150,7 +150,7 @@ module Mover = | Ok () -> let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index fb8095bb..acf4d6d5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -24,7 +24,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq if audioFiles.Length = 0 then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index d907e3a7..56794f77 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -62,7 +62,7 @@ module Tagger = printer.Error $"Error writing image to the audio file: %s{ex.Message}" let private releaseYear userSettings videoMetadata : uint32 option = - if userSettings.IgnoreUploadYearUploaders |> caseInsensitiveContains videoMetadata.Uploader + if userSettings.IgnoreUploadYearUploaders |> Seq.caseInsensitiveContains videoMetadata.Uploader then None else if videoMetadata.UploadDate.Length <> 4 then None diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index cec864f8..5f0f78b6 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -33,7 +33,7 @@ module TaggingSets = let fileHasSupportedExtension (f: string) = match Path.GetExtension f with | Null -> false - | NonNull (x: string) -> caseInsensitiveContains x AudioExtensions + | NonNull (x: string) -> Seq.caseInsensitiveContains x AudioExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index ef3446e2..518ccac6 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -22,12 +22,12 @@ module Program = let main (args: string array) : int = let printer = Printer(showDebug = true) - if args.Length > 0 && caseInsensitiveContains args[0] helpFlags then + if args.Length > 0 && Seq.caseInsensitiveContains args[0] helpFlags then Help.Print printer int ExitCodes.Success else let maybeSettingsPath = - if args.Length >= 2 && caseInsensitiveContains args[0] settingsFileFlags then + if args.Length >= 2 && Seq.caseInsensitiveContains args[0] settingsFileFlags then args[1] // Expected to be a settings file path else defaultSettingsFileName diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 5e77b405..3e0f9fce 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -62,15 +62,12 @@ module String = let trimTerminalLineBreak (text: string) = text.TrimEnd(newLine.ToCharArray()) -[] module Seq = let caseInsensitiveContains text (xs: string seq) : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) -[] module List = let isNotEmpty l = not (List.isEmpty l) -[] module Array = let doesNotContain x arr = Array.contains x arr |> not From 358abcd027e8ec2c5f35d664cdc4b015adddc4a7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:53:27 +0900 Subject: [PATCH 071/247] Renamer cleanup --- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 53 ++++++++++----------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index acf4d6d5..975d4ebd 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -1,75 +1,74 @@ namespace CCVTAC.Console.PostProcessing +open CCVTAC.Console +open CCVTAC.Console.Settings.Settings open System open System.IO open System.Text open System.Text.RegularExpressions -open CCVTAC.Console -open CCVTAC.Console.Settings.Settings open Startwatch.Library module Renamer = - let private getNormalizationForm (form: string) = + let private toNormalizationForm (form: string) = match form.Trim().ToUpperInvariant() with | "D" -> NormalizationForm.FormD | "KD" -> NormalizationForm.FormKD | "KC" -> NormalizationForm.FormKC | _ -> NormalizationForm.FormC - let Run (settings: UserSettings) (workingDirectory: string) (printer: Printer) : unit = + let Run userSettings workingDirectory (printer: Printer) : unit = let watch = Watch() - - let workingDirInfo = DirectoryInfo(workingDirectory) + let workingDirInfo = DirectoryInfo workingDirectory let audioFiles = workingDirInfo.EnumerateFiles() |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq - if audioFiles.Length = 0 then + if List.isEmpty audioFiles then printer.Warning "No audio files to rename were found." else printer.Debug $"Renaming %d{audioFiles.Length} audio file(s)..." for file in audioFiles do let newFileName = - // Fold over rename patterns, starting with StringBuilder(file.Name) - settings.RenamePatterns - |> Seq.fold - (fun (sb: StringBuilder) (renamePattern) -> - let regex = Regex(renamePattern.RegexPattern) + userSettings.RenamePatterns + |> Array.fold + (fun (sb: StringBuilder) renamePattern -> + let regex = Regex renamePattern.RegexPattern let matches = regex.Matches(sb.ToString()) |> Seq.cast - |> Seq.filter (fun m -> m.Success) + |> Seq.filter _.Success |> Seq.rev |> Seq.toList if matches.Length = 0 then sb else - if not settings.QuietMode then - let matchedPatternSummary = + if not userSettings.QuietMode then + let patternSummary = // if isNull renamePattern.Summary then // TODO: Check on this. $"`%s{renamePattern.RegexPattern}` (no description)" // else // sprintf "\"%s\"" renamePattern.Summary - printer.Debug(sprintf "Rename pattern %s matched × %d." matchedPatternSummary matches.Length) + printer.Debug $"Rename pattern %s{patternSummary} matched × %d{matches.Length}." for m in matches do - // remove matched substring sb.Remove(m.Index, m.Length) |> ignore - // build replacement text by replacing %s placeholders with group captures + // Build replacement text by replacing %s placeholders with group captures let replacementText = m.Groups |> Seq.cast - |> Seq.mapi (fun i g -> + |> Seq.mapi (fun i _ -> let searchFor = sprintf "%%<%d>s" (i + 1) let replaceWith = - // Group 0 is the entire match, and we only want groups starting at 1. - if i + 1 < m.Groups.Count then m.Groups[i + 1].Value.Trim() else String.Empty + // Group 0 is the entire match, so we only want groups starting at 1. + if i + 1 < m.Groups.Count + then m.Groups[i + 1].Value.Trim() + else String.Empty (searchFor, replaceWith)) |> Seq.fold (fun (sbRep: StringBuilder) (searchFor, replaceWith) -> sbRep.Replace(searchFor, replaceWith)) @@ -79,18 +78,18 @@ module Renamer = sb.Insert(m.Index, replacementText) |> ignore sb) - (StringBuilder(file.Name)) + (StringBuilder file.Name) |> _.ToString() try let dest = Path.Combine(workingDirectory, newFileName) - |> _.Normalize(getNormalizationForm settings.NormalizationForm) + |> _.Normalize(toNormalizationForm userSettings.NormalizationForm) File.Move(file.FullName, dest) - printer.Debug (sprintf "• From: \"%s\"" file.Name) - printer.Debug (sprintf " To: \"%s\"" newFileName) + printer.Debug $"• From: \"%s{file.Name}\"" + printer.Debug $" To: \"%s{newFileName}\"" with ex -> - printer.Error (sprintf "• Error renaming \"%s\": %s" file.Name ex.Message) + printer.Error $"• Error renaming \"%s{file.Name}\": %s{ex.Message}" - printer.Info (sprintf "Renaming done in %s." watch.ElapsedFriendly) + printer.Info $"Renaming done in %s{watch.ElapsedFriendly}." From 271932f1c9c079ade0a05976330d3ab067790c00 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:59:54 +0900 Subject: [PATCH 072/247] Extract fold lambda to func, etc. --- .../PostProcessing/PostProcessing.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 104 +++++++++--------- 2 files changed, 55 insertions(+), 51 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index c6222dae..167b9993 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -77,7 +77,7 @@ module PostProcessor = match Tagger.run settings taggingSets collectionJson mediaType printer with | Ok msg -> printer.Info msg - Renamer.Run settings workingDirectory printer + Renamer.run settings workingDirectory printer Mover.run taggingSets collectionJson settings true printer let allTaggingSetFiles = taggingSets |> Seq.collect allFiles diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 975d4ebd..488ee240 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -8,6 +8,8 @@ open System.Text open System.Text.RegularExpressions open Startwatch.Library +type SB = StringBuilder + module Renamer = let private toNormalizationForm (form: string) = @@ -17,7 +19,51 @@ module Renamer = | "KC" -> NormalizationForm.FormKC | _ -> NormalizationForm.FormC - let Run userSettings workingDirectory (printer: Printer) : unit = + let private updateTextViaPatterns userSettings (printer: Printer) (sb: SB) (renamePattern: RenamePattern) = + let regex = Regex renamePattern.RegexPattern + let matches = + regex.Matches(sb.ToString()) + |> Seq.cast + |> Seq.filter _.Success + |> Seq.rev + |> Seq.toList + + if matches.Length = 0 then sb + else + if not userSettings.QuietMode then + let patternSummary = + // if isNull renamePattern.Summary then // TODO: Check on this. + $"`%s{renamePattern.RegexPattern}` (no description)" + // else + // sprintf "\"%s\"" renamePattern.Summary + + printer.Debug $"Rename pattern %s{patternSummary} matched × %d{matches.Length}." + + for m in matches do + sb.Remove(m.Index, m.Length) |> ignore + + // Build replacement text by replacing %s placeholders with group captures + let replacementText = + m.Groups + |> Seq.cast + |> Seq.mapi (fun i _ -> + let searchFor = sprintf "%%<%d>s" (i + 1) + let replaceWith = + // Group 0 is the entire match, so we only want groups starting at 1. + if i + 1 < m.Groups.Count + then m.Groups[i + 1].Value.Trim() + else String.Empty + (searchFor, replaceWith)) + |> Seq.fold (fun (sbRep: SB) (searchFor, replaceWith) -> + sbRep.Replace(searchFor, replaceWith)) + (SB renamePattern.ReplaceWithPattern) + |> _.ToString() + + sb.Insert(m.Index, replacementText) |> ignore + + sb + + let run userSettings workingDirectory (printer: Printer) : unit = let watch = Watch() let workingDirInfo = DirectoryInfo workingDirectory @@ -31,65 +77,23 @@ module Renamer = else printer.Debug $"Renaming %d{audioFiles.Length} audio file(s)..." - for file in audioFiles do + for audioFile in audioFiles do let newFileName = userSettings.RenamePatterns |> Array.fold - (fun (sb: StringBuilder) renamePattern -> - let regex = Regex renamePattern.RegexPattern - let matches = - regex.Matches(sb.ToString()) - |> Seq.cast - |> Seq.filter _.Success - |> Seq.rev - |> Seq.toList - - if matches.Length = 0 then sb - else - if not userSettings.QuietMode then - let patternSummary = - // if isNull renamePattern.Summary then // TODO: Check on this. - $"`%s{renamePattern.RegexPattern}` (no description)" - // else - // sprintf "\"%s\"" renamePattern.Summary - - printer.Debug $"Rename pattern %s{patternSummary} matched × %d{matches.Length}." - - for m in matches do - sb.Remove(m.Index, m.Length) |> ignore - - // Build replacement text by replacing %s placeholders with group captures - let replacementText = - m.Groups - |> Seq.cast - |> Seq.mapi (fun i _ -> - let searchFor = sprintf "%%<%d>s" (i + 1) - let replaceWith = - // Group 0 is the entire match, so we only want groups starting at 1. - if i + 1 < m.Groups.Count - then m.Groups[i + 1].Value.Trim() - else String.Empty - (searchFor, replaceWith)) - |> Seq.fold (fun (sbRep: StringBuilder) (searchFor, replaceWith) -> - sbRep.Replace(searchFor, replaceWith)) - (StringBuilder(renamePattern.ReplaceWithPattern)) - |> _.ToString() - - sb.Insert(m.Index, replacementText) |> ignore - - sb) - (StringBuilder file.Name) + (fun (sb: SB) rp -> updateTextViaPatterns userSettings printer sb rp) + (SB audioFile.Name) |> _.ToString() try - let dest = + let destinationPath = Path.Combine(workingDirectory, newFileName) |> _.Normalize(toNormalizationForm userSettings.NormalizationForm) - File.Move(file.FullName, dest) - printer.Debug $"• From: \"%s{file.Name}\"" + File.Move(audioFile.FullName, destinationPath) + printer.Debug $"• From: \"%s{audioFile.Name}\"" printer.Debug $" To: \"%s{newFileName}\"" with ex -> - printer.Error $"• Error renaming \"%s{file.Name}\": %s{ex.Message}" + printer.Error $"• Error renaming \"%s{audioFile.Name}\": %s{ex.Message}" printer.Info $"Renaming done in %s{watch.ElapsedFriendly}." From b0ee44d26c92c0edc1ea3a89e802a981478a6cb7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:38:05 +0900 Subject: [PATCH 073/247] Readd rename pattern summary logic --- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 488ee240..d7c888bd 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -14,13 +14,14 @@ module Renamer = let private toNormalizationForm (form: string) = match form.Trim().ToUpperInvariant() with - | "D" -> NormalizationForm.FormD + | "D" -> NormalizationForm.FormD | "KD" -> NormalizationForm.FormKD | "KC" -> NormalizationForm.FormKC - | _ -> NormalizationForm.FormC + | _ -> NormalizationForm.FormC let private updateTextViaPatterns userSettings (printer: Printer) (sb: SB) (renamePattern: RenamePattern) = let regex = Regex renamePattern.RegexPattern + let matches = regex.Matches(sb.ToString()) |> Seq.cast @@ -28,21 +29,22 @@ module Renamer = |> Seq.rev |> Seq.toList - if matches.Length = 0 then sb + if matches.Length = 0 + then sb else if not userSettings.QuietMode then let patternSummary = - // if isNull renamePattern.Summary then // TODO: Check on this. + if hasNoText renamePattern.Summary then $"`%s{renamePattern.RegexPattern}` (no description)" - // else - // sprintf "\"%s\"" renamePattern.Summary + else + $"\"%s{renamePattern.Summary}\"" printer.Debug $"Rename pattern %s{patternSummary} matched × %d{matches.Length}." for m in matches do sb.Remove(m.Index, m.Length) |> ignore - // Build replacement text by replacing %s placeholders with group captures + // Build replacement text by replacing % placeholders with group captures let replacementText = m.Groups |> Seq.cast @@ -81,7 +83,7 @@ module Renamer = let newFileName = userSettings.RenamePatterns |> Array.fold - (fun (sb: SB) rp -> updateTextViaPatterns userSettings printer sb rp) + (fun (sb: SB) -> updateTextViaPatterns userSettings printer sb) (SB audioFile.Name) |> _.ToString() From 46948c3af674503709e417baeab8641d2a195592 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:26:11 +0900 Subject: [PATCH 074/247] Minor tweaks --- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index d7c888bd..5f3eb189 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -44,7 +44,7 @@ module Renamer = for m in matches do sb.Remove(m.Index, m.Length) |> ignore - // Build replacement text by replacing % placeholders with group captures + // Build replacement text by replacing % placeholders with group captures. let replacementText = m.Groups |> Seq.cast @@ -56,9 +56,7 @@ module Renamer = then m.Groups[i + 1].Value.Trim() else String.Empty (searchFor, replaceWith)) - |> Seq.fold (fun (sbRep: SB) (searchFor, replaceWith) -> - sbRep.Replace(searchFor, replaceWith)) - (SB renamePattern.ReplaceWithPattern) + |> Seq.fold (fun (sbRep: SB) -> sbRep.Replace) (SB renamePattern.ReplaceWithPattern) |> _.ToString() sb.Insert(m.Index, replacementText) |> ignore From e89ccc6581841b5186a784e748eb53b2ee12f67d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:53:25 +0900 Subject: [PATCH 075/247] Add caseInsensitiveContains to more collection modules --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 14 +++++++------- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 2 +- src/CCVTAC.FSharp/Program.fs | 4 ++-- src/CCVTAC.FSharp/Utilities.fs | 6 ++++++ 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 410ff26d..f2edfd48 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -15,7 +15,7 @@ module Directories = /// Counts the number of audio files in a directory let internal audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() - |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension AudioExtensions) |> Seq.length /// Returns the filenames in a given directory, optionally ignoring specific filenames diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index ecc2adb3..f945ee46 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -146,38 +146,38 @@ module Orchestrator = Ok NextAction.Continue // Quit - elif Seq.caseInsensitiveContains command Commands.quitCommands then + elif Array.caseInsensitiveContains command Commands.quitCommands then Ok NextAction.QuitAtUserRequest // History - elif Seq.caseInsensitiveContains command Commands.history then + elif Array.caseInsensitiveContains command Commands.history then history.ShowRecent printer Ok NextAction.Continue // Update downloader - elif Seq.caseInsensitiveContains command Commands.updateDownloader then + elif Array.caseInsensitiveContains command Commands.updateDownloader then Updater.run settings printer |> ignore Ok NextAction.Continue // Settings summary - elif Seq.caseInsensitiveContains command Commands.settingsSummary then + elif Array.caseInsensitiveContains command Commands.settingsSummary then Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters - elif Seq.caseInsensitiveContains command Commands.splitChapterToggles then + elif Array.caseInsensitiveContains command Commands.splitChapterToggles then settings <- toggleSplitChapters(settings) printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) Ok NextAction.Continue // Toggle embed images - elif Seq.caseInsensitiveContains command Commands.embedImagesToggles then + elif Array.caseInsensitiveContains command Commands.embedImagesToggles then settings <- toggleEmbedImages(settings) printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) Ok NextAction.Continue // Toggle quiet mode - elif Seq.caseInsensitiveContains command Commands.quietModeToggles then + elif Array.caseInsensitiveContains command Commands.quietModeToggles then settings <- toggleQuietMode(settings) printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) printer.ShowDebug(not settings.QuietMode) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 0cb7e216..72f163a9 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -150,7 +150,7 @@ module Mover = | Ok () -> let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 5f3eb189..983fdf40 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -69,7 +69,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> Seq.caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension AudioExtensions) |> List.ofSeq if List.isEmpty audioFiles then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 56794f77..c63d4aef 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -62,7 +62,7 @@ module Tagger = printer.Error $"Error writing image to the audio file: %s{ex.Message}" let private releaseYear userSettings videoMetadata : uint32 option = - if userSettings.IgnoreUploadYearUploaders |> Seq.caseInsensitiveContains videoMetadata.Uploader + if userSettings.IgnoreUploadYearUploaders |> Array.caseInsensitiveContains videoMetadata.Uploader then None else if videoMetadata.UploadDate.Length <> 4 then None diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 518ccac6..aa24a2c1 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -22,12 +22,12 @@ module Program = let main (args: string array) : int = let printer = Printer(showDebug = true) - if args.Length > 0 && Seq.caseInsensitiveContains args[0] helpFlags then + if args.Length > 0 && Array.caseInsensitiveContains args[0] helpFlags then Help.Print printer int ExitCodes.Success else let maybeSettingsPath = - if args.Length >= 2 && Seq.caseInsensitiveContains args[0] settingsFileFlags then + if args.Length >= 2 && Array.caseInsensitiveContains args[0] settingsFileFlags then args[1] // Expected to be a settings file path else defaultSettingsFileName diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 3e0f9fce..2a9bc184 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -69,5 +69,11 @@ module Seq = module List = let isNotEmpty l = not (List.isEmpty l) + let caseInsensitiveContains text (xs: string list) : bool = + xs |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) + module Array = let doesNotContain x arr = Array.contains x arr |> not + + let caseInsensitiveContains text (xs: string array) : bool = + xs |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) From 14f244452856549845d1246904d7cc510dee1e72 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:57:25 +0900 Subject: [PATCH 076/247] Minor cleanup --- src/CCVTAC.FSharp/Settings/Settings.fs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 1dc62727..90bdfe61 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -123,16 +123,15 @@ module Settings = Error "No move-to directory was specified." | { MoveToDirectory = dir } when dirMissing dir -> Error $"Move-to directory \"{dir}\" is missing." - | { AudioQuality = q } when q > 10uy -> + | { AudioQuality = q } when q < 0uy || q > 10uy -> Error "Audio quality must be in the range 10 (lowest) and 0 (highest)." | { NormalizationForm = nf } when not(supportedNormalizationForms |> List.contains (nf.ToUpperInvariant())) -> let okFormats = String.Join(", ", supportedNormalizationForms) Error $"\"{nf}\" is an invalid normalization form. Use one of the following: {okFormats}." - | { AudioFormats = fmt } when not (fmt |> Array.forall (fun f -> f |> validAudioFormat)) -> + | { AudioFormats = fmt } when not (fmt |> Array.forall validAudioFormat) -> let formats = String.Join(", ", fmt) let approved = supportedAudioFormats |> String.concat ", " - let nl = Environment.NewLine - Error $"Audio formats (\"%s{formats}\") include an unsupported audio format.{nl}Only the following supported formats: {approved}." + Error $"Audio formats (\"%s{formats}\") include an unsupported audio format.{newLine}Only the following supported formats: {approved}." | _ -> Ok settings From f77b95c5bec7a80a8d818f8d01c090de9f5874c0 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 2 Dec 2025 21:14:56 +0900 Subject: [PATCH 077/247] Add exception handling to settings JSON deserialization; etc. --- src/CCVTAC.FSharp/Settings/Settings.fs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 90bdfe61..7d46d770 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -146,12 +146,14 @@ module Settings = let options = JsonSerializerOptions() options.AllowTrailingCommas <- true options.ReadCommentHandling <- JsonCommentHandling.Skip - match JsonSerializer.Deserialize<'a>(json, options) with // TODO: Add exception handling. - | null -> Error "Could not deserialize the JSON" - | s -> Ok s + try + match JsonSerializer.Deserialize<'a>(json, options) with + | null -> Error "Could not deserialize the settings JSON" + | s -> Ok s + with e -> Error e.Message let fileExists (FilePath path) = - match path |> File.Exists with + match File.Exists path with | true -> Ok() | false -> Error $"File \"{path}\" does not exist." @@ -173,12 +175,12 @@ module Settings = try let json = JsonSerializer.Serialize(settings, options) - (file, json) |> File.WriteAllText + File.WriteAllText(file, json) Ok $"A new settings file was saved to \"{file}\". Please populate it with your desired settings." with | :? FileNotFoundException -> Error $"File \"{file}\" was not found." | :? JsonException -> Error "Failure parsing user settings to JSON." - | e -> Error $"Failure writing \"{file}\": {e.Message}" + | e -> Error $"Unexpected error writing \"{file}\": {e.Message}" let writeDefaultFile (filePath: FilePath option) defaultFileName = let confirmedPath = From eea63e7e2d4c5596550cdf6e4cc0bef4f414690d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:51:32 +0900 Subject: [PATCH 078/247] Reduce code --- src/CCVTAC.FSharp/Settings/Settings.fs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 7d46d770..81691d09 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -221,21 +221,18 @@ module Settings = open Validation let toggleSplitChapters settings = - let toggledSetting = not settings.SplitChapters - { settings with SplitChapters = toggledSetting } + { settings with SplitChapters = not settings.SplitChapters } let toggleEmbedImages settings = - let toggledSetting = not settings.EmbedImages - { settings with EmbedImages = toggledSetting } + { settings with EmbedImages = not settings.EmbedImages } let toggleQuietMode settings = - let toggledSetting = not settings.QuietMode - { settings with QuietMode = toggledSetting } + { settings with QuietMode = not settings.QuietMode } let updateAudioFormat settings (newFormat: string) = - let updatedSettings = { settings with AudioFormats = newFormat.Split(',')} + let updatedSettings = { settings with AudioFormats = newFormat.Split ',' } validate updatedSettings let updateAudioQuality settings newQuality = - let updatedSettings = { settings with AudioQuality = newQuality} + let updatedSettings = { settings with AudioQuality = newQuality } validate updatedSettings From 0d5179cf1d5db2ed296c2096c9613484cdbfb63d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:01:42 +0900 Subject: [PATCH 079/247] Don't auto-open the String utility module either --- src/CCVTAC.FSharp/Commands.fs | 2 +- src/CCVTAC.FSharp/Downloading/Downloader.fs | 2 +- src/CCVTAC.FSharp/Downloading/Updater.fs | 2 +- src/CCVTAC.FSharp/Downloading/Uploader.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 4 ++-- src/CCVTAC.FSharp/Orchestrator.fs | 6 +++--- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 10 +++++----- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- .../PostProcessing/Tagging/Tagger.fs | 10 +++++----- .../PostProcessing/Tagging/TaggingSet.fs | 8 ++++---- .../PostProcessing/VideoMetadata.fs | 18 +++++++++--------- .../YouTubeMetadataExtensionMethods.fs | 18 +++++++++--------- src/CCVTAC.FSharp/Printer.fs | 4 ++-- src/CCVTAC.FSharp/Settings/Settings.fs | 8 ++++---- src/CCVTAC.FSharp/Utilities.fs | 1 - 15 files changed, 48 insertions(+), 49 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index 43bf0654..50709305 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -8,7 +8,7 @@ module internal Commands = let prefix: char = '\\' let private makeCommand (text: string) : string = - if hasNoText text then + if String.hasNoText text then raise (ArgumentException("The text cannot be null or white space.", "text")) if text.Contains ' ' then raise (ArgumentException("The text should not contain any white space.", "text")) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index bb37c147..b1ac3211 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -110,7 +110,7 @@ module Downloader = let combinedErrors = errors |> List.append ["No audio files were downloaded."] - |> String.concat newLine + |> String.concat String.newLine Error combinedErrors else // Continue to post-processing if errors. diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 95f451a1..fa1e10aa 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -16,7 +16,7 @@ module Updater = /// A `Result` that, if successful, contains the name of the successfully downloaded format. let internal run (settings: UserSettings) (printer: Printer) = // Check if update command is provided - if hasNoText settings.DownloaderUpdateCommand then + if String.hasNoText settings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() else diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs index 3ec1b8f3..1f7c22fc 100644 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ b/src/CCVTAC.FSharp/Downloading/Uploader.fs @@ -10,7 +10,7 @@ module Updater = Supplementary: string option } let internal run (settings: UserSettings) (printer: Printer) : Result = - if hasNoText settings.DownloaderUpdateCommand then + if String.hasNoText settings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() else diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 1994b03d..11f80ab6 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -45,8 +45,8 @@ module Runner = process'.WaitForExit() printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}.") - let trimmedErrors = if hasText error - then Some (trimTerminalLineBreak error) + let trimmedErrors = if String.hasText error + then Some (String.trimTerminalLineBreak error) else None if isSuccessExitCode otherSuccessExitCodes process'.ExitCode diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index f945ee46..e3a17d46 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -42,7 +42,7 @@ module Orchestrator = | _ -> String.Empty let connector = - if allHaveText [urlSummary; commandSummary] then " and " else String.Empty + if String.allHaveText [urlSummary; commandSummary] then " and " else String.Empty printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." @@ -139,7 +139,7 @@ module Orchestrator = : Result = // Help - if equalIgnoringCase Commands.helpCommand command then + if String.equalIgnoringCase Commands.helpCommand command then for kvp in Commands.summary do printer.Info(kvp.Key) printer.Info $" %s{kvp.Value}" @@ -271,7 +271,7 @@ module Orchestrator = if categoryCounts[InputCategory.Url] > 1 then printer.Info(sprintf "%sFinished with batch of %d URLs in %s." - newLine + String.newLine categoryCounts[InputCategory.Url] watch.ElapsedFriendly) batchResults.PrintBatchFailures() diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 72f163a9..36918319 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -76,10 +76,10 @@ module Mover = try let baseFileName = - if hasNoText maybeCollectionName then + if String.hasNoText maybeCollectionName then subFolderName else - $"%s{subFolderName} - %s{replaceInvalidPathChars None None maybeCollectionName}" + $"%s{subFolderName} - %s{String.replaceInvalidPathChars None None maybeCollectionName}" match getCoverImage workingDirInfo audioFileCount with | None -> () @@ -110,14 +110,14 @@ module Mover = let private getSafeSubDirectoryName (collectionData: CollectionMetadata option) taggingSet : string = let workingName = match collectionData with - | Some metadata when hasText metadata.Uploader && - hasText metadata.Title -> metadata.Uploader + | Some metadata when String.hasText metadata.Uploader && + String.hasText metadata.Title -> metadata.Uploader | _ -> match getParsedVideoJson taggingSet with | Ok v -> v.Uploader | Error _ -> "COLLECTION_DATA_NOT_FOUND" - let safeName = workingName |> replaceInvalidPathChars None None |> _.Trim() + let safeName = workingName |> String.replaceInvalidPathChars None None |> _.Trim() let topicSuffix = " - Topic" if safeName.EndsWith topicSuffix then safeName.Replace(topicSuffix, String.Empty) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 983fdf40..4642c774 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -34,7 +34,7 @@ module Renamer = else if not userSettings.QuietMode then let patternSummary = - if hasNoText renamePattern.Summary then + if String.hasNoText renamePattern.Summary then $"`%s{renamePattern.RegexPattern}` (no description)" else $"\"%s{renamePattern.Summary}\"" diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index c63d4aef..b2519578 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -26,7 +26,7 @@ module Tagger = if isNull (box videoData) then Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"" else Ok videoData with - | :? JsonException as ex -> Error $"%s{ex.Message}%s{newLine}%s{ex.StackTrace}" + | :? JsonException as ex -> Error $"%s{ex.Message}%s{String.newLine}%s{ex.StackTrace}" with ex -> Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}." @@ -50,7 +50,7 @@ module Tagger = taggingSet let private writeImage (taggedFile: TaggedFile) imageFilePath (printer: Printer) = - if hasNoText imageFilePath then + if String.hasNoText imageFilePath then printer.Error "No image file path was provided, so cannot add an image to the file." else try @@ -87,7 +87,7 @@ module Tagger = let tagDetector = TagDetector settings.TagDetectionPatterns // Title - if hasText videoData.Track then + if String.hasText videoData.Track then printer.Debug $"• Using metadata title \"%s{videoData.Track}\"" taggedFile.Tag.Title <- videoData.Track else @@ -98,7 +98,7 @@ module Tagger = | None -> printer.Debug "No title was found." // Artists - if hasText videoData.Artist then + if String.hasText videoData.Artist then let metadataArtists = videoData.Artist let firstArtist = metadataArtists.Split([|", "|], StringSplitOptions.None)[0] let diffSummary = @@ -115,7 +115,7 @@ module Tagger = taggedFile.Tag.Performers <- [| artist |] // Album - if hasText videoData.Album then + if String.hasText videoData.Album then printer.Debug $"• Using metadata album \"%s{videoData.Album}\"" taggedFile.Tag.Album <- videoData.Album else diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 5f0f78b6..3f42737f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -43,13 +43,13 @@ module TaggingSets = |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) |> Seq.filter (fun (_, fileNames) -> let isSupportedExtension = fileNames |> Seq.exists fileHasSupportedExtension - let jsonCount = fileNames |> Seq.filter (endsWithIgnoringCase jsonFileExt) |> Seq.length - let imageCount = fileNames |> Seq.filter (endsWithIgnoringCase imageFileExt) |> Seq.length + let jsonCount = fileNames |> Seq.filter (String.endsWithIgnoringCase jsonFileExt) |> Seq.length + let imageCount = fileNames |> Seq.filter (String.endsWithIgnoringCase imageFileExt) |> Seq.length isSupportedExtension && jsonCount = 1 && imageCount = 1) |> Seq.map (fun (key, files) -> let audioFiles = files |> Seq.filter fileHasSupportedExtension - let jsonFile = files |> Seq.find (endsWithIgnoringCase jsonFileExt) - let imageFile = files |> Seq.find (endsWithIgnoringCase imageFileExt) + let jsonFile = files |> Seq.find (String.endsWithIgnoringCase jsonFileExt) + let imageFile = files |> Seq.find (String.endsWithIgnoringCase imageFileExt) { ResourceId = key AudioFilePaths = audioFiles |> Seq.toList JsonFilePath = jsonFile diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index 69a963d5..34cd7d7f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -84,11 +84,11 @@ type VideoMetadata = /// Returns a string summarizing video uploader information. member private this.UploaderSummary() : string = let uploaderLinkOrIdOrEmpty = - if hasText this.UploaderUrl then this.UploaderUrl - elif hasText this.UploaderId then this.UploaderId + if String.hasText this.UploaderUrl then this.UploaderUrl + elif String.hasText this.UploaderId then this.UploaderId else String.Empty - let suffix = if hasText uploaderLinkOrIdOrEmpty then $" (%s{uploaderLinkOrIdOrEmpty})" else String.Empty + let suffix = if String.hasText uploaderLinkOrIdOrEmpty then $" (%s{uploaderLinkOrIdOrEmpty})" else String.Empty this.Uploader + suffix /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") @@ -109,22 +109,22 @@ type VideoMetadata = sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - if hasText this.Creator && this.Creator <> this.Uploader then + if String.hasText this.Creator && this.Creator <> this.Uploader then sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore - if hasText this.Artist then + if String.hasText this.Artist then sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore - if hasText this.Album then + if String.hasText this.Album then sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore - if hasText this.Title && this.Title <> this.Fulltitle then + if String.hasText this.Title && this.Title <> this.Fulltitle then sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore let description = - if hasNoText this.Description then "None." else this.Description + if String.hasNoText this.Description then "None." else this.Description sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore @@ -136,7 +136,7 @@ type VideoMetadata = match this.PlaylistIndex with | NonNullV (index: uint32) -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore | NullV -> () - sb.AppendLine(sprintf "■ Playlist description: %s" (if hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if String.hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore | None -> () sb.ToString() diff --git a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs index 46ad670e..03dacb92 100644 --- a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs +++ b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs @@ -10,11 +10,11 @@ module YouTubeMetadataExtensionMethods = /// Returns a string summarizing video uploader information. member private this.UploaderSummary() : string = let uploaderLinkOrIdOrEmpty = - if hasText this.UploaderUrl then this.UploaderUrl - elif hasText this.UploaderId then this.UploaderId + if String.hasText this.UploaderUrl then this.UploaderUrl + elif String.hasText this.UploaderId then this.UploaderId else String.Empty - let suffix = if hasText uploaderLinkOrIdOrEmpty then + let suffix = if String.hasText uploaderLinkOrIdOrEmpty then $" (%s{uploaderLinkOrIdOrEmpty})" else String.Empty this.Uploader + suffix @@ -37,22 +37,22 @@ module YouTubeMetadataExtensionMethods = sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - if hasText this.Creator && this.Creator <> this.Uploader then + if String.hasText this.Creator && this.Creator <> this.Uploader then sb.AppendLine $"■ Creator: %s{this.Creator}" |> ignore - if hasText this.Artist then + if String.hasText this.Artist then sb.AppendLine $"■ Artist: %s{this.Artist}" |> ignore - if hasText this.Album then + if String.hasText this.Album then sb.AppendLine $"■ Album: %s{this.Album}" |> ignore - if hasText this.Title && this.Title <> this.Fulltitle then + if String.hasText this.Title && this.Title <> this.Fulltitle then sb.AppendLine $"■ Track Title: %s{this.Title}" |> ignore sb.AppendLine $"■ Uploaded: %s{this.FormattedUploadDate()}" |> ignore let description = - if hasNoText this.Description then "None." else this.Description + if String.hasNoText this.Description then "None." else this.Description sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore @@ -64,7 +64,7 @@ module YouTubeMetadataExtensionMethods = match this.PlaylistIndex with | NullV -> () | NonNullV index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore - sb.AppendLine(sprintf "■ Playlist description: %s" (if hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if String.hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore | None -> () sb.ToString() diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index a8fd45e3..c8256628 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -73,7 +73,7 @@ type Printer(showDebug: bool) = if int logLevel > int minimumLogLevel then () else - if hasNoText message then + if String.hasNoText message then raise (ArgumentNullException("message", "Message cannot be empty.")) Printer.EmptyLines prependLines @@ -104,7 +104,7 @@ type Printer(showDebug: bool) = member this.Errors(errors: string seq, ?appendLines: byte) = if Seq.isEmpty errors then raise (ArgumentException("No errors were provided!", "errors")) - for err in errors |> Seq.filter hasText do + for err in errors |> Seq.filter String.hasText do this.Error err Printer.EmptyLines(defaultArg appendLines 0uy) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 81691d09..65ebb1b7 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -86,7 +86,7 @@ module Settings = let printSummary settings (printer: Printer) headerOpt : unit = match headerOpt with - | Some h when hasText h -> printer.Info h + | Some h when String.hasText h -> printer.Info h | _ -> () let table = Table() @@ -115,11 +115,11 @@ module Settings = let validAudioFormat fmt = supportedAudioFormats |> List.contains fmt match settings with - | { WorkingDirectory = dir } when hasNoText dir -> + | { WorkingDirectory = dir } when String.hasNoText dir -> Error "No working directory was specified." | { WorkingDirectory = dir } when dirMissing dir -> Error $"Working directory \"{dir}\" is missing." - | { MoveToDirectory = dir } when hasNoText dir -> + | { MoveToDirectory = dir } when String.hasNoText dir -> Error "No move-to directory was specified." | { MoveToDirectory = dir } when dirMissing dir -> Error $"Move-to directory \"{dir}\" is missing." @@ -131,7 +131,7 @@ module Settings = | { AudioFormats = fmt } when not (fmt |> Array.forall validAudioFormat) -> let formats = String.Join(", ", fmt) let approved = supportedAudioFormats |> String.concat ", " - Error $"Audio formats (\"%s{formats}\") include an unsupported audio format.{newLine}Only the following supported formats: {approved}." + Error $"Audio formats (\"%s{formats}\") include an unsupported audio format.{String.newLine}Only the following supported formats: {approved}." | _ -> Ok settings diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 2a9bc184..1069d54f 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -6,7 +6,6 @@ open System.Text type SB = StringBuilder -[] module String = let newLine = Environment.NewLine From 2f9a37e223f2e6d95884682ccc1afae8a1aa867e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:02:39 +0900 Subject: [PATCH 080/247] Remove unused Url record type --- src/CCVTAC.FSharp/Downloading/Updater.fs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index fa1e10aa..189a48b2 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -7,11 +7,6 @@ open CCVTAC.Console.Settings.Settings /// Manages downloader updates module Updater = - type private Urls = { - Primary: string - Supplementary: string option - } - /// Completes the actual download process. /// A `Result` that, if successful, contains the name of the successfully downloaded format. let internal run (settings: UserSettings) (printer: Printer) = From 02c2cf8b1110e88efed07bf7cd537b0271f9b6cf Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:04:42 +0900 Subject: [PATCH 081/247] Remove unused file --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 1 - src/CCVTAC.FSharp/Downloading/Uploader.fs | 29 ----------------------- 2 files changed, 30 deletions(-) delete mode 100644 src/CCVTAC.FSharp/Downloading/Uploader.fs diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 581a13be..719e8a3f 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -17,7 +17,6 @@ - diff --git a/src/CCVTAC.FSharp/Downloading/Uploader.fs b/src/CCVTAC.FSharp/Downloading/Uploader.fs deleted file mode 100644 index 1f7c22fc..00000000 --- a/src/CCVTAC.FSharp/Downloading/Uploader.fs +++ /dev/null @@ -1,29 +0,0 @@ -namespace CCVTAC.Console - -open CCVTAC.Console.ExternalTools -open CCVTAC.Console.Settings.Settings - -module Updater = - - type private Urls = - { Primary: string - Supplementary: string option } - - let internal run (settings: UserSettings) (printer: Printer) : Result = - if String.hasNoText settings.DownloaderUpdateCommand then - printer.Info("No downloader update command provided, so will skip.") - Ok() - else - let args = ToolSettings.create settings.DownloaderUpdateCommand settings.WorkingDirectory - - match Runner.run args [] printer with - | Ok (exitCode, warnings) -> - if exitCode <> 0 then - printer.Warning "Update completed with minor issues." - match warnings with - | Some w -> printer.Warning w - | None -> () - Ok() - | Error error -> - printer.Error($"Failure updating: %s{error}") - Error error From 336389b21439d20145de338a76f9e868ed5093c3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:06:48 +0900 Subject: [PATCH 082/247] Tweak Updater --- src/CCVTAC.FSharp/Downloading/Updater.fs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 189a48b2..a04e4bbd 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -4,23 +4,19 @@ open CCVTAC.Console.ExternalTools open CCVTAC.Console open CCVTAC.Console.Settings.Settings -/// Manages downloader updates module Updater = - /// Completes the actual download process. - /// A `Result` that, if successful, contains the name of the successfully downloaded format. - let internal run (settings: UserSettings) (printer: Printer) = - // Check if update command is provided - if String.hasNoText settings.DownloaderUpdateCommand then + let internal run userSettings (printer: Printer) : Result = + if String.hasNoText userSettings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() else - let settings = ToolSettings.create settings.DownloaderUpdateCommand settings.WorkingDirectory + let toolSettings = ToolSettings.create userSettings.DownloaderUpdateCommand userSettings.WorkingDirectory - match Runner.run settings [] printer with + match Runner.run toolSettings [] printer with | Ok (exitCode, warnings) -> if exitCode <> 0 then - printer.Warning("Update completed with minor issues.") + printer.Warning("Tool updated with minor issues.") match warnings with | Some w -> printer.Warning w @@ -28,8 +24,6 @@ module Updater = Ok() - | Error error -> - printer.Error $"Failure updating: {error}" - Error error - - + | Error err -> + printer.Error $"Failure updating: {err}" + Error err From 8193e1c628cf39df54afda42e3e2016ece02db08 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:18:05 +0900 Subject: [PATCH 083/247] Lowercase func names --- src/CCVTAC.FSharp/InputHelper.fs | 2 +- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 2 +- src/CCVTAC.FSharp/Settings/Id3Version.fs | 2 +- src/CCVTAC.FSharp/Shared.fs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index fd9565ee..3a5841a8 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -66,7 +66,7 @@ module InputHelper = | true, v -> v | _ -> 0 - let CountCategories (inputs: CategorizedInput seq) : CategoryCounts = + let countCategories (inputs: CategorizedInput seq) : CategoryCounts = let counts = inputs |> Seq.cast diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index f2edfd48..a15584de 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -15,7 +15,7 @@ module Directories = /// Counts the number of audio files in a directory let internal audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) |> Seq.length /// Returns the filenames in a given directory, optionally ignoring specific filenames diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index e3a17d46..4dce4da7 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -308,7 +308,7 @@ module Orchestrator = ) else let categorizedInputs = InputHelper.categorizeInputs splitInputs - let categoryCounts = InputHelper.CountCategories categorizedInputs + let categoryCounts = InputHelper.countCategories categorizedInputs summarizeInput categorizedInputs categoryCounts printer diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 36918319..94b351d2 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -150,7 +150,7 @@ module Mover = | Ok () -> let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 4642c774..b8810ed0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -69,7 +69,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension AudioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) |> List.ofSeq if List.isEmpty audioFiles then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 3f42737f..5ad2b128 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -33,7 +33,7 @@ module TaggingSets = let fileHasSupportedExtension (f: string) = match Path.GetExtension f with | Null -> false - | NonNull (x: string) -> Seq.caseInsensitiveContains x AudioExtensions + | NonNull (x: string) -> Seq.caseInsensitiveContains x audioExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match diff --git a/src/CCVTAC.FSharp/Settings/Id3Version.fs b/src/CCVTAC.FSharp/Settings/Id3Version.fs index cb62fa1d..2234912d 100644 --- a/src/CCVTAC.FSharp/Settings/Id3Version.fs +++ b/src/CCVTAC.FSharp/Settings/Id3Version.fs @@ -9,6 +9,6 @@ module TagFormat = | TwoPoint4 = 4 /// Locks the ID3v2.x version to a valid one and optionally forces that version. - let SetId3V2Version (version: Id3V2Version) (forceAsDefault: bool) : unit = + let setId3V2Version (version: Id3V2Version) (forceAsDefault: bool) : unit = TagLib.Id3v2.Tag.DefaultVersion <- byte version TagLib.Id3v2.Tag.ForceDefaultVersion <- forceAsDefault diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index b4332a77..092cd378 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -3,5 +3,5 @@ namespace CCVTAC.Console [] module Shared = - let AudioExtensions = + let audioExtensions = [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] From 52783e5a16fac69ec960c40cde08ac88cfb5840b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:35:12 +0900 Subject: [PATCH 084/247] Nomenclature --- .../DownloadEntityTests.fs | 10 +++++----- src/CCVTAC.FSharp/Downloading/Downloader.fs | 2 +- src/CCVTAC.FSharp/Downloading/Downloading.fs | 19 +++++++++---------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs b/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs index 84e62be1..fd11ec8b 100644 --- a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs +++ b/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs @@ -140,7 +140,7 @@ module DownloadUrlsTests = let ``Generates expected URL for video`` () = let video = Video "12312312312" let expectedUrl = ["https://www.youtube.com/watch?v=12312312312"] - let result = extractDownloadUrls video + let result = generateDownloadUrl video Assert.Equal(result.Length, 1) Assert.Equal(expectedUrl.Length, result.Length) Assert.Equal(expectedUrl.Head, result.Head) @@ -151,7 +151,7 @@ module DownloadUrlsTests = let videoUrl = "https://www.youtube.com/watch?v=12312312312" let playlistUrl = "https://www.youtube.com/playlist?list=OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg" let expectedUrls = [videoUrl; playlistUrl] - let result = extractDownloadUrls playlistVideo + let result = generateDownloadUrl playlistVideo Assert.Equal(result.Length, 2) Assert.Equal(expectedUrls.Length, result.Length) Assert.Equal(expectedUrls.Head, result.Head) @@ -161,7 +161,7 @@ module DownloadUrlsTests = let ``Generates expected URL for standard playlist`` () = let sPlaylist = StandardPlaylist "PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" let expectedUrls = ["https://www.youtube.com/playlist?list=PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L"] - let result = extractDownloadUrls sPlaylist + let result = generateDownloadUrl sPlaylist Assert.Equal(result.Length, 1) Assert.Equal(expectedUrls.Length, result.Length) Assert.Equal(expectedUrls.Head, result.Head) @@ -170,7 +170,7 @@ module DownloadUrlsTests = let ``Generates expected URL for release playlist`` () = let rPlaylist = ReleasePlaylist "OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" let expectedUrls = ["https://www.youtube.com/playlist?list=OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L"] - let result = extractDownloadUrls rPlaylist + let result = generateDownloadUrl rPlaylist Assert.Equal(result.Length, 1) Assert.Equal(expectedUrls.Length, result.Length) Assert.Equal(expectedUrls.Head, result.Head) @@ -179,7 +179,7 @@ module DownloadUrlsTests = let ``Generates expected URL for channel`` () = let channel = Channel "www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" let expectedUrls = ["https://www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg"] - let result = extractDownloadUrls channel + let result = generateDownloadUrl channel Assert.Equal(result.Length, 1) Assert.Equal(expectedUrls.Length, result.Length) Assert.Equal(expectedUrls.Head, result.Head) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index b1ac3211..0d6fd58a 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -73,7 +73,7 @@ module Downloader = if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") - let rawUrls = extractDownloadUrls(mediaType) + let rawUrls = generateDownloadUrl(mediaType) let urls = { Primary = rawUrls[0] Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } diff --git a/src/CCVTAC.FSharp/Downloading/Downloading.fs b/src/CCVTAC.FSharp/Downloading/Downloading.fs index 848e2d9c..8413e813 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloading.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloading.fs @@ -10,30 +10,29 @@ module public Downloading = | ReleasePlaylist of Id: string | Channel of Id: string - let private (|Regex|_|) pattern input = + let private (|RegexMatch|_|) pattern input = match Regex.Match(input, pattern) with | m when m.Success -> Some (List.tail [for g in m.Groups -> g.Value]) | _ -> None - let mediaTypeWithIds url = match url with - | Regex @"(?<=v=|v\=)([\w-]{11})(?:&list=([\w_-]+))" [videoId; playlistId] -> + | RegexMatch @"(?<=v=|v\=)([\w-]{11})(?:&list=([\w_-]+))" [videoId; playlistId] -> Ok (PlaylistVideo (videoId, playlistId)) - | Regex @"^([\w-]{11})$" [id] - | Regex @"(?<=v=|v\\=)([\w-]{11})" [id] - | Regex @"(?<=youtu\.be/)(.{11})" [id] -> + | RegexMatch @"^([\w-]{11})$" [id] + | RegexMatch @"(?<=v=|v\\=)([\w-]{11})" [id] + | RegexMatch @"(?<=youtu\.be/)(.{11})" [id] -> Ok (Video id) - | Regex @"(?<=list=)(P[\w\-]+)" [id] -> + | RegexMatch @"(?<=list=)(P[\w\-]+)" [id] -> Ok (StandardPlaylist id) - | Regex @"(?<=list=)(O[\w\-]+)" [id] -> + | RegexMatch @"(?<=list=)(O[\w\-]+)" [id] -> Ok (ReleasePlaylist id) - | Regex @"((?:www\.)?youtube\.com\/(?:channel\/|c\/|user\/|@)(?:[A-Za-z0-9\-@%\/]+))" [ id ] -> + | RegexMatch @"((?:www\.)?youtube\.com\/(?:channel\/|c\/|user\/|@)(?:[A-Za-z0-9\-@%\/]+))" [id] -> Ok (Channel id) | _ -> Error $"Unable to determine media type of URL \"{url}\". (Might it contain invalid characters?)" - let extractDownloadUrls mediaType = + let generateDownloadUrl mediaType = let fullUrl urlBase id = urlBase + id let videoUrl = fullUrl "https://www.youtube.com/watch?v=" let playlistUrl = fullUrl "https://www.youtube.com/playlist?list=" From 17d90c36e3857ea1871bec120f3bb3864ebcdcd1 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:52:48 +0900 Subject: [PATCH 085/247] Nomenclature, etc. --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 45 ++++++++++----------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 0d6fd58a..da8531e4 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -21,8 +21,8 @@ module Downloader = /// mediaType: Some MediaType for normal downloads, None for metadata-only supplementary downloads /// additionalArgs: optional extra args (e.g., the URL) let generateDownloadArgs audioFormat settings (mediaType: MediaType option) additionalArgs : string = - let writeJson = "--write-info-json" - let trimFileNames = "--trim-filenames 250" + let writeJsonArg = "--write-info-json" + let trimFileNamesArg = "--trim-filenames 250" let formatArg = match audioFormat with @@ -33,14 +33,13 @@ module Downloader = let args = match mediaType with | None -> - [ $"--flat-playlist {writeJson} {trimFileNames}" ] + [ $"--flat-playlist {writeJsonArg} {trimFileNamesArg}" ] | Some _ -> - [ "--extract-audio" - formatArg + [ $"--extract-audio {formatArg}" $"--audio-quality {settings.AudioQuality}" "--write-thumbnail --convert-thumbnails jpg" - writeJson - trimFileNames + writeJsonArg + trimFileNamesArg "--retries 2" ] |> Set.ofList @@ -61,19 +60,20 @@ module Downloader = |> ignore | None -> () - let extras = defaultArg additionalArgs [] |> Set.ofList - String.Join(" ", args |> Set.union extras) + let extraArgs = defaultArg additionalArgs [] |> Set.ofList + String.Join(" ", Set.union args extraArgs) let internal wrapUrlInMediaType url : Result = mediaTypeWithIds url /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. - let internal run (mediaType: MediaType) settings (printer: Printer) : Result = + let internal run (mediaType: MediaType) userSettings (printer: Printer) : Result = if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") let rawUrls = generateDownloadUrl(mediaType) + let urls = { Primary = rawUrls[0] Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } @@ -82,11 +82,11 @@ module Downloader = let mutable successfulFormat = String.Empty let mutable stopped = false - for format in settings.AudioFormats do + for format in userSettings.AudioFormats do if not stopped then - let args = generateDownloadArgs (Some format) settings (Some mediaType) (Some [urls.Primary]) + let args = generateDownloadArgs (Some format) userSettings (Some mediaType) (Some [urls.Primary]) let commandWithArgs = $"{programName} {args}" - let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory + let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory downloadResult <- Runner.run downloadSettings [1] printer @@ -106,12 +106,11 @@ module Downloader = let mutable errors = match downloadResult with Error e -> [e] | Ok _ -> [] - if audioFileCount settings.WorkingDirectory = 0 then - let combinedErrors = - errors - |> List.append ["No audio files were downloaded."] - |> String.concat String.newLine - Error combinedErrors + if audioFileCount userSettings.WorkingDirectory = 0 then + errors + |> List.append ["No audio files were downloaded."] + |> String.concat String.newLine + |> Error else // Continue to post-processing if errors. if List.isNotEmpty errors then @@ -121,16 +120,16 @@ module Downloader = // Attempt a metadata-only supplementary download. match urls.Supplementary with | Some supplementaryUrl -> - let args = generateDownloadArgs None settings None (Some [supplementaryUrl]) + let args = generateDownloadArgs None userSettings None (Some [supplementaryUrl]) let commandWithArgs = $"{programName} {args}" - let downloadSettings = ToolSettings.create commandWithArgs settings.WorkingDirectory + let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory let supplementaryDownloadResult = Runner.run downloadSettings [1] printer match supplementaryDownloadResult with | Ok _ -> - printer.Info("Supplementary metadata download completed OK.") + printer.Info "Supplementary metadata download completed OK." | Error err -> - printer.Error("Supplementary metadata download failed.") + printer.Error "Supplementary metadata download failed." errors <- List.append [err] errors | None -> () From 14669837e9e6b0fd7090686233c9f89e0d10cd69 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:15:19 +0900 Subject: [PATCH 086/247] Trivial tweaks --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index da8531e4..05d00842 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -104,11 +104,10 @@ module Downloader = | Error e -> printer.Debug $"Failure downloading \"%s{format}\" format: %s{e}" - let mutable errors = match downloadResult with Error e -> [e] | Ok _ -> [] + let mutable errors = match downloadResult with Error err -> [err] | Ok _ -> [] if audioFileCount userSettings.WorkingDirectory = 0 then - errors - |> List.append ["No audio files were downloaded."] + "No audio files were downloaded." :: errors |> String.concat String.newLine |> Error else From 3f7b1b830eb0f1cd1221eb731d07099785ea72c4 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:45:25 +0900 Subject: [PATCH 087/247] Remove duplicated code; convert methods to funcs; better null-handling; etc. --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 2 +- .../PostProcessing/MetadataUtilities.fs | 69 ++++++ src/CCVTAC.FSharp/PostProcessing/Mover.fs | 23 +- .../PostProcessing/Tagging/Detectors.fs | 5 +- .../PostProcessing/Tagging/TagDetector.fs | 1 + .../PostProcessing/Tagging/Tagger.fs | 14 +- .../PostProcessing/VideoMetadata.fs | 209 ++++++------------ .../YouTubeMetadataExtensionMethods.fs | 70 ------ 8 files changed, 161 insertions(+), 232 deletions(-) create mode 100644 src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs delete mode 100644 src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 719e8a3f..9faf79b8 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -21,7 +21,7 @@ - + diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs new file mode 100644 index 00000000..68a4cafa --- /dev/null +++ b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs @@ -0,0 +1,69 @@ +namespace CCVTAC.Console.PostProcessing + +open System +open System.Text +open CCVTAC.Console + +module MetadataUtilities = + + /// Returns a string summarizing video uploader information. + let private uploaderSummary (v: VideoMetadata) : string = + let uploaderLinkOrIdOrEmpty = + if String.hasText v.UploaderUrl then v.UploaderUrl + elif String.hasText v.UploaderId then v.UploaderId + else String.Empty + + let suffix = if String.hasText uploaderLinkOrIdOrEmpty then + $" (%s{uploaderLinkOrIdOrEmpty})" + else String.Empty + v.Uploader + suffix + + /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") + /// from the plain YYYYMMDD version (e.g., "20230827"). + let private formattedUploadDate (v: VideoMetadata) : string = + // Assumes UploadDate has at least 8 characters (YYYYMMDD) + let y = if String.IsNullOrEmpty v.UploadDate then String.Empty else v.UploadDate.[0..3] + let m = if v.UploadDate.Length >= 6 then v.UploadDate.[4..5] else String.Empty + let d = if v.UploadDate.Length >= 8 then v.UploadDate.[6..7] else String.Empty + sprintf "%s/%s/%s" m d y + + /// Returns a formatted comment using data parsed from the JSON file. + let generateComment (v: VideoMetadata) (maybeCollectionData: CollectionMetadata option) : string = + let sb = StringBuilder() + sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore + sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore + sb.AppendLine(sprintf "■ URL: %s" v.WebpageUrl) |> ignore + sb.AppendLine(sprintf "■ Title: %s" v.Fulltitle) |> ignore + sb.AppendLine(sprintf "■ Uploader: %s" (uploaderSummary v)) |> ignore + + if String.hasText v.Creator && v.Creator <> v.Uploader then + sb.AppendLine $"■ Creator: %s{v.Creator}" |> ignore + + if String.hasText v.Artist then + sb.AppendLine $"■ Artist: %s{v.Artist}" |> ignore + + if String.hasText v.Album then + sb.AppendLine $"■ Album: %s{v.Album}" |> ignore + + if String.hasText v.Title && v.Title <> v.Fulltitle then + sb.AppendLine $"■ Track Title: %s{v.Title}" |> ignore + + sb.AppendLine $"■ Uploaded: %s{formattedUploadDate v}" |> ignore + + let description = + if String.hasNoText v.Description then "None." else v.Description + + sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore + + match maybeCollectionData with + | Some collectionData -> + sb.AppendLine() |> ignore + sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore + sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore + match v.PlaylistIndex with + | NullV -> () + | NonNullV index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore + sb.AppendLine(sprintf "■ Playlist description: %s" (if String.hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore + | None -> () + + sb.ToString() diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 94b351d2..a8289c62 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -92,20 +92,15 @@ module Mover = let private getParsedVideoJson (taggingSet: TaggingSet) : Result = try - let json = File.ReadAllText(taggingSet.JsonFilePath) - - try - #nowarn 3265 - let videoData = JsonSerializer.Deserialize(json) - #warnon 3265 - - if isNull (box videoData) - then Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"" - else Ok videoData - with :? JsonException as ex -> - Error $"Error deserializing JSON from file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}" - with ex -> - Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}." + let json = File.ReadAllText taggingSet.JsonFilePath + match JsonSerializer.Deserialize json with + | Null -> Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"" + | NonNull v -> Ok v + with + | :? JsonException as exn -> + Error $"Error deserializing JSON from file \"%s{taggingSet.JsonFilePath}\": %s{exn.Message}" + | exn -> + Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{exn.Message}." let private getSafeSubDirectoryName (collectionData: CollectionMetadata option) taggingSet : string = let workingName = diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index 5e8adae8..19ef8e1b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -1,10 +1,9 @@ namespace CCVTAC.Console.PostProcessing.Tagging -open System -open System.Text.RegularExpressions -open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing +open System +open System.Text.RegularExpressions module Detectors = /// Attempts casting the input text to type T and returning it. diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs index ef26c5c9..08ee585f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -3,6 +3,7 @@ namespace CCVTAC.Console.PostProcessing.Tagging open System open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing +open CCVTAC.Console.PostProcessing.Tagging /// Provides methods to search for specific tag field data (artist, album, etc.) within video metadata. type TagDetector(tagDetectionPatterns: TagDetectionPatterns) = diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index b2519578..fc9280e0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -6,9 +6,11 @@ open System.Text.Json open CCVTAC.Console open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing +open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.Downloading.Downloading open Startwatch.Library open TaggingSets +open MetadataUtilities type TaggedFile = TagLib.File @@ -18,13 +20,9 @@ module Tagger = try let json = File.ReadAllText taggingSet.JsonFilePath try - #nowarn 3265 - let videoData = JsonSerializer.Deserialize(json) - #warnon 3265 - - // TODO: Make this more idiomatic. - if isNull (box videoData) then Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"" - else Ok videoData + match JsonSerializer.Deserialize json with + | Null -> Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"." + | NonNull v -> Ok v with | :? JsonException as ex -> Error $"%s{ex.Message}%s{String.newLine}%s{ex.StackTrace}" with ex -> @@ -154,7 +152,7 @@ module Tagger = taggedFile.Tag.Year <- year // Comment - taggedFile.Tag.Comment <- videoData.GenerateComment collectionData + taggedFile.Tag.Comment <- generateComment videoData collectionData // Artwork embedding match imageFilePath with diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index 34cd7d7f..c233bf22 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -2,141 +2,78 @@ namespace CCVTAC.Console.PostProcessing open System open System.Collections.Generic -open System.Text open System.Text.Json.Serialization -open CCVTAC.Console -[] -type VideoMetadata = - { [] Id: string - [] Title: string - [] Thumbnail: string - [] Description: string - [] ChannelId: string - [] ChannelUrl: string - [] Duration: Nullable - [] ViewCount: Nullable - [] AgeLimit: Nullable - [] WebpageUrl: string - [] Categories: IReadOnlyList - [] Tags: IReadOnlyList - [] PlayableInEmbed: Nullable - [] LiveStatus: string - [] ReleaseTimestamp: Nullable - [] FormatSortFields: IReadOnlyList - [] Album: string - [] Artist: string - [] Track: string - [] CommentCount: Nullable - [] LikeCount: Nullable - [] Channel: string - [] ChannelFollowerCount: Nullable - [] ChannelIsVerified: Nullable - [] Uploader: string - [] UploaderId: string - [] UploaderUrl: string - [] UploadDate: string - [] Creator: string - [] AltTitle: string - [] Availability: string - [] WebpageUrlBasename: string - [] WebpageUrlDomain: string - [] Extractor: string - [] ExtractorKey: string - [] PlaylistCount: Nullable - [] Playlist: string - [] PlaylistId: string - [] PlaylistTitle: string - [] NEntries: Nullable - [] PlaylistIndex: Nullable - [] DisplayId: string - [] Fulltitle: string - [] DurationString: string - [] ReleaseDate: string - [] ReleaseYear: Nullable - [] IsLive: Nullable - [] WasLive: Nullable - [] Epoch: Nullable - [] Asr: Nullable - [] Filesize: Nullable - [] FormatId: string - [] FormatNote: string - [] SourcePreference: Nullable - [] AudioChannels: Nullable - [] Quality: Nullable - [] HasDrm: Nullable - [] Tbr: Nullable - [] Url: string - [] LanguagePreference: Nullable - [] Ext: string - [] Vcodec: string - [] Acodec: string - [] Container: string - [] Protocol: string - [] Resolution: string - [] AudioExt: string - [] VideoExt: string - [] Vbr: Nullable - [] Abr: Nullable - [] Format: string - [] Type: string } - - /// Returns a string summarizing video uploader information. - member private this.UploaderSummary() : string = - let uploaderLinkOrIdOrEmpty = - if String.hasText this.UploaderUrl then this.UploaderUrl - elif String.hasText this.UploaderId then this.UploaderId - else String.Empty - - let suffix = if String.hasText uploaderLinkOrIdOrEmpty then $" (%s{uploaderLinkOrIdOrEmpty})" else String.Empty - this.Uploader + suffix - - /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") - /// from the plain YYYYMMDD version (e.g., "20230827"). - member private this.FormattedUploadDate() : string = - // Assumes UploadDate has at least 8 characters (YYYYMMDD) - let y = if String.IsNullOrEmpty this.UploadDate then "" else this.UploadDate[0..3] - let m = if this.UploadDate.Length >= 6 then this.UploadDate[4..5] else "" - let d = if this.UploadDate.Length >= 8 then this.UploadDate[6..7] else "" - sprintf "%s/%s/%s" m d y - - /// Returns a formatted comment using data parsed from the JSON file. - member this.GenerateComment(maybeCollectionData: CollectionMetadata option) : string = - let sb = StringBuilder() - sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore - sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore - sb.AppendLine(sprintf "■ URL: %s" this.WebpageUrl) |> ignore - sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore - sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - - if String.hasText this.Creator && this.Creator <> this.Uploader then - sb.AppendLine(sprintf "■ Creator: %s" this.Creator) |> ignore - - if String.hasText this.Artist then - sb.AppendLine(sprintf "■ Artist: %s" this.Artist) |> ignore - - if String.hasText this.Album then - sb.AppendLine(sprintf "■ Album: %s" this.Album) |> ignore - - if String.hasText this.Title && this.Title <> this.Fulltitle then - sb.AppendLine(sprintf "■ Track Title: %s" this.Title) |> ignore - - sb.AppendLine(sprintf "■ Uploaded: %s" (this.FormattedUploadDate())) |> ignore - - let description = - if String.hasNoText this.Description then "None." else this.Description - - sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore - - match maybeCollectionData with - | Some collectionData -> - sb.AppendLine() |> ignore - sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore - sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore - match this.PlaylistIndex with - | NonNullV (index: uint32) -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore - | NullV -> () - sb.AppendLine(sprintf "■ Playlist description: %s" (if String.hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore - | None -> () - - sb.ToString() +type VideoMetadata = { + [] Id: string + [] Title: string + [] Thumbnail: string + [] Description: string + [] ChannelId: string + [] ChannelUrl: string + [] Duration: Nullable + [] ViewCount: Nullable + [] AgeLimit: Nullable + [] WebpageUrl: string + [] Categories: IReadOnlyList + [] Tags: IReadOnlyList + [] PlayableInEmbed: Nullable + [] LiveStatus: string + [] ReleaseTimestamp: Nullable + [] FormatSortFields: IReadOnlyList + [] Album: string + [] Artist: string + [] Track: string + [] CommentCount: Nullable + [] LikeCount: Nullable + [] Channel: string + [] ChannelFollowerCount: Nullable + [] ChannelIsVerified: Nullable + [] Uploader: string + [] UploaderId: string + [] UploaderUrl: string + [] UploadDate: string + [] Creator: string + [] AltTitle: string + [] Availability: string + [] WebpageUrlBasename: string + [] WebpageUrlDomain: string + [] Extractor: string + [] ExtractorKey: string + [] PlaylistCount: Nullable + [] Playlist: string + [] PlaylistId: string + [] PlaylistTitle: string + [] NEntries: Nullable + [] PlaylistIndex: Nullable + [] DisplayId: string + [] Fulltitle: string + [] DurationString: string + [] ReleaseDate: string + [] ReleaseYear: Nullable + [] IsLive: Nullable + [] WasLive: Nullable + [] Epoch: Nullable + [] Asr: Nullable + [] Filesize: Nullable + [] FormatId: string + [] FormatNote: string + [] SourcePreference: Nullable + [] AudioChannels: Nullable + [] Quality: Nullable + [] HasDrm: Nullable + [] Tbr: Nullable + [] Url: string + [] LanguagePreference: Nullable + [] Ext: string + [] Vcodec: string + [] Acodec: string + [] Container: string + [] Protocol: string + [] Resolution: string + [] AudioExt: string + [] VideoExt: string + [] Vbr: Nullable + [] Abr: Nullable + [] Format: string + [] Type: string } diff --git a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs b/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs deleted file mode 100644 index 03dacb92..00000000 --- a/src/CCVTAC.FSharp/PostProcessing/YouTubeMetadataExtensionMethods.fs +++ /dev/null @@ -1,70 +0,0 @@ -namespace CCVTAC.Console.PostProcessing - -open System -open System.Text -open CCVTAC.Console - -module YouTubeMetadataExtensionMethods = - type VideoMetadata with - - /// Returns a string summarizing video uploader information. - member private this.UploaderSummary() : string = - let uploaderLinkOrIdOrEmpty = - if String.hasText this.UploaderUrl then this.UploaderUrl - elif String.hasText this.UploaderId then this.UploaderId - else String.Empty - - let suffix = if String.hasText uploaderLinkOrIdOrEmpty then - $" (%s{uploaderLinkOrIdOrEmpty})" - else String.Empty - this.Uploader + suffix - - /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") - /// from the plain YYYYMMDD version (e.g., "20230827"). - member private this.FormattedUploadDate() : string = - // Assumes UploadDate has at least 8 characters (YYYYMMDD) - let y = if String.IsNullOrEmpty this.UploadDate then "" else this.UploadDate.[0..3] - let m = if this.UploadDate.Length >= 6 then this.UploadDate.[4..5] else "" - let d = if this.UploadDate.Length >= 8 then this.UploadDate.[6..7] else "" - sprintf "%s/%s/%s" m d y - - /// Returns a formatted comment using data parsed from the JSON file. - member this.GenerateComment(maybeCollectionData: CollectionMetadata option) : string = - let sb = StringBuilder() - sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore - sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore - sb.AppendLine(sprintf "■ URL: %s" this.WebpageUrl) |> ignore - sb.AppendLine(sprintf "■ Title: %s" this.Fulltitle) |> ignore - sb.AppendLine(sprintf "■ Uploader: %s" (this.UploaderSummary())) |> ignore - - if String.hasText this.Creator && this.Creator <> this.Uploader then - sb.AppendLine $"■ Creator: %s{this.Creator}" |> ignore - - if String.hasText this.Artist then - sb.AppendLine $"■ Artist: %s{this.Artist}" |> ignore - - if String.hasText this.Album then - sb.AppendLine $"■ Album: %s{this.Album}" |> ignore - - if String.hasText this.Title && this.Title <> this.Fulltitle then - sb.AppendLine $"■ Track Title: %s{this.Title}" |> ignore - - sb.AppendLine $"■ Uploaded: %s{this.FormattedUploadDate()}" |> ignore - - let description = - if String.hasNoText this.Description then "None." else this.Description - - sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore - - match maybeCollectionData with - | Some collectionData -> - sb.AppendLine() |> ignore - sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore - sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore - match this.PlaylistIndex with - | NullV -> () - | NonNullV index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore - sb.AppendLine(sprintf "■ Playlist description: %s" (if String.hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore - | None -> () - - sb.ToString() From e3ab5def0bc3c4787e134ccaeaede595ba1c5286 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:49:30 +0900 Subject: [PATCH 088/247] Simply func with List.tryFind --- .../PostProcessing/MetadataUtilities.fs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs index 68a4cafa..903a193d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs +++ b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs @@ -8,14 +8,10 @@ module MetadataUtilities = /// Returns a string summarizing video uploader information. let private uploaderSummary (v: VideoMetadata) : string = - let uploaderLinkOrIdOrEmpty = - if String.hasText v.UploaderUrl then v.UploaderUrl - elif String.hasText v.UploaderId then v.UploaderId - else String.Empty - - let suffix = if String.hasText uploaderLinkOrIdOrEmpty then - $" (%s{uploaderLinkOrIdOrEmpty})" - else String.Empty + let suffix = + match List.tryFind String.hasText [v.UploaderUrl; v.UploaderId] with + | Some x -> $" (%s{x})" + | None -> String.Empty v.Uploader + suffix /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") From c260175a171c47aebbb8c4b63a692d261b17d8bd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:33:03 +0900 Subject: [PATCH 089/247] Improve formattedUploadDate func --- .../PostProcessing/MetadataUtilities.fs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs index 903a193d..e7b60bb5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs +++ b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs @@ -14,13 +14,10 @@ module MetadataUtilities = | None -> String.Empty v.Uploader + suffix - /// Returns a formatted MM/DD/YYYY version of the upload date (e.g., "08/27/2023") - /// from the plain YYYYMMDD version (e.g., "20230827"). - let private formattedUploadDate (v: VideoMetadata) : string = - // Assumes UploadDate has at least 8 characters (YYYYMMDD) - let y = if String.IsNullOrEmpty v.UploadDate then String.Empty else v.UploadDate.[0..3] - let m = if v.UploadDate.Length >= 6 then v.UploadDate.[4..5] else String.Empty - let d = if v.UploadDate.Length >= 8 then v.UploadDate.[6..7] else String.Empty + let private formattedUploadDate (dateText: string) : string = + let y = dateText[0..3] + let m = dateText[4..5] + let d = dateText[6..7] sprintf "%s/%s/%s" m d y /// Returns a formatted comment using data parsed from the JSON file. @@ -44,7 +41,8 @@ module MetadataUtilities = if String.hasText v.Title && v.Title <> v.Fulltitle then sb.AppendLine $"■ Track Title: %s{v.Title}" |> ignore - sb.AppendLine $"■ Uploaded: %s{formattedUploadDate v}" |> ignore + if v.UploadDate.Length = 8 then + sb.AppendLine $"■ Uploaded: %s{formattedUploadDate v.UploadDate}" |> ignore let description = if String.hasNoText v.Description then "None." else v.Description From 64c432da4059080ad59a84c7be07c0f5358a681d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:49:56 +0900 Subject: [PATCH 090/247] Add new String module funcs --- src/CCVTAC.FSharp/Utilities.fs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 1069d54f..8586356b 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -19,6 +19,12 @@ module String = let allHaveText xs = xs |> List.forall hasText + let textOrFallback fallback text = + if hasText text then text else fallback + + let textOrEmpty text = + textOrFallback text String.Empty + let equalIgnoringCase x y = String.Equals(x, y, StringComparison.OrdinalIgnoreCase) From 6f79e2d99eb3935d0eda2ccf35af5fdb52c84551 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:50:06 +0900 Subject: [PATCH 091/247] Clean up metabase funcs --- .../PostProcessing/MetadataUtilities.fs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs index e7b60bb5..c262c5b5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs +++ b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs @@ -6,7 +6,6 @@ open CCVTAC.Console module MetadataUtilities = - /// Returns a string summarizing video uploader information. let private uploaderSummary (v: VideoMetadata) : string = let suffix = match List.tryFind String.hasText [v.UploaderUrl; v.UploaderId] with @@ -20,14 +19,13 @@ module MetadataUtilities = let d = dateText[6..7] sprintf "%s/%s/%s" m d y - /// Returns a formatted comment using data parsed from the JSON file. - let generateComment (v: VideoMetadata) (maybeCollectionData: CollectionMetadata option) : string = + let generateComment (v: VideoMetadata) (c: CollectionMetadata option) : string = let sb = StringBuilder() sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore - sb.AppendLine(sprintf "■ Downloaded: %O" DateTime.Now) |> ignore - sb.AppendLine(sprintf "■ URL: %s" v.WebpageUrl) |> ignore - sb.AppendLine(sprintf "■ Title: %s" v.Fulltitle) |> ignore - sb.AppendLine(sprintf "■ Uploader: %s" (uploaderSummary v)) |> ignore + sb.AppendLine $"■ Downloaded: {DateTime.Now}" |> ignore + sb.AppendLine $"■ URL: %s{v.WebpageUrl}" |> ignore + sb.AppendLine $"■ Title: %s{v.Fulltitle}" |> ignore + sb.AppendLine $"■ Uploader: %s{uploaderSummary v}" |> ignore if String.hasText v.Creator && v.Creator <> v.Uploader then sb.AppendLine $"■ Creator: %s{v.Creator}" |> ignore @@ -44,20 +42,18 @@ module MetadataUtilities = if v.UploadDate.Length = 8 then sb.AppendLine $"■ Uploaded: %s{formattedUploadDate v.UploadDate}" |> ignore - let description = - if String.hasNoText v.Description then "None." else v.Description + let description = String.textOrFallback "None." v.Description + sb.AppendLine $"■ Video description: %s{description}" |> ignore - sb.AppendLine(sprintf "■ Video description: %s" description) |> ignore - - match maybeCollectionData with - | Some collectionData -> + match c with + | Some c' -> sb.AppendLine() |> ignore - sb.AppendLine(sprintf "■ Playlist name: %s" collectionData.Title) |> ignore - sb.AppendLine(sprintf "■ Playlist URL: %s" collectionData.WebpageUrl) |> ignore + sb.AppendLine $"■ Playlist name: %s{c'.Title}" |> ignore + sb.AppendLine $"■ Playlist URL: %s{c'.WebpageUrl}" |> ignore match v.PlaylistIndex with - | NullV -> () - | NonNullV index -> if index > 0u then sb.AppendLine(sprintf "■ Playlist index: %d" index) |> ignore - sb.AppendLine(sprintf "■ Playlist description: %s" (if String.hasNoText collectionData.Description then String.Empty else collectionData.Description)) |> ignore + | NonNullV index -> if index > 0u then sb.AppendLine $"■ Playlist index: %d{index}" |> ignore + | NullV -> () + sb.AppendLine($"■ Playlist description: %s{String.textOrEmpty c'.Description}") |> ignore | None -> () sb.ToString() From 68017165954c858bc64a0f6cd85b65cbe9b0ed24 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:04:30 +0900 Subject: [PATCH 092/247] Convert TagDetector from type with members to module --- .../PostProcessing/Tagging/TagDetector.fs | 56 ++++++++++++------- .../PostProcessing/Tagging/Tagger.fs | 14 ++--- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs index 08ee585f..40875b6a 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -1,53 +1,69 @@ namespace CCVTAC.Console.PostProcessing.Tagging -open System open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing open CCVTAC.Console.PostProcessing.Tagging +open System -/// Provides methods to search for specific tag field data (artist, album, etc.) within video metadata. -type TagDetector(tagDetectionPatterns: TagDetectionPatterns) = - /// Detection patterns for various metadata fields - member private _.Patterns = tagDetectionPatterns +module TagDetection = + + let detectTitle + (videoData: VideoMetadata) + (defaultTitle: string option) + (tagDetectionPatterns: TagDetectionPatterns) + : string option = - /// Detects the title from video metadata - member this.DetectTitle(videoData: VideoMetadata, ?defaultTitle: string) : string option = let detectedTitle = - Detectors.detectSingle videoData this.Patterns.Title None + Detectors.detectSingle videoData tagDetectionPatterns.Title None match detectedTitle, defaultTitle with | Some title, _ -> Some title | None, Some defaultVal -> Some defaultVal | None, None -> None - /// Detects the artist from video metadata - member this.DetectArtist(videoData: VideoMetadata, ?defaultArtist: string) : string option = + let detectArtist + (videoData: VideoMetadata) + (defaultArtist: string option) + (tagDetectionPatterns: TagDetectionPatterns) + : string option = + let detectedArtist = - Detectors.detectSingle videoData this.Patterns.Artist None + Detectors.detectSingle videoData tagDetectionPatterns.Artist None match detectedArtist, defaultArtist with | Some artist, _ -> Some artist | None, Some defaultVal -> Some defaultVal | None, None -> None - /// Detects the album from video metadata - member this.DetectAlbum(videoData: VideoMetadata, defaultAlbum: string option) : string option = + let detectAlbum + (videoData: VideoMetadata) + (defaultAlbum: string option) + (tagDetectionPatterns: TagDetectionPatterns) + : string option = + let detectedAlbum = - Detectors.detectSingle videoData this.Patterns.Album None + Detectors.detectSingle videoData tagDetectionPatterns.Album None match detectedAlbum, defaultAlbum with | Some album, _ -> Some album | None, Some defaultVal -> Some defaultVal | None, None -> None - /// Detects composers from video metadata - member this.DetectComposers(videoData: VideoMetadata) : string option = - Detectors.detectMultiple videoData this.Patterns.Composer (String.Empty) "; " |> Some + let detectComposers + (videoData: VideoMetadata) + (tagDetectionPatterns: TagDetectionPatterns) + : string option = + + Detectors.detectMultiple videoData tagDetectionPatterns.Composer String.Empty "; " |> Some + + let detectReleaseYear + (videoData: VideoMetadata) + (defaultYear: uint32 option) + (tagDetectionPatterns: TagDetectionPatterns) + : uint32 option = - /// Detects the release year from video metadata - member this.DetectReleaseYear(videoData: VideoMetadata, defaultYear: uint32 option) : uint32 option = let detectedYear = - Detectors.detectSingle videoData this.Patterns.Year None + Detectors.detectSingle videoData tagDetectionPatterns.Year None match detectedYear, defaultYear with | Some year, _ -> Some year diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index fc9280e0..e7ccf75b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -82,14 +82,14 @@ module Tagger = printer.Debug $"Current audio file: \"%s{audioFileName}\"" use taggedFile = TaggedFile.Create audioFilePath - let tagDetector = TagDetector settings.TagDetectionPatterns + let patterns = settings.TagDetectionPatterns // Title if String.hasText videoData.Track then printer.Debug $"• Using metadata title \"%s{videoData.Track}\"" taggedFile.Tag.Title <- videoData.Track else - match tagDetector.DetectTitle(videoData, videoData.Title) with + match TagDetection.detectTitle videoData (Some videoData.Title) patterns with | Some title -> printer.Debug $"• Found title \"%s{title}\"" taggedFile.Tag.Title <- title @@ -106,7 +106,7 @@ module Tagger = taggedFile.Tag.Performers <- [| firstArtist |] printer.Debug $"• Using metadata artist \"%s{firstArtist}\"%s{diffSummary}" else - match tagDetector.DetectArtist videoData with + match TagDetection.detectArtist videoData None patterns with | None -> () | Some artist -> printer.Debug $"• Found artist \"%s{artist}\"" @@ -118,14 +118,14 @@ module Tagger = taggedFile.Tag.Album <- videoData.Album else let collectionTitle = collectionData |> Option.map _.Title - match tagDetector.DetectAlbum(videoData, collectionTitle) with + match TagDetection.detectAlbum videoData collectionTitle patterns with | None -> () | Some album -> printer.Debug $"• Found album \"%s{album}\"" taggedFile.Tag.Album <- album // Composers - match tagDetector.DetectComposers videoData with + match TagDetection.detectComposers videoData patterns with | None -> () | Some composers -> printer.Debug $"• Found composer(s) \"%s{composers}\"" @@ -144,8 +144,8 @@ module Tagger = printer.Debug $"• Using metadata release year \"%d{year}\"" taggedFile.Tag.Year <- year | NullV -> - let maybeDefaultYear = releaseYear settings videoData - match tagDetector.DetectReleaseYear(videoData, maybeDefaultYear) with + let defaultYear = releaseYear settings videoData + match TagDetection.detectReleaseYear videoData defaultYear patterns with | None -> () | Some year -> printer.Debug $"• Found year \"%d{year}\"" From 59b8d53c0944b90c4ef53acc4709e63d26b9e6d9 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:14:22 +0900 Subject: [PATCH 093/247] Deletion cleanup --- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 36 ++++++------------- .../PostProcessing/PostProcessing.fs | 2 +- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index aedd2c5a..f5efd079 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -1,30 +1,22 @@ namespace CCVTAC.Console.PostProcessing -open System -open System.IO open CCVTAC.Console +open System.IO module Deleter = - /// Retrieves collection files based on collection metadata let private getCollectionFiles (collectionMetadata: CollectionMetadata option) (workingDirectory: string) - : Result = + : Result = match collectionMetadata with | None -> Ok [||] | Some metadata -> - try - let files = - Directory.GetFiles(workingDirectory, $"*{metadata.Id}*") - - Ok files - with - | ex -> Error $"Error collecting filenames: {ex.Message}" + try Ok (Directory.GetFiles(workingDirectory, $"*{metadata.Id}*")) + with ex -> Error $"Error collecting filenames: {ex.Message}" - /// Deletes all specified files let private deleteAll - (fileNames: string[]) + (fileNames: string array) (printer: Printer) : unit = @@ -37,7 +29,6 @@ module Deleter = | ex -> printer.Error($"• Deletion error: {ex.Message}") ) - /// Runs the deletion process for temporary files let internal run (taggingSetFileNames: string seq) (collectionMetadata: CollectionMetadata option) @@ -45,25 +36,20 @@ module Deleter = (printer: Printer) : unit = - // Get collection files let collectionFileNames = match getCollectionFiles collectionMetadata workingDirectory with | Ok files -> - printer.Debug($"Found {files.Length} collection files.") + printer.Debug $"Found {files.Length} collection files." files | Error err -> printer.Warning err [||] - // Combine all file names - let allFileNames = - Seq.concat [taggingSetFileNames; collectionFileNames] - |> Seq.toArray + let allFileNames = Seq.concat [taggingSetFileNames; collectionFileNames] |> Seq.toArray - // Check if any files to delete - if allFileNames.Length = 0 then - printer.Warning("No files to delete were found.") + if Array.isEmpty allFileNames then + printer.Warning "No files to delete were found." else - printer.Debug($"Deleting {allFileNames.Length} temporary files...") + printer.Debug $"Deleting {allFileNames.Length} temporary files..." deleteAll allFileNames printer - printer.Info("Deleted temporary files.") + printer.Info "Deleted temporary files." diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 167b9993..56ab395e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -80,7 +80,7 @@ module PostProcessor = Renamer.run settings workingDirectory printer Mover.run taggingSets collectionJson settings true printer - let allTaggingSetFiles = taggingSets |> Seq.collect allFiles + let allTaggingSetFiles = taggingSets |> List.collect allFiles Deleter.run allTaggingSetFiles collectionJson workingDirectory printer match Directories.warnIfAnyFiles workingDirectory 20 with From b31154c45b08c2b8f8222fe67e1ddcb42134af8a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:27:24 +0900 Subject: [PATCH 094/247] Clean up deserialization --- .../PostProcessing/CollectionMetadata.fs | 1 - .../PostProcessing/PostProcessing.fs | 19 +++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs index 15de16e8..ae3def33 100644 --- a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs @@ -4,7 +4,6 @@ open System open System.Collections.Generic open System.Text.Json.Serialization -[] type CollectionMetadata = { [] Id: string [] Title: string diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 56ab395e..e2efd451 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -33,15 +33,11 @@ module PostProcessor = else let fileName = fileNames.Single() let json = File.ReadAllText fileName - #nowarn 3265 - let collectionData = JsonSerializer.Deserialize(json) - #warnon 3265 - if isNull (box collectionData) then - Error $"Deserialized collection metadata for \"%s{fileName}\" was null." - else - Ok collectionData - with ex -> - Error ex.Message + match JsonSerializer.Deserialize json with + | Null -> Error $"Deserialized collection metadata for \"%s{fileName}\" was null." + | NonNull collectionData -> Ok collectionData + with + | ex -> Error ex.Message let private generateTaggingSets directoryName : Result = try @@ -70,9 +66,8 @@ module PostProcessor = printer.Debug "Found playlist/channel metadata." Some cm - if settings.EmbedImages - then ImageProcessor.run workingDirectory printer - else () + if settings.EmbedImages then + ImageProcessor.run workingDirectory printer match Tagger.run settings taggingSets collectionJson mediaType printer with | Ok msg -> From 301b147c84080cee09be233ce38cafbdc0fa0d9a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:57:08 +0900 Subject: [PATCH 095/247] Use type for external tool results --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 9 +++++---- src/CCVTAC.FSharp/Downloading/Updater.fs | 6 +++--- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 8 +++++--- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 20 ++++++++++--------- .../PostProcessing/Tagging/Tagger.fs | 2 +- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 05d00842..46063867 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -1,6 +1,7 @@ namespace CCVTAC.Console.Downloading open CCVTAC.Console +open CCVTAC.Console.ExternalTools.Runner open CCVTAC.Console.IoUtilities.Directories open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.ExternalTools @@ -78,7 +79,7 @@ module Downloader = { Primary = rawUrls[0] Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } - let mutable downloadResult : Result = Error String.Empty + let mutable downloadResult : Result = Error String.Empty let mutable successfulFormat = String.Empty let mutable stopped = false @@ -91,12 +92,12 @@ module Downloader = downloadResult <- Runner.run downloadSettings [1] printer match downloadResult with - | Ok (exitCode, warning) -> + | Ok result -> successfulFormat <- format - if exitCode <> 0 then + if result.ExitCode <> 0 then printer.Warning "Downloading completed with minor issues." - match warning with + match result.Error with | Some w -> printer.Warning w | None -> () diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index a04e4bbd..7b886d0f 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -14,11 +14,11 @@ module Updater = let toolSettings = ToolSettings.create userSettings.DownloaderUpdateCommand userSettings.WorkingDirectory match Runner.run toolSettings [] printer with - | Ok (exitCode, warnings) -> - if exitCode <> 0 then + | Ok result -> + if result.ExitCode <> 0 then printer.Warning("Tool updated with minor issues.") - match warnings with + match result.Error with | Some w -> printer.Warning w | None -> () diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 11f80ab6..d5446c9c 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -7,6 +7,8 @@ open System.Diagnostics module Runner = + type ToolResult = { ExitCode: int; Error: string option } + [] let private authenticSuccessExitCode = 0 @@ -19,7 +21,7 @@ module Runner = /// Printer for logging /// A Result instance containing the exit code and any warnings or else an error message. let internal run toolSettings (otherSuccessExitCodes: int list) (printer: Printer) - : Result = + : Result = let watch = Watch() printer.Info $"Running {toolSettings.CommandWithArgs}..." @@ -43,12 +45,12 @@ module Runner = let error = process'.StandardError.ReadToEnd() process'.WaitForExit() - printer.Info($"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}.") + printer.Info $"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}." let trimmedErrors = if String.hasText error then Some (String.trimTerminalLineBreak error) else None if isSuccessExitCode otherSuccessExitCodes process'.ExitCode - then Ok (process'.ExitCode, trimmedErrors) + then Ok { ExitCode = process'.ExitCode; Error = trimmedErrors } else Error $"{splitCommandWithArgs[0]} exited with code {process'.ExitCode}: {trimmedErrors}." diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index a8289c62..37860008 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -25,23 +25,25 @@ module Mover = if images.Length = 0 then None else let playlistImages = images |> Array.filter (fun i -> isPlaylistImage i.FullName) |> Array.toList - if playlistImages.Any() then Some (playlistImages.First()) - else if audioFileCount > 1 && images.Length = 1 then Some images[0] + if playlistImages.Any() + then Some (playlistImages[0]) + elif audioFileCount > 1 && images.Length = 1 + then Some images[0] else None let private ensureDirectoryExists (moveToDir: string) (printer: Printer) : Result = try if Directory.Exists moveToDir then - printer.Debug $"Found move-to directory \"%s{moveToDir}\"." - Ok () + Ok <| printer.Debug $"Found move-to directory \"%s{moveToDir}\"." else - printer.Debug ($"Creating move-to directory \"%s{moveToDir}\" (based on playlist metadata)... ", appendLineBreak = false) + printer.Debug ($"Creating move-to directory \"%s{moveToDir}\" (based on playlist metadata)... ", + appendLineBreak = false) Directory.CreateDirectory moveToDir |> ignore - printer.Debug "OK." - Ok () + Ok <| printer.Debug "OK." with ex -> - printer.Error $"Error creating move-to directory \"%s{moveToDir}\": %s{ex.Message}" - Error String.Empty // TODO: Update. + let errMsg = $"Error creating move-to directory \"%s{moveToDir}\": %s{ex.Message}" + printer.Error errMsg + Error errMsg let private moveAudioFiles (audioFiles: FileInfo list) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index e7ccf75b..57b1d6a0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -62,7 +62,7 @@ module Tagger = let private releaseYear userSettings videoMetadata : uint32 option = if userSettings.IgnoreUploadYearUploaders |> Array.caseInsensitiveContains videoMetadata.Uploader then None - else if videoMetadata.UploadDate.Length <> 4 + elif videoMetadata.UploadDate.Length <> 4 then None else match UInt32.TryParse(videoMetadata.UploadDate.Substring(0, 4)) with From a84275a473906b13031097a4915dd87a229c4b93 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:41:54 +0900 Subject: [PATCH 096/247] Update tag data discovery and casting --- .../PostProcessing/Tagging/Detectors.fs | 39 ++++++-------- .../PostProcessing/Tagging/TagDetector.fs | 54 +++++-------------- 2 files changed, 30 insertions(+), 63 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index 19ef8e1b..03bf7b4b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -6,22 +6,17 @@ open System open System.Text.RegularExpressions module Detectors = - /// Attempts casting the input text to type T and returning it. + /// Attempts casting the input text to type 'a and returning it. /// If casting fails, the default value is returned instead. - let private cast<'a> (text: string option) (defaultValue: 'a) : 'a = - match text with - | None -> defaultValue - | Some textValue -> - try - // If T is string, return the text directly - if typeof<'a> = typeof then - box textValue :?> 'a - // Some textValue - else - // Try to convert to the target type - Convert.ChangeType(textValue, typeof<'a>) :?> 'a - with - | _ -> defaultValue + let private cast<'a> (text: string) (defaultValue: 'a) : 'a = + try + // If T is string, return the text directly + if typeof<'a> = typeof then + box text :?> 'a + else + Convert.ChangeType(text, typeof<'a>) :?> 'a + with + | _ -> defaultValue /// Extracts the value of the specified tag field from the given data. /// Video metadata @@ -41,21 +36,19 @@ module Detectors = let internal detectSingle<'a> (videoMetadata: VideoMetadata) (patterns: TagDetectionPattern seq) - (defaultValue: 'a option) = + (defaultValue: 'a option) + : 'a option = patterns |> Seq.tryPick (fun pattern -> let fieldText = extractMetadataText videoMetadata pattern.SearchField let match' = Regex(pattern.RegexPattern).Match(fieldText) - if not match'.Success - then + if not match'.Success then None else let matchedText = match'.Groups[pattern.MatchGroup].Value.Trim() - - cast (Some matchedText) (Some defaultValue) // TODO: Check why 2nd `Some` is needed. - ) + cast matchedText None) |> Option.defaultValue defaultValue /// Finds and returns all instances of text matching a given detection scheme pattern, @@ -67,7 +60,7 @@ module Detectors = (patterns: TagDetectionPattern seq) (defaultValue: 'a) (separator: string) - = + : 'a = let matchedValues = patterns @@ -84,4 +77,4 @@ module Detectors = defaultValue else let joinedMatchedText = String.Join(separator, matchedValues) - cast<'a> (Some joinedMatchedText) defaultValue + cast<'a> joinedMatchedText defaultValue diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs index 40875b6a..f8c4f1b1 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -1,71 +1,45 @@ namespace CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.PostProcessing open CCVTAC.Console.PostProcessing.Tagging -open System module TagDetection = - let detectTitle - (videoData: VideoMetadata) - (defaultTitle: string option) - (tagDetectionPatterns: TagDetectionPatterns) - : string option = - + let detectTitle videoData fallback (tagDetectionPatterns: TagDetectionPatterns) : string option = let detectedTitle = Detectors.detectSingle videoData tagDetectionPatterns.Title None - match detectedTitle, defaultTitle with + match detectedTitle, fallback with | Some title, _ -> Some title - | None, Some defaultVal -> Some defaultVal + | None, Some title -> Some title | None, None -> None - let detectArtist - (videoData: VideoMetadata) - (defaultArtist: string option) - (tagDetectionPatterns: TagDetectionPatterns) - : string option = - + let detectArtist videoData fallback (tagDetectionPatterns: TagDetectionPatterns) : string option = let detectedArtist = Detectors.detectSingle videoData tagDetectionPatterns.Artist None - match detectedArtist, defaultArtist with + match detectedArtist, fallback with | Some artist, _ -> Some artist - | None, Some defaultVal -> Some defaultVal + | None, Some artist -> Some artist | None, None -> None - let detectAlbum - (videoData: VideoMetadata) - (defaultAlbum: string option) - (tagDetectionPatterns: TagDetectionPatterns) - : string option = - + let detectAlbum videoData fallback (tagDetectionPatterns: TagDetectionPatterns) : string option = let detectedAlbum = Detectors.detectSingle videoData tagDetectionPatterns.Album None - match detectedAlbum, defaultAlbum with + match detectedAlbum, fallback with | Some album, _ -> Some album - | None, Some defaultVal -> Some defaultVal + | None, Some album -> Some album | None, None -> None - let detectComposers - (videoData: VideoMetadata) - (tagDetectionPatterns: TagDetectionPatterns) - : string option = - - Detectors.detectMultiple videoData tagDetectionPatterns.Composer String.Empty "; " |> Some - - let detectReleaseYear - (videoData: VideoMetadata) - (defaultYear: uint32 option) - (tagDetectionPatterns: TagDetectionPatterns) - : uint32 option = + let detectComposers videoData (tagDetectionPatterns: TagDetectionPatterns) : string option = + Detectors.detectMultiple videoData tagDetectionPatterns.Composer None "; " + let detectReleaseYear videoData fallback (tagDetectionPatterns: TagDetectionPatterns) : uint32 option = let detectedYear = Detectors.detectSingle videoData tagDetectionPatterns.Year None - match detectedYear, defaultYear with + match detectedYear, fallback with | Some year, _ -> Some year - | None, Some defaultVal -> Some defaultVal + | None, Some year -> Some year | None, None -> None From 2d5a680fc1b31b79b7fc6d35f67e4ffd1b91f3c5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:36:50 +0900 Subject: [PATCH 097/247] Fix type casting during metadata detection; add test --- .../CCVTAC.FSharp.Tests.fsproj | 1 + .../DownloadEntityTests.fs | 1 - src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 110 ++++++++++++++++++ .../PostProcessing/Tagging/Detectors.fs | 37 +++--- .../PostProcessing/Tagging/TagDetector.fs | 2 +- 5 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 src/CCVTAC.FSharp.Tests/TagDetectionTests.fs diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj index bb09d563..e08a8441 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj @@ -8,6 +8,7 @@ + diff --git a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs b/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs index fd11ec8b..44a4480e 100644 --- a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs +++ b/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs @@ -1,7 +1,6 @@ module DownloadEntityTests open Xunit -open CCVTAC.Console.Downloading open CCVTAC.Console.Downloading.Downloading module MediaTypeWithIdsTests = diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs new file mode 100644 index 00000000..6c07c092 --- /dev/null +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -0,0 +1,110 @@ +// module CCVTAC.FSharp.Tests.TaggingTests +module TagDetectionTests + +open Xunit +open CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.Console.PostProcessing +open CCVTAC.Console.Settings.Settings +open System + +let emptyVideoMetadata = { + Id = String.Empty + Title = String.Empty + Thumbnail = String.Empty + Description = String.Empty + ChannelId = String.Empty + ChannelUrl = String.Empty + Duration = System.Nullable 0 + ViewCount = System.Nullable 0 + AgeLimit = System.Nullable 0 + WebpageUrl = String.Empty + Categories = [] + Tags = [] + PlayableInEmbed = Nullable false + LiveStatus = String.Empty + ReleaseTimestamp = System.Nullable 0 + FormatSortFields = [] + Album = String.Empty + Artist = String.Empty + Track = String.Empty + CommentCount = System.Nullable 0 + LikeCount = System.Nullable 0 + Channel = String.Empty + ChannelFollowerCount = System.Nullable 0 + ChannelIsVerified = Nullable false + Uploader = String.Empty + UploaderId = String.Empty + UploaderUrl = String.Empty + UploadDate = String.Empty + Creator = String.Empty + AltTitle = String.Empty + Availability = String.Empty + WebpageUrlBasename = String.Empty + WebpageUrlDomain = String.Empty + Extractor = String.Empty + ExtractorKey = String.Empty + PlaylistCount = System.Nullable 0 + Playlist = String.Empty + PlaylistId = String.Empty + PlaylistTitle = String.Empty + NEntries = System.Nullable 0 + PlaylistIndex = Nullable 0u + DisplayId = String.Empty + Fulltitle = String.Empty + DurationString = String.Empty + ReleaseDate = String.Empty + ReleaseYear = Nullable 0u + IsLive = Nullable false + WasLive = Nullable false + Epoch = System.Nullable 0 + Asr = System.Nullable 0 + Filesize = System.Nullable 0 + FormatId = String.Empty + FormatNote = String.Empty + SourcePreference = System.Nullable 0 + AudioChannels = System.Nullable 0 + Quality = Nullable 0 + HasDrm = Nullable false + Tbr = Nullable 0 + Url = String.Empty + LanguagePreference = System.Nullable 0 + Ext = String.Empty + Vcodec = String.Empty + Acodec = String.Empty + Container = String.Empty + Protocol = String.Empty + Resolution = String.Empty + AudioExt = String.Empty + VideoExt = String.Empty + Vbr = System.Nullable 0 + Abr = Nullable 0 + Format = String.Empty + Type = String.Empty +} + +[] +let ``Detects album name in video description`` () = + let testAlbumName = "Test Album Name" + let videoMetadata = { emptyVideoMetadata with Description = $" album: {testAlbumName}" } + let fallback : string option = None + + let detectionPattern : TagDetectionPattern = { + RegexPattern = "(?<=[Aa]lbum: ).+" + MatchGroup = 0 + SearchField = "description" + Summary = Some "Find album name in description" + } + + let tagDetectionPatterns : TagDetectionPatterns = { + Title = [||] + Artist = [||] + Album = [| detectionPattern |] + Composer = [||] + Year = [||] + } + + let result = TagDetection.detectAlbum videoMetadata fallback tagDetectionPatterns + + match result with + | Some r -> Assert.Equal(testAlbumName, r) + | None -> Assert.Fail $"Expected album name \"{testAlbumName}\" was not found" diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index 03bf7b4b..af7d1eb9 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -8,21 +8,21 @@ open System.Text.RegularExpressions module Detectors = /// Attempts casting the input text to type 'a and returning it. /// If casting fails, the default value is returned instead. - let private cast<'a> (text: string) (defaultValue: 'a) : 'a = + let tryCast<'a> (text: string) : 'a option = try // If T is string, return the text directly if typeof<'a> = typeof then - box text :?> 'a + Some (box text :?> 'a) else - Convert.ChangeType(text, typeof<'a>) :?> 'a + Some (Convert.ChangeType(text, typeof<'a>) :?> 'a) with - | _ -> defaultValue + | _ -> None /// Extracts the value of the specified tag field from the given data. /// Video metadata /// The name of the field within the video metadata to read /// The text content of the requested field of the video metadata - let private extractMetadataText (metadata: VideoMetadata) (fieldName: string) = + let private extractMetadataText (metadata: VideoMetadata) (fieldName: string) : string = match fieldName with | "title" -> metadata.Title | "description" -> metadata.Description @@ -32,7 +32,7 @@ module Detectors = /// Finds and returns the first instance of text matching a given detection scheme pattern, /// parsing into T if necessary. - /// A match of type T if there was a match; otherwise, the default value provided. + /// A match of type 'a if there was a match; otherwise, the default value provided. let internal detectSingle<'a> (videoMetadata: VideoMetadata) (patterns: TagDetectionPattern seq) @@ -44,29 +44,28 @@ module Detectors = let fieldText = extractMetadataText videoMetadata pattern.SearchField let match' = Regex(pattern.RegexPattern).Match(fieldText) - if not match'.Success then - None - else + if match'.Success then let matchedText = match'.Groups[pattern.MatchGroup].Value.Trim() - cast matchedText None) - |> Option.defaultValue defaultValue + tryCast<'a> matchedText + else + None) + |> Option.orElse defaultValue /// Finds and returns all instances of text matching a given detection scheme pattern, /// concatenating them into a single string (using a custom separator), then casting - /// to type T if necessary. - /// A match of type T if there were any matches; otherwise, the default value provided. + /// to type 'a if necessary. + /// A match of type 'a if there were any matches; otherwise, the default value provided. let internal detectMultiple<'a> (data: VideoMetadata) (patterns: TagDetectionPattern seq) - (defaultValue: 'a) + (defaultValue: 'a option) (separator: string) - : 'a = + : 'a option = let matchedValues = patterns |> Seq.collect (fun pattern -> let fieldText = extractMetadataText data pattern.SearchField - Regex(pattern.RegexPattern).Matches(fieldText) |> Seq.filter _.Success |> Seq.map _.Groups[pattern.MatchGroup].Value.Trim()) @@ -76,5 +75,7 @@ module Detectors = if matchedValues.Length = 0 then defaultValue else - let joinedMatchedText = String.Join(separator, matchedValues) - cast<'a> joinedMatchedText defaultValue + String.Join(separator, matchedValues) + |> tryCast<'a> + |> Option.orElse defaultValue + diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs index f8c4f1b1..23eb884e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs @@ -33,7 +33,7 @@ module TagDetection = | None, None -> None let detectComposers videoData (tagDetectionPatterns: TagDetectionPatterns) : string option = - Detectors.detectMultiple videoData tagDetectionPatterns.Composer None "; " + Detectors.detectMultiple videoData tagDetectionPatterns.Composer None "; " let detectReleaseYear videoData fallback (tagDetectionPatterns: TagDetectionPatterns) : uint32 option = let detectedYear = From 110be8756eb3726f81bf62d472b47aa4be050fee Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:35:40 +0900 Subject: [PATCH 098/247] Add more test cases --- src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 135 +++++++++++++------ 1 file changed, 92 insertions(+), 43 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs index 6c07c092..822f6762 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -1,11 +1,10 @@ -// module CCVTAC.FSharp.Tests.TaggingTests module TagDetectionTests -open Xunit open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.PostProcessing open CCVTAC.Console.Settings.Settings open System +open Xunit let emptyVideoMetadata = { Id = String.Empty @@ -14,24 +13,24 @@ let emptyVideoMetadata = { Description = String.Empty ChannelId = String.Empty ChannelUrl = String.Empty - Duration = System.Nullable 0 - ViewCount = System.Nullable 0 - AgeLimit = System.Nullable 0 + Duration = Nullable 0 + ViewCount = Nullable 0 + AgeLimit = Nullable 0 WebpageUrl = String.Empty Categories = [] Tags = [] - PlayableInEmbed = Nullable false + PlayableInEmbed = Nullable false LiveStatus = String.Empty - ReleaseTimestamp = System.Nullable 0 + ReleaseTimestamp = Nullable 0 FormatSortFields = [] Album = String.Empty Artist = String.Empty Track = String.Empty - CommentCount = System.Nullable 0 - LikeCount = System.Nullable 0 + CommentCount = Nullable 0 + LikeCount = Nullable 0 Channel = String.Empty - ChannelFollowerCount = System.Nullable 0 - ChannelIsVerified = Nullable false + ChannelFollowerCount = Nullable 0 + ChannelIsVerified = Nullable false Uploader = String.Empty UploaderId = String.Empty UploaderUrl = String.Empty @@ -43,31 +42,31 @@ let emptyVideoMetadata = { WebpageUrlDomain = String.Empty Extractor = String.Empty ExtractorKey = String.Empty - PlaylistCount = System.Nullable 0 + PlaylistCount = Nullable 0 Playlist = String.Empty PlaylistId = String.Empty PlaylistTitle = String.Empty - NEntries = System.Nullable 0 - PlaylistIndex = Nullable 0u + NEntries = Nullable 0 + PlaylistIndex = Nullable 0u DisplayId = String.Empty Fulltitle = String.Empty DurationString = String.Empty ReleaseDate = String.Empty - ReleaseYear = Nullable 0u - IsLive = Nullable false - WasLive = Nullable false - Epoch = System.Nullable 0 - Asr = System.Nullable 0 - Filesize = System.Nullable 0 + ReleaseYear = Nullable 0u + IsLive = Nullable false + WasLive = Nullable false + Epoch = Nullable 0 + Asr = Nullable 0 + Filesize = Nullable 0 FormatId = String.Empty FormatNote = String.Empty - SourcePreference = System.Nullable 0 - AudioChannels = System.Nullable 0 - Quality = Nullable 0 - HasDrm = Nullable false - Tbr = Nullable 0 + SourcePreference = Nullable 0 + AudioChannels = Nullable 0 + Quality = Nullable 0 + HasDrm = Nullable false + Tbr = Nullable 0 Url = String.Empty - LanguagePreference = System.Nullable 0 + LanguagePreference = Nullable 0 Ext = String.Empty Vcodec = String.Empty Acodec = String.Empty @@ -76,35 +75,85 @@ let emptyVideoMetadata = { Resolution = String.Empty AudioExt = String.Empty VideoExt = String.Empty - Vbr = System.Nullable 0 - Abr = Nullable 0 + Vbr = Nullable 0 + Abr = Nullable 0 Format = String.Empty Type = String.Empty } +let newLine = Environment.NewLine + [] let ``Detects album name in video description`` () = - let testAlbumName = "Test Album Name" - let videoMetadata = { emptyVideoMetadata with Description = $" album: {testAlbumName}" } - let fallback : string option = None + let testArtist = "Test Artist Name (日本語入り)" + let testAlbum = "Test Album Name (日本語入り)" + let testTitle = "Test Title (日本語入り)" + let testComposer = "Test Composer (日本語入り)" + let testYear = 1945u + + let videoMetadata = { + emptyVideoMetadata with + Title = $"{testArtist}「{testTitle}」" + Description = $"album: {testAlbum}{newLine}℗ %d{testYear}{newLine}Composed by: {testComposer}" } + + let artistPattern = { + RegexPattern = "^(.+?)「(.+)」" + MatchGroup = 1 + SearchField = "title" + Summary = Some "Find artist in video title" + } + + let titlePattern = { + artistPattern with + MatchGroup = 2 + Summary = Some "Find title in the video title" + } - let detectionPattern : TagDetectionPattern = { + let albumPattern = { RegexPattern = "(?<=[Aa]lbum: ).+" MatchGroup = 0 SearchField = "description" - Summary = Some "Find album name in description" + Summary = Some "Find album in description" } - let tagDetectionPatterns : TagDetectionPatterns = { - Title = [||] - Artist = [||] - Album = [| detectionPattern |] - Composer = [||] - Year = [||] + let composerPattern = { + RegexPattern = "(?<=[Cc]omposed by |[Cc]omposed by: |[Cc]omposer: |作曲[::・]).+" + MatchGroup = 0 + SearchField = "description" + Summary = Some "Find composer in description" } - let result = TagDetection.detectAlbum videoMetadata fallback tagDetectionPatterns + let yearPattern = { + RegexPattern = "(?<=℗ )[12]\d{3}" + MatchGroup = 0 + SearchField = "description" + Summary = Some "Find year in description" + } + + let tagDetectionPatterns = { + Title = [| titlePattern |] + Artist = [| artistPattern |] + Album = [| albumPattern |] + Composer = [| composerPattern |] + Year = [| yearPattern |] + } + + match TagDetection.detectArtist videoMetadata None tagDetectionPatterns with + | Some artistResult -> Assert.Equal(testArtist, artistResult) + | None -> Assert.Fail $"Expected artist \"{testArtist}\" was not found." + + match TagDetection.detectAlbum videoMetadata None tagDetectionPatterns with + | Some albumResult -> Assert.Equal(testAlbum, albumResult) + | None -> Assert.Fail $"Expected album \"{testAlbum}\" was not found." + + match TagDetection.detectTitle videoMetadata None tagDetectionPatterns with + | Some titleResult -> Assert.Equal(testTitle, titleResult) + | None -> Assert.Fail $"Expected title \"{testTitle}\" was not found." + + match TagDetection.detectComposers videoMetadata tagDetectionPatterns with + | Some composerResult -> Assert.Equal(testComposer, composerResult) + | None -> Assert.Fail $"Expected composer \"{testComposer}\" was not found." - match result with - | Some r -> Assert.Equal(testAlbumName, r) - | None -> Assert.Fail $"Expected album name \"{testAlbumName}\" was not found" + match TagDetection.detectReleaseYear videoMetadata None tagDetectionPatterns with + | Some yearResult -> Assert.Equal(testYear, yearResult) + | None -> Assert.Fail $"Expected year \"%d{testYear}\" was not found." From 3402471564e38603442c7a18affd530464317db2 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:19:32 +0900 Subject: [PATCH 099/247] Collection tweaks in Mover --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 37860008..93f01fa6 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -21,16 +21,18 @@ module Mover = playlistImageRegex.IsMatch fileName let private getCoverImage (workingDirInfo: DirectoryInfo) audioFileCount : FileInfo option = - let images = workingDirInfo.EnumerateFiles imageFileWildcard |> Seq.toArray - if images.Length = 0 then None + let images = workingDirInfo.EnumerateFiles imageFileWildcard |> Seq.toList + if List.isEmpty images then + None else - let playlistImages = images |> Array.filter (fun i -> isPlaylistImage i.FullName) |> Array.toList - if playlistImages.Any() - then Some (playlistImages[0]) + let playlistImages = images |> List.filter (fun i -> isPlaylistImage i.FullName) + if not (List.isEmpty playlistImages) + then Some playlistImages[0] elif audioFileCount > 1 && images.Length = 1 then Some images[0] else None + // TODO: Move to IoUtilities. let private ensureDirectoryExists (moveToDir: string) (printer: Printer) : Result = try if Directory.Exists moveToDir then @@ -158,7 +160,8 @@ module Mover = let successCount, failureCount = moveAudioFiles audioFileNames fullMoveToDir overwrite printer - moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite printer + moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir + audioFileNames.Length overwrite printer let fileLabel = if successCount = 1u then "file" else "files" printer.Info $"Moved %d{successCount} audio %s{fileLabel} in %s{watch.ElapsedFriendly}." From 6a05ee4bb95ecccfa960dbfbf5c24ad65a7f7446 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:37:45 +0900 Subject: [PATCH 100/247] Move directory-checking func to IO utilities --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 14 ++++++++++++++ src/CCVTAC.FSharp/PostProcessing/Mover.fs | 18 ++---------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index a15584de..9b77ba1a 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -96,3 +96,17 @@ module Directories = report.AppendLine("This generally occurs due to the same video appearing twice in playlists.") |> ignore Error (report.ToString()) + + let ensureDirectoryExists moveToDir (printer: Printer) : Result = + try + if Directory.Exists moveToDir then + Ok <| printer.Debug $"Found move-to directory \"%s{moveToDir}\"." + else + printer.Debug ($"Creating move-to directory \"%s{moveToDir}\" (based on playlist metadata)... ", + appendLineBreak = false) + Directory.CreateDirectory moveToDir |> ignore + Ok <| printer.Debug "OK." + with ex -> + let errMsg = $"Error creating move-to directory \"%s{moveToDir}\": %s{ex.Message}" + printer.Error errMsg + Error errMsg diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 93f01fa6..847f658f 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -1,5 +1,6 @@ namespace CCVTAC.Console.PostProcessing +open CCVTAC.Console.IoUtilities open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.Settings.Settings open CCVTAC.Console @@ -32,21 +33,6 @@ module Mover = then Some images[0] else None - // TODO: Move to IoUtilities. - let private ensureDirectoryExists (moveToDir: string) (printer: Printer) : Result = - try - if Directory.Exists moveToDir then - Ok <| printer.Debug $"Found move-to directory \"%s{moveToDir}\"." - else - printer.Debug ($"Creating move-to directory \"%s{moveToDir}\" (based on playlist metadata)... ", - appendLineBreak = false) - Directory.CreateDirectory moveToDir |> ignore - Ok <| printer.Debug "OK." - with ex -> - let errMsg = $"Error creating move-to directory \"%s{moveToDir}\": %s{ex.Message}" - printer.Error errMsg - Error errMsg - let private moveAudioFiles (audioFiles: FileInfo list) (moveToDir: string) @@ -144,7 +130,7 @@ module Mover = let collectionName = maybeCollectionData |> Option.map _.Title |> Option.defaultValue String.Empty let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) - match ensureDirectoryExists fullMoveToDir printer with + match Directories.ensureDirectoryExists fullMoveToDir printer with | Error _ -> () // Error was already printed. | Ok () -> let audioFileNames = From 4fc5afa9ba65f170a2f79d9de4131823c3ea1187 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:37:57 +0900 Subject: [PATCH 101/247] Add ofTry func --- src/CCVTAC.FSharp/Utilities.fs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 8586356b..8f82f042 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -6,6 +6,11 @@ open System.Text type SB = StringBuilder +module Utilities = + let ofTry (f: unit -> 'a) : Result<'a, string> = + try Ok (f()) + with exn -> Error exn.Message + module String = let newLine = Environment.NewLine From 65d57f87a2c4d8b9b62d2b10293d20b1d4214e01 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:20:42 +0900 Subject: [PATCH 102/247] Refactor move-to dir logic; add simple pluralization --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 20 ++++++-------- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 28 ++++++++++++-------- src/CCVTAC.FSharp/Utilities.fs | 7 +++++ 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 9b77ba1a..7533cf3e 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -97,16 +97,12 @@ module Directories = Error (report.ToString()) - let ensureDirectoryExists moveToDir (printer: Printer) : Result = + let ensureDirectoryExists dirName : Result = try - if Directory.Exists moveToDir then - Ok <| printer.Debug $"Found move-to directory \"%s{moveToDir}\"." - else - printer.Debug ($"Creating move-to directory \"%s{moveToDir}\" (based on playlist metadata)... ", - appendLineBreak = false) - Directory.CreateDirectory moveToDir |> ignore - Ok <| printer.Debug "OK." - with ex -> - let errMsg = $"Error creating move-to directory \"%s{moveToDir}\": %s{ex.Message}" - printer.Error errMsg - Error errMsg + dirName + |> Path.GetFullPath + |> Directory.CreateDirectory + |> Ok + with exn -> + Error $"Error accessing or creating directory \"%s{dirName}\": %s{exn.Message}" + diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 847f658f..79197652 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -130,28 +130,34 @@ module Mover = let collectionName = maybeCollectionData |> Option.map _.Title |> Option.defaultValue String.Empty let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) - match Directories.ensureDirectoryExists fullMoveToDir printer with - | Error _ -> () // Error was already printed. - | Ok () -> + match Directories.ensureDirectoryExists fullMoveToDir with + | Error err -> + printer.Error err + | Ok dirInfo -> + printer.Debug $"Move-to directory \"%s{dirInfo.Name}\" exists." + + let inline fileLabel count = NumberUtilities.pluralize "file" "files" count + let audioFileNames = workingDirInfo.EnumerateFiles() |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) |> List.ofSeq if audioFileNames.IsEmpty then - printer.Error "No audio filenames to move found." + printer.Error "No audio filenames to move were found." else - printer.Debug $"Moving %d{audioFileNames.Length} audio file(s) to \"%s{fullMoveToDir}\"..." + let toMoveFileLabel = fileLabel audioFileNames.Length + printer.Debug $"Moving %d{audioFileNames.Length} audio %s{toMoveFileLabel} to \"%s{fullMoveToDir}\"..." - let successCount, failureCount = + let moveSuccessCount, moveFailureCount = moveAudioFiles audioFileNames fullMoveToDir overwrite printer moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite printer - let fileLabel = if successCount = 1u then "file" else "files" - printer.Info $"Moved %d{successCount} audio %s{fileLabel} in %s{watch.ElapsedFriendly}." + let movedFileLabel = fileLabel moveSuccessCount + printer.Info $"Moved %d{moveSuccessCount} audio %s{movedFileLabel} in %s{watch.ElapsedFriendly}." - if failureCount > 0u then - let fileLabel' = if failureCount = 1u then "file" else "files" - printer.Warning $"However, %d{failureCount} audio %s{fileLabel'} could not be moved." + if moveFailureCount > 0u then + let fileLabel' = fileLabel moveFailureCount + printer.Warning $"However, %d{moveFailureCount} audio %s{fileLabel'} could not be moved." diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 8f82f042..ceb54173 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -11,6 +11,13 @@ module Utilities = try Ok (f()) with exn -> Error exn.Message +module NumberUtilities = + let inline isOne (x: ^a) = + x = LanguagePrimitives.GenericOne<'a> + + let inline pluralize ifOne ifNotOne count = + if isOne count then ifOne else ifNotOne + module String = let newLine = Environment.NewLine From 75d22c02e0e04cefbdc7eaef14ba1f03e6d86d8d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:26:57 +0900 Subject: [PATCH 103/247] New utility func for file labels --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 10 ++++------ src/CCVTAC.FSharp/Utilities.fs | 3 +++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 79197652..83f16c62 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -136,8 +136,6 @@ module Mover = | Ok dirInfo -> printer.Debug $"Move-to directory \"%s{dirInfo.Name}\" exists." - let inline fileLabel count = NumberUtilities.pluralize "file" "files" count - let audioFileNames = workingDirInfo.EnumerateFiles() |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) @@ -146,7 +144,7 @@ module Mover = if audioFileNames.IsEmpty then printer.Error "No audio filenames to move were found." else - let toMoveFileLabel = fileLabel audioFileNames.Length + let toMoveFileLabel = String.fileLabel audioFileNames.Length printer.Debug $"Moving %d{audioFileNames.Length} audio %s{toMoveFileLabel} to \"%s{fullMoveToDir}\"..." let moveSuccessCount, moveFailureCount = @@ -155,9 +153,9 @@ module Mover = moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite printer - let movedFileLabel = fileLabel moveSuccessCount + let movedFileLabel = String.fileLabel moveSuccessCount printer.Info $"Moved %d{moveSuccessCount} audio %s{movedFileLabel} in %s{watch.ElapsedFriendly}." if moveFailureCount > 0u then - let fileLabel' = fileLabel moveFailureCount - printer.Warning $"However, %d{moveFailureCount} audio %s{fileLabel'} could not be moved." + let failFileLabel = String.fileLabel moveFailureCount + printer.Warning $"However, %d{moveFailureCount} audio %s{failFileLabel} could not be moved." diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index ceb54173..3e52b165 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -43,6 +43,9 @@ module String = let endsWithIgnoringCase endingText (text: string) = text.EndsWith(endingText, StringComparison.InvariantCultureIgnoreCase) + let inline fileLabel count = + NumberUtilities.pluralize "file" "files" count + /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. /// Throws if the replacement character is an invalid path character. From 686d03e014bc2a1457139cf732b7be3fb693914b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:37:28 +0900 Subject: [PATCH 104/247] Refactor move-image func --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 83f16c62..731eacde 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -61,8 +61,7 @@ module Mover = (moveToDir: string) (audioFileCount: int) (overwrite: bool) - (printer: Printer) - : unit = + : Result = try let baseFileName = @@ -72,13 +71,13 @@ module Mover = $"%s{subFolderName} - %s{String.replaceInvalidPathChars None None maybeCollectionName}" match getCoverImage workingDirInfo audioFileCount with - | None -> () - | Some image -> + | None -> + Error "No image found." + | Some fileInfo -> let dest = Path.Combine(moveToDir, $"%s{baseFileName.Trim()}.jpg") - image.MoveTo(dest, overwrite = overwrite) - printer.Info "Moved image file." + Ok <| fileInfo.MoveTo(dest, overwrite = overwrite) with ex -> - printer.Warning $"Error copying the image file: %s{ex.Message}" + Error $"Error copying the image file: %s{ex.Message}" let private getParsedVideoJson (taggingSet: TaggingSet) : Result = try @@ -150,12 +149,14 @@ module Mover = let moveSuccessCount, moveFailureCount = moveAudioFiles audioFileNames fullMoveToDir overwrite printer - moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir - audioFileNames.Length overwrite printer - let movedFileLabel = String.fileLabel moveSuccessCount printer.Info $"Moved %d{moveSuccessCount} audio %s{movedFileLabel} in %s{watch.ElapsedFriendly}." if moveFailureCount > 0u then let failFileLabel = String.fileLabel moveFailureCount printer.Warning $"However, %d{moveFailureCount} audio %s{failFileLabel} could not be moved." + + match moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir + audioFileNames.Length overwrite with + | Ok _ -> printer.Info $"Moved image to %s{fullMoveToDir}." + | Error err -> printer.Error $"Error moving the image file: %s{err}." From ac19c13504407fe49071200e3171a004b6becc47 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:14:17 +0900 Subject: [PATCH 105/247] Update history section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aabce5ea..e3667b77 100644 --- a/README.md +++ b/README.md @@ -206,4 +206,4 @@ If you run into any issues, feel free to create an issue on GitHub. Please provi ## History -This application was originally written in C#, but I ported it to F# beginning in late 2025 to try out F#'s object-oriented programming capabilities and to move toward a more functional codebase. +The first incarnation of this application was written in C#. After picking up F# and functional programming out of curiosity in 2024 and creating other tools (such as Audio Tag Tools) with it, I become curious about its OOP capabilities as well. I rewrote this application in OOP F# (initially using LLMs reduce the work and time necessary, though a *lot* of cleanup was necessary). Ultimately, I prefer the F# code over the C# code, so I kept this version. From 09772a154bb005246999dcb22a624c0587f5ba81 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:55:20 +0900 Subject: [PATCH 106/247] Replace nulls with options; improve move-image func --- .../PostProcessing/CollectionMetadata.fs | 6 +-- .../PostProcessing/MetadataUtilities.fs | 4 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 21 ++++---- .../PostProcessing/Tagging/Tagger.fs | 10 ++-- .../PostProcessing/VideoMetadata.fs | 53 +++++++++---------- 5 files changed, 46 insertions(+), 48 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs index ae3def33..9ba3b5ac 100644 --- a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs @@ -11,8 +11,8 @@ type CollectionMetadata = [] Description: string [] Tags: IReadOnlyList [] ModifiedDate: string - [] ViewCount: Nullable - [] PlaylistCount: Nullable + [] ViewCount: int option + [] PlaylistCount: int option [] Channel: string [] ChannelId: string [] UploaderId: string @@ -23,4 +23,4 @@ type CollectionMetadata = [] WebpageUrl: string [] WebpageUrlBasename: string [] WebpageUrlDomain: string - [] Epoch: Nullable } + [] Epoch: int option } diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs index c262c5b5..1f07c3f8 100644 --- a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs +++ b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs @@ -51,8 +51,8 @@ module MetadataUtilities = sb.AppendLine $"■ Playlist name: %s{c'.Title}" |> ignore sb.AppendLine $"■ Playlist URL: %s{c'.WebpageUrl}" |> ignore match v.PlaylistIndex with - | NonNullV index -> if index > 0u then sb.AppendLine $"■ Playlist index: %d{index}" |> ignore - | NullV -> () + | Some index -> if index > 0u then sb.AppendLine $"■ Playlist index: %d{index}" |> ignore + | None -> () sb.AppendLine($"■ Playlist description: %s{String.textOrEmpty c'.Description}") |> ignore | None -> () diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 731eacde..4a01b005 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -61,21 +61,20 @@ module Mover = (moveToDir: string) (audioFileCount: int) (overwrite: bool) - : Result = + : Result = try - let baseFileName = - if String.hasNoText maybeCollectionName then - subFolderName - else - $"%s{subFolderName} - %s{String.replaceInvalidPathChars None None maybeCollectionName}" - match getCoverImage workingDirInfo audioFileCount with | None -> - Error "No image found." + Ok "No image to move was found." | Some fileInfo -> + let baseFileName = + if String.hasNoText maybeCollectionName + then subFolderName + else $"%s{subFolderName} - %s{String.replaceInvalidPathChars None None maybeCollectionName}" let dest = Path.Combine(moveToDir, $"%s{baseFileName.Trim()}.jpg") - Ok <| fileInfo.MoveTo(dest, overwrite = overwrite) + fileInfo.MoveTo(dest, overwrite = overwrite) + Ok $"Image file \"{fileInfo.Name}\" was moved." with ex -> Error $"Error copying the image file: %s{ex.Message}" @@ -108,7 +107,7 @@ module Mover = else safeName let run - (taggingSets: seq) + (taggingSets: TaggingSet seq) (maybeCollectionData: CollectionMetadata option) (settings: UserSettings) (overwrite: bool) @@ -158,5 +157,5 @@ module Mover = match moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite with - | Ok _ -> printer.Info $"Moved image to %s{fullMoveToDir}." + | Ok msg -> printer.Info msg | Error err -> printer.Error $"Error moving the image file: %s{err}." diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 57b1d6a0..fd3a694d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -133,17 +133,17 @@ module Tagger = // Track number match videoData.PlaylistIndex with - | NullV -> () - | NonNullV (trackNo: uint32) -> + | None -> () + | Some (trackNo: uint32) -> printer.Debug $"• Using playlist index of %d{trackNo} for track number" taggedFile.Tag.Track <- uint32 trackNo // Year match videoData.ReleaseYear with - | NonNullV (year: uint32) -> + | Some (year: uint32) -> printer.Debug $"• Using metadata release year \"%d{year}\"" taggedFile.Tag.Year <- year - | NullV -> + | None -> let defaultYear = releaseYear settings videoData match TagDetection.detectReleaseYear videoData defaultYear patterns with | None -> () @@ -200,7 +200,7 @@ module Tagger = let run (settings: UserSettings) - (taggingSets: seq) + (taggingSets: TaggingSet seq) (collectionJson: CollectionMetadata option) (mediaType: MediaType) (printer: Printer) diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index c233bf22..5716003b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -1,6 +1,5 @@ namespace CCVTAC.Console.PostProcessing -open System open System.Collections.Generic open System.Text.Json.Serialization @@ -11,24 +10,24 @@ type VideoMetadata = { [] Description: string [] ChannelId: string [] ChannelUrl: string - [] Duration: Nullable - [] ViewCount: Nullable - [] AgeLimit: Nullable + [] Duration: int option + [] ViewCount: int option + [] AgeLimit: int option [] WebpageUrl: string [] Categories: IReadOnlyList [] Tags: IReadOnlyList - [] PlayableInEmbed: Nullable + [] PlayableInEmbed: bool option [] LiveStatus: string - [] ReleaseTimestamp: Nullable + [] ReleaseTimestamp: int option [] FormatSortFields: IReadOnlyList [] Album: string [] Artist: string [] Track: string - [] CommentCount: Nullable - [] LikeCount: Nullable + [] CommentCount: int option + [] LikeCount: int option [] Channel: string - [] ChannelFollowerCount: Nullable - [] ChannelIsVerified: Nullable + [] ChannelFollowerCount: int option + [] ChannelIsVerified: bool option [] Uploader: string [] UploaderId: string [] UploaderUrl: string @@ -40,31 +39,31 @@ type VideoMetadata = { [] WebpageUrlDomain: string [] Extractor: string [] ExtractorKey: string - [] PlaylistCount: Nullable + [] PlaylistCount: int option [] Playlist: string [] PlaylistId: string [] PlaylistTitle: string - [] NEntries: Nullable - [] PlaylistIndex: Nullable + [] NEntries: int option + [] PlaylistIndex: uint32 option [] DisplayId: string [] Fulltitle: string [] DurationString: string [] ReleaseDate: string - [] ReleaseYear: Nullable - [] IsLive: Nullable - [] WasLive: Nullable - [] Epoch: Nullable - [] Asr: Nullable - [] Filesize: Nullable + [] ReleaseYear: uint32 option + [] IsLive: bool option + [] WasLive: bool option + [] Epoch: int option + [] Asr: int option + [] Filesize: int option [] FormatId: string [] FormatNote: string - [] SourcePreference: Nullable - [] AudioChannels: Nullable - [] Quality: Nullable - [] HasDrm: Nullable - [] Tbr: Nullable + [] SourcePreference: int option + [] AudioChannels: int option + [] Quality: double option + [] HasDrm: bool option + [] Tbr: double option [] Url: string - [] LanguagePreference: Nullable + [] LanguagePreference: int option [] Ext: string [] Vcodec: string [] Acodec: string @@ -73,7 +72,7 @@ type VideoMetadata = { [] Resolution: string [] AudioExt: string [] VideoExt: string - [] Vbr: Nullable - [] Abr: Nullable + [] Vbr: int option + [] Abr: double option [] Format: string [] Type: string } From ddb04f0eac6f85f8cff3c5fc97cf02db34ee88ac Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:58:20 +0900 Subject: [PATCH 107/247] Refactor pluralize func --- src/CCVTAC.FSharp/Settings/Settings.fs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 65ebb1b7..f4d377b1 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -55,10 +55,8 @@ module Settings = | true -> "ON" | false -> "OFF" - let pluralize (label: string) count = - if count = 1 - then $"{count} {label}" - else $"{count} {label}s" // Intentionally naive implementation. + let pluralize label count = + NumberUtilities.pluralize label $"{label}s" count let tagDetectionPatternCount (patterns: TagDetectionPatterns) = patterns.Title.Length + From 2e3af767a424546afadc07a44393e62dedbc96da Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:02:12 +0900 Subject: [PATCH 108/247] Convert IReadOnlyLists to F# lists --- src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs | 4 +--- src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs index 9ba3b5ac..b5ed5733 100644 --- a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs @@ -1,7 +1,5 @@ namespace CCVTAC.Console.PostProcessing -open System -open System.Collections.Generic open System.Text.Json.Serialization type CollectionMetadata = @@ -9,7 +7,7 @@ type CollectionMetadata = [] Title: string [] Availability: string [] Description: string - [] Tags: IReadOnlyList + [] Tags: obj list [] ModifiedDate: string [] ViewCount: int option [] PlaylistCount: int option diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index 5716003b..b1f1e8ed 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -14,12 +14,12 @@ type VideoMetadata = { [] ViewCount: int option [] AgeLimit: int option [] WebpageUrl: string - [] Categories: IReadOnlyList - [] Tags: IReadOnlyList + [] Categories: string list + [] Tags: string list [] PlayableInEmbed: bool option [] LiveStatus: string [] ReleaseTimestamp: int option - [] FormatSortFields: IReadOnlyList + [] FormatSortFields: string list [] Album: string [] Artist: string [] Track: string From 7e9dc9d1cd98e6714bca57374b0df5714c4483e3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:02:24 +0900 Subject: [PATCH 109/247] Fix pluralize func --- src/CCVTAC.FSharp/Settings/Settings.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index f4d377b1..66541be4 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -57,6 +57,7 @@ module Settings = let pluralize label count = NumberUtilities.pluralize label $"{label}s" count + |> sprintf "%d %s" count let tagDetectionPatternCount (patterns: TagDetectionPatterns) = patterns.Title.Length + From f0b23552e246d2b8f36910c94886b9be643f35ae Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:05:45 +0900 Subject: [PATCH 110/247] Fix test --- src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 52 ++++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs index 822f6762..777e4f37 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -13,24 +13,24 @@ let emptyVideoMetadata = { Description = String.Empty ChannelId = String.Empty ChannelUrl = String.Empty - Duration = Nullable 0 - ViewCount = Nullable 0 - AgeLimit = Nullable 0 + Duration = None + ViewCount = None + AgeLimit = None WebpageUrl = String.Empty Categories = [] Tags = [] - PlayableInEmbed = Nullable false + PlayableInEmbed = None LiveStatus = String.Empty - ReleaseTimestamp = Nullable 0 + ReleaseTimestamp = None FormatSortFields = [] Album = String.Empty Artist = String.Empty Track = String.Empty - CommentCount = Nullable 0 - LikeCount = Nullable 0 + CommentCount = None + LikeCount = None Channel = String.Empty - ChannelFollowerCount = Nullable 0 - ChannelIsVerified = Nullable false + ChannelFollowerCount = None + ChannelIsVerified = None Uploader = String.Empty UploaderId = String.Empty UploaderUrl = String.Empty @@ -42,31 +42,31 @@ let emptyVideoMetadata = { WebpageUrlDomain = String.Empty Extractor = String.Empty ExtractorKey = String.Empty - PlaylistCount = Nullable 0 + PlaylistCount = None Playlist = String.Empty PlaylistId = String.Empty PlaylistTitle = String.Empty - NEntries = Nullable 0 - PlaylistIndex = Nullable 0u + NEntries = None + PlaylistIndex = None DisplayId = String.Empty Fulltitle = String.Empty DurationString = String.Empty ReleaseDate = String.Empty - ReleaseYear = Nullable 0u - IsLive = Nullable false - WasLive = Nullable false - Epoch = Nullable 0 - Asr = Nullable 0 - Filesize = Nullable 0 + ReleaseYear = None + IsLive = None + WasLive = None + Epoch = None + Asr = None + Filesize = None FormatId = String.Empty FormatNote = String.Empty - SourcePreference = Nullable 0 - AudioChannels = Nullable 0 - Quality = Nullable 0 - HasDrm = Nullable false - Tbr = Nullable 0 + SourcePreference = None + AudioChannels = None + Quality = None + HasDrm = None + Tbr = None Url = String.Empty - LanguagePreference = Nullable 0 + LanguagePreference = None Ext = String.Empty Vcodec = String.Empty Acodec = String.Empty @@ -75,8 +75,8 @@ let emptyVideoMetadata = { Resolution = String.Empty AudioExt = String.Empty VideoExt = String.Empty - Vbr = Nullable 0 - Abr = Nullable 0 + Vbr = None + Abr = None Format = String.Empty Type = String.Empty } From 8be4e2cdb18dd05b91b0abca9eb79a962c99ec40 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:06:18 +0900 Subject: [PATCH 111/247] Rename var --- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index e2efd451..7048dac0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -35,7 +35,7 @@ module PostProcessor = let json = File.ReadAllText fileName match JsonSerializer.Deserialize json with | Null -> Error $"Deserialized collection metadata for \"%s{fileName}\" was null." - | NonNull collectionData -> Ok collectionData + | NonNull parsedData -> Ok parsedData with | ex -> Error ex.Message From 37383bfcf18564d21157d5fb4388ef4df6b124a8 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 11:11:56 +0900 Subject: [PATCH 112/247] Replace ImmutableArray with array --- src/CCVTAC.FSharp/InputHelper.fs | 28 ++++++++++++---------------- src/CCVTAC.FSharp/Orchestrator.fs | 16 +++++++--------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 3a5841a8..f34f17cc 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -1,15 +1,12 @@ namespace CCVTAC.Console -open System -open System.Linq open System.Text.RegularExpressions open System.Collections.Generic -open System.Collections.Immutable module InputHelper = - let internal Prompt = - sprintf "Enter one or more YouTube media URLs or commands (or \"%s\"):\n▶︎" Commands.helpCommand + let internal prompt = + $"Enter one or more YouTube media URLs or commands (or \"%s{Commands.helpCommand}\"):\n▶︎" /// A regular expression that detects where commands and URLs begin in input strings. let private userInputRegex = Regex(@"(?:https:|\\)", RegexOptions.Compiled) @@ -18,29 +15,28 @@ module InputHelper = /// Takes a user input string and splits it into a collection of inputs /// based upon substrings detected by the class's regular expression pattern. - let splitInput (input: string) : ImmutableArray = + let splitInput (input: string) : string array = let matches = userInputRegex.Matches(input) |> Seq.cast |> Seq.toArray if matches.Length = 0 then - ImmutableArray.Empty + [| |] elif matches.Length = 1 then - ImmutableArray.Create input + [| input |] else let startIndices = matches |> Array.map _.Index let indexPairs = startIndices - |> Array.mapi (fun i startIndex -> + |> Array.mapi (fun idx startIndex -> let endIndex = - if i = startIndices.Length - 1 then input.Length else startIndices[i + 1] + if idx = startIndices.Length - 1 + then input.Length + else startIndices[idx + 1] { Start = startIndex; End = endIndex }) - let splitInputs = - indexPairs - |> Array.map (fun p -> input[p.Start..(p.End - 1)].Trim()) - |> Array.distinct - - ImmutableArray.CreateRange splitInputs + indexPairs + |> Array.map (fun p -> input[p.Start..(p.End - 1)].Trim()) + |> Array.distinct type InputCategory = | Url diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 4dce4da7..d530e58c 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -293,22 +293,20 @@ module Orchestrator = printer.Info "Aborting..." () | Ok () -> - let results = ResultTracker(printer) + let results = ResultTracker printer let history = History(settings.HistoryFile, settings.HistoryDisplayCount) let mutable nextAction = NextAction.Continue let mutable settingsRef = settings while nextAction = NextAction.Continue do - let input = printer.GetInput InputHelper.Prompt - let splitInputs = InputHelper.splitInput input + let input = printer.GetInput prompt + let splitInputs = splitInput input - if splitInputs.IsEmpty then - printer.Error (sprintf "Invalid input. Enter only URLs or commands beginning with \"%c\"." Commands. - prefix - ) + if Array.isEmpty splitInputs then + printer.Error $"Invalid input. Enter only URLs or commands beginning with \"%c{Commands.prefix}\"." else - let categorizedInputs = InputHelper.categorizeInputs splitInputs - let categoryCounts = InputHelper.countCategories categorizedInputs + let categorizedInputs = categorizeInputs splitInputs + let categoryCounts = countCategories categorizedInputs summarizeInput categorizedInputs categoryCounts printer From 9dce1bf636c7ca6789349333688c72b344f83a82 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:30:12 +0900 Subject: [PATCH 113/247] Fix up warnIfAnyFiles --- README.md | 3 +- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 31 +++++++-------- src/CCVTAC.FSharp/Orchestrator.fs | 39 ++++++++++--------- .../PostProcessing/PostProcessing.fs | 4 +- src/CCVTAC.FSharp/Program.fs | 6 +-- 5 files changed, 41 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e3667b77..99ee70c2 100644 --- a/README.md +++ b/README.md @@ -206,4 +206,5 @@ If you run into any issues, feel free to create an issue on GitHub. Please provi ## History -The first incarnation of this application was written in C#. After picking up F# and functional programming out of curiosity in 2024 and creating other tools (such as Audio Tag Tools) with it, I become curious about its OOP capabilities as well. I rewrote this application in OOP F# (initially using LLMs reduce the work and time necessary, though a *lot* of cleanup was necessary). Ultimately, I prefer the F# code over the C# code, so I kept this version. +The first incarnation of this application was written in C#. After picking up F# and functional programming out of curiosity in 2024 and creating other tools (such as Audio Tag Tools) with it, I become curious about its OOP capabilities as well. I rewrote this application in OOP F# (initially using LLMs reduce the work and time necessary, though a *lot* of cleanup was necessary). Ultimately, I prefer the F# code over the C# code, so I kept this version. It is not idiomatic F#, but that's kind of the point as well. 😄 + diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 7533cf3e..2fa6be23 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -33,7 +33,7 @@ module Directories = |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) /// Deletes all files in the working directory - let internal deleteAllFiles (workingDirectory: string) (showMaxErrors: int) = + let internal deleteAllFiles showMaxErrors workingDirectory = let fileNames = getDirectoryFileNames workingDirectory None let mutable successCount = 0 @@ -68,32 +68,31 @@ module Directories = /// Asks user if they want to delete all files let internal askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = if printer.AskToBool("Delete all temporary files?", "Yes", "No") - then deleteAllFiles workingDirectory 10 + then deleteAllFiles 10 workingDirectory else Error "Will not delete the files." - let internal warnIfAnyFiles (directory: string) (showMax: int) = - let fileNames = getDirectoryFileNames directory None + let internal warnIfAnyFiles showMax dirName = + let fileNames = getDirectoryFileNames dirName None - if fileNames.Length = 0 then - Ok() + if Array.isEmpty fileNames then + Ok () else - let fileLabel = if fileNames.Length = 1 then "file" else "files" + let fileLabel : int -> string = NumberUtilities.pluralize "file" "files" let report = StringBuilder() - report.AppendLine( - $"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{directory}\":" - ) |> ignore + report.AppendLine $"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":" + |> ignore - fileNames - |> Array.truncate showMax - |> Array.iter (fun fileName -> - report.AppendLine($"• {fileName}") |> ignore - ) + report.AppendLine + (fileNames + |> Array.truncate showMax + |> Array.map (sprintf "• %s") + |> String.concat String.newLine) |> ignore if fileNames.Length > showMax then report.AppendLine($"... plus {fileNames.Length - showMax} more.") |> ignore - report.AppendLine("This generally occurs due to the same video appearing twice in playlists.") |> ignore + report.AppendLine("This sometimes occurs due to the same video appearing twice in playlists.") |> ignore Error (report.ToString()) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index d530e58c..dd31f90b 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -78,7 +78,7 @@ module Orchestrator = (printer: Printer) : Result = - match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with + match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with | Error err -> printer.Error err Ok NextAction.QuitDueToErrors @@ -281,7 +281,7 @@ module Orchestrator = /// Ensures the download environment is ready, then initiates the UI input and download process. let start (settings: UserSettings) (printer: Printer) : unit = // The working directory should start empty. Give the user a chance to empty it. - match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with + match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with | Error err -> printer.Error err @@ -292,26 +292,27 @@ module Orchestrator = printer.Error err printer.Info "Aborting..." () - | Ok () -> - let results = ResultTracker printer - let history = History(settings.HistoryFile, settings.HistoryDisplayCount) - let mutable nextAction = NextAction.Continue - let mutable settingsRef = settings + | Ok () -> () + + let results = ResultTracker printer + let history = History(settings.HistoryFile, settings.HistoryDisplayCount) + let mutable nextAction = NextAction.Continue + let mutable settingsRef = settings - while nextAction = NextAction.Continue do - let input = printer.GetInput prompt - let splitInputs = splitInput input + while nextAction = NextAction.Continue do + let input = printer.GetInput prompt + let splitInputs = splitInput input - if Array.isEmpty splitInputs then - printer.Error $"Invalid input. Enter only URLs or commands beginning with \"%c{Commands.prefix}\"." - else - let categorizedInputs = categorizeInputs splitInputs - let categoryCounts = countCategories categorizedInputs + if Array.isEmpty splitInputs then + printer.Error $"Invalid input. Enter only URLs or commands beginning with \"%c{Commands.prefix}\"." + else + let categorizedInputs = categorizeInputs splitInputs + let categoryCounts = countCategories categorizedInputs - summarizeInput categorizedInputs categoryCounts printer + summarizeInput categorizedInputs categoryCounts printer - // ProcessBatch may modify settings; reflect that by using a mutable reference - nextAction <- processBatch categorizedInputs categoryCounts &settingsRef results history printer + // ProcessBatch may modify settings; reflect that by using a mutable reference + nextAction <- processBatch categorizedInputs categoryCounts &settingsRef results history printer - results.PrintSessionSummary() + results.PrintSessionSummary() diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 7048dac0..57f4f647 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -78,12 +78,12 @@ module PostProcessor = let allTaggingSetFiles = taggingSets |> List.collect allFiles Deleter.run allTaggingSetFiles collectionJson workingDirectory printer - match Directories.warnIfAnyFiles workingDirectory 20 with + match Directories.warnIfAnyFiles 20 workingDirectory with | Ok _ -> () | Error err -> printer.Error err printer.Info "Will delete the remaining files..." - match Directories.deleteAllFiles workingDirectory 20 with + match Directories.deleteAllFiles 20 workingDirectory with | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." | Error e -> printer.Error e | Error e -> diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index aa24a2c1..12cd87f2 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -49,7 +49,7 @@ module Program = Console.CancelKeyPress.Add(fun _ -> printer.Warning("\nQuitting at user's request.") - match Directories.warnIfAnyFiles settings.WorkingDirectory 10 with + match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with | Ok () -> () | Error warnResult -> printer.Error warnResult @@ -64,7 +64,5 @@ module Program = with ex -> printer.Critical $"Fatal error: %s{ex.Message}" AnsiConsole.WriteException ex - printer.Info( - "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." - ) + printer.Info "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." int ExitCodes.OperationError From 4d97d19ea570a2510c880bcffe0fbdc154b2bebb Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:41:32 +0900 Subject: [PATCH 114/247] Further warnIfAnyFiles tweaks --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 34 +++++++++----------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 2fa6be23..e6c9b349 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -77,24 +77,22 @@ module Directories = if Array.isEmpty fileNames then Ok () else - let fileLabel : int -> string = NumberUtilities.pluralize "file" "files" - let report = StringBuilder() - - report.AppendLine $"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":" - |> ignore - - report.AppendLine - (fileNames - |> Array.truncate showMax - |> Array.map (sprintf "• %s") - |> String.concat String.newLine) |> ignore - - if fileNames.Length > showMax then - report.AppendLine($"... plus {fileNames.Length - showMax} more.") |> ignore - - report.AppendLine("This sometimes occurs due to the same video appearing twice in playlists.") |> ignore - - Error (report.ToString()) + let fileLabel = NumberUtilities.pluralize "file" "files" fileNames.Length + + StringBuilder() + .AppendLine($"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":") + .AppendLine + (fileNames + |> Array.truncate showMax + |> Array.map (sprintf "• %s") + |> String.concat String.newLine) + |> fun sb -> + if fileNames.Length > showMax + then sb.AppendLine $"... plus {fileNames.Length - showMax} more." + else sb + |> _.AppendLine("This sometimes occurs due to the same video appearing twice in playlists.") + |> _.ToString() + |> Error let ensureDirectoryExists dirName : Result = try From 98e40c0115d9be9a22555ef497ab85ae0dae6fbe Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:49:48 +0900 Subject: [PATCH 115/247] Remove 'internal' --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 4 ++-- src/CCVTAC.FSharp/Downloading/Updater.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 2 +- src/CCVTAC.FSharp/Help.fs | 2 +- src/CCVTAC.FSharp/InputHelper.fs | 2 +- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 8 ++++---- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 46063867..4dbaa9bc 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -64,12 +64,12 @@ module Downloader = let extraArgs = defaultArg additionalArgs [] |> Set.ofList String.Join(" ", Set.union args extraArgs) - let internal wrapUrlInMediaType url : Result = + let wrapUrlInMediaType url : Result = mediaTypeWithIds url /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. - let internal run (mediaType: MediaType) userSettings (printer: Printer) : Result = + let run (mediaType: MediaType) userSettings (printer: Printer) : Result = if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 7b886d0f..2256ee92 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -6,7 +6,7 @@ open CCVTAC.Console.Settings.Settings module Updater = - let internal run userSettings (printer: Printer) : Result = + let run userSettings (printer: Printer) : Result = if String.hasNoText userSettings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index d5446c9c..1b794e0b 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -20,7 +20,7 @@ module Runner = /// Additional exit codes, other than 0, that can be treated as non-failures /// Printer for logging /// A Result instance containing the exit code and any warnings or else an error message. - let internal run toolSettings (otherSuccessExitCodes: int list) (printer: Printer) + let run toolSettings (otherSuccessExitCodes: int list) (printer: Printer) : Result = let watch = Watch() diff --git a/src/CCVTAC.FSharp/Help.fs b/src/CCVTAC.FSharp/Help.fs index bf796a7f..3a9da3d6 100644 --- a/src/CCVTAC.FSharp/Help.fs +++ b/src/CCVTAC.FSharp/Help.fs @@ -2,7 +2,7 @@ namespace CCVTAC.Console module Help = - let internal Print (printer: Printer) : unit = + let Print (printer: Printer) : unit = let helpText = """ CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI tool that acts as a wrapper around yt-dlp (https://github.com/yt-dlp/yt-dlp) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index f34f17cc..e71f51ef 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -5,7 +5,7 @@ open System.Collections.Generic module InputHelper = - let internal prompt = + let prompt = $"Enter one or more YouTube media URLs or commands (or \"%s{Commands.helpCommand}\"):\n▶︎" /// A regular expression that detects where commands and URLs begin in input strings. diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index e6c9b349..4eb22f3b 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -13,7 +13,7 @@ module Directories = let private enumerationOptions = EnumerationOptions() /// Counts the number of audio files in a directory - let internal audioFileCount (directory: string) = + let audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) |> Seq.length @@ -33,7 +33,7 @@ module Directories = |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) /// Deletes all files in the working directory - let internal deleteAllFiles showMaxErrors workingDirectory = + let deleteAllFiles showMaxErrors workingDirectory = let fileNames = getDirectoryFileNames workingDirectory None let mutable successCount = 0 @@ -66,12 +66,12 @@ module Directories = Error (output.ToString()) /// Asks user if they want to delete all files - let internal askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = + let askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = if printer.AskToBool("Delete all temporary files?", "Yes", "No") then deleteAllFiles 10 workingDirectory else Error "Will not delete the files." - let internal warnIfAnyFiles showMax dirName = + let warnIfAnyFiles showMax dirName = let fileNames = getDirectoryFileNames dirName None if Array.isEmpty fileNames then diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index f5efd079..2df550db 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -29,7 +29,7 @@ module Deleter = | ex -> printer.Error($"• Deletion error: {ex.Message}") ) - let internal run + let run (taggingSetFileNames: string seq) (collectionMetadata: CollectionMetadata option) (workingDirectory: string) diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs index 2ed408f8..2d9b5385 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -6,7 +6,7 @@ module ImageProcessor = let private programName = "mogrify" - let internal run workingDirectory printer : unit = + let run workingDirectory printer : unit = let toolSettings = ToolSettings.create $"{programName} -trim -fuzz 10%% *.jpg" diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index af7d1eb9..e6bf94d1 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -33,7 +33,7 @@ module Detectors = /// Finds and returns the first instance of text matching a given detection scheme pattern, /// parsing into T if necessary. /// A match of type 'a if there was a match; otherwise, the default value provided. - let internal detectSingle<'a> + let detectSingle<'a> (videoMetadata: VideoMetadata) (patterns: TagDetectionPattern seq) (defaultValue: 'a option) @@ -55,7 +55,7 @@ module Detectors = /// concatenating them into a single string (using a custom separator), then casting /// to type 'a if necessary. /// A match of type 'a if there were any matches; otherwise, the default value provided. - let internal detectMultiple<'a> + let detectMultiple<'a> (data: VideoMetadata) (patterns: TagDetectionPattern seq) (defaultValue: 'a option) From 8102a5efd04956c561bf998f529e91b73736ea18 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:56:49 +0900 Subject: [PATCH 116/247] Use Null/NonNull active pattern --- src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs | 4 ++-- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 4 ++-- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs index e163ee99..b35c9e9f 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs @@ -32,9 +32,9 @@ module ExternalTool = try match Process.Start processStartInfo with - | null -> + | Null -> Error $"The program \"{name}\" was not found. (The process was null.)" - | process' -> + | NonNull process' -> process'.WaitForExit() Ok() with diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 1b794e0b..1dee86a3 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -39,9 +39,9 @@ module Runner = processStartInfo.WorkingDirectory <- toolSettings.WorkingDirectory match Process.Start processStartInfo with - | null -> + | Null -> Error $"Could not locate {splitCommandWithArgs[0]}." - | process' -> + | NonNull process' -> let error = process'.StandardError.ReadToEnd() process'.WaitForExit() diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index dd31f90b..3b5a6dcc 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -186,7 +186,7 @@ module Orchestrator = // Update audio formats prefix elif startsWithIgnoreCase command Commands.updateAudioFormatPrefix then let format = command.Replace(Commands.updateAudioFormatPrefix, "").ToLowerInvariant() - if String.IsNullOrEmpty format then + if String.hasNoText format then Error "You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." else let updateResult = updateAudioFormat settings format From 2d3fcd774c0647542082c05a2185fb11919613f3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:26:47 +0900 Subject: [PATCH 117/247] Clean up deleteAllFiles, etc. --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 54 +++++++++----------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 4eb22f3b..b2dc7303 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -1,12 +1,12 @@ namespace CCVTAC.Console.IoUtilities -open System open System.IO -open System.Text open CCVTAC.Console module Directories = + type private ErrorList = string ResizeArray + [] let private allFilesSearchPattern = "*" @@ -33,39 +33,34 @@ module Directories = |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) /// Deletes all files in the working directory - let deleteAllFiles showMaxErrors workingDirectory = + let deleteAllFiles showMaxErrors workingDirectory : Result = let fileNames = getDirectoryFileNames workingDirectory None - let mutable successCount = 0 - let errors = ResizeArray() // TODO: Use an F# list. - - fileNames |> Array.iter (fun fileName -> - try - File.Delete fileName - successCount <- successCount + 1 - with - | ex -> errors.Add ex.Message - ) + let successCount, errors = + Array.fold + (fun (s: uint, errs: ErrorList) fileName -> + try File.Delete fileName + (s + 1u, errs) + with ex -> errs.Add ex.Message; s, errs) + (0u, ErrorList()) + fileNames if errors.Count = 0 then Ok successCount else - let output = StringBuilder( - $"While {successCount} files were deleted successfully, some files could not be deleted:" - ) - - errors - |> Seq.truncate showMaxErrors - |> Seq.iter (fun error -> - output.AppendLine($"• {error}") |> ignore - ) - - if errors.Count > showMaxErrors then - output.AppendLine($"... plus {errors.Count - showMaxErrors} more.") |> ignore - - Error (output.ToString()) + SB($"While {successCount} files were deleted successfully, some files could not be deleted:{String.newLine}") + .AppendLine + (fileNames + |> Array.truncate showMaxErrors + |> Array.map (sprintf "• %s") + |> String.concat String.newLine) + |> fun sb -> + if errors.Count > showMaxErrors + then sb.AppendLine $"... plus {errors.Count - showMaxErrors} more." + else sb + |> _.ToString() + |> Error - /// Asks user if they want to delete all files let askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = if printer.AskToBool("Delete all temporary files?", "Yes", "No") then deleteAllFiles 10 workingDirectory @@ -79,8 +74,7 @@ module Directories = else let fileLabel = NumberUtilities.pluralize "file" "files" fileNames.Length - StringBuilder() - .AppendLine($"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":") + SB($"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":{String.newLine}") .AppendLine (fileNames |> Array.truncate showMax From bd109e51c1f36300e996e70ab3da75a98f2239e8 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:28:03 +0900 Subject: [PATCH 118/247] Use StringBuilder type alias --- src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs index 1f07c3f8..1b1351d8 100644 --- a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs +++ b/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs @@ -20,7 +20,7 @@ module MetadataUtilities = sprintf "%s/%s/%s" m d y let generateComment (v: VideoMetadata) (c: CollectionMetadata option) : string = - let sb = StringBuilder() + let sb = SB() sb.AppendLine("CCVTAC SOURCE DATA:") |> ignore sb.AppendLine $"■ Downloaded: {DateTime.Now}" |> ignore sb.AppendLine $"■ URL: %s{v.WebpageUrl}" |> ignore diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index b8810ed0..1e7c37a2 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -8,8 +8,6 @@ open System.Text open System.Text.RegularExpressions open Startwatch.Library -type SB = StringBuilder - module Renamer = let private toNormalizationForm (form: string) = From 7e58c1b60b0a94d208f7cb23cedb359570fccf01 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:32:17 +0900 Subject: [PATCH 119/247] Remove unneeded type --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index b2dc7303..3e631571 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -10,8 +10,6 @@ module Directories = [] let private allFilesSearchPattern = "*" - let private enumerationOptions = EnumerationOptions() - /// Counts the number of audio files in a directory let audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() @@ -29,7 +27,7 @@ module Directories = |> Seq.distinct |> Seq.toArray - Directory.GetFiles(directoryName, allFilesSearchPattern, enumerationOptions) + Directory.GetFiles(directoryName, allFilesSearchPattern, EnumerationOptions()) |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) /// Deletes all files in the working directory From b8d3ef7edd229c4c74f0223dfa5f5ae3bf8d0045 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:32:36 +0900 Subject: [PATCH 120/247] Add func summaries --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 3e631571..35f26b16 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -59,11 +59,13 @@ module Directories = |> _.ToString() |> Error - let askToDeleteAllFiles (workingDirectory: string) (printer: Printer) = + /// Ask the user to confirm the deletion of files in the specified directory. + let askToDeleteAllFiles dirName (printer: Printer) = if printer.AskToBool("Delete all temporary files?", "Yes", "No") - then deleteAllFiles 10 workingDirectory + then deleteAllFiles 10 dirName else Error "Will not delete the files." + /// Warn the user if there are any files in the specified directory. let warnIfAnyFiles showMax dirName = let fileNames = getDirectoryFileNames dirName None @@ -86,6 +88,7 @@ module Directories = |> _.ToString() |> Error + /// Ensures the specified directory exists, including creation of it if necessary. let ensureDirectoryExists dirName : Result = try dirName From 4bf52f4bba4fdbdd556fd6cf2ac885cdd499df5d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:37:02 +0900 Subject: [PATCH 121/247] Reorder usings --- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 57f4f647..7c1200b6 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -1,13 +1,13 @@ namespace CCVTAC.Console.PostProcessing -open System.IO -open System.Linq -open System.Text.Json -open System.Text.RegularExpressions open CCVTAC.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.Settings.Settings +open System.IO +open System.Linq +open System.Text.Json +open System.Text.RegularExpressions open Startwatch.Library open TaggingSets From 805b3a45273f4c3087ddb555d9f8d935927f1bfd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:42:50 +0900 Subject: [PATCH 122/247] Add and use isZero func --- src/CCVTAC.FSharp/InputHelper.fs | 4 ++-- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs | 3 ++- src/CCVTAC.FSharp/Printer.fs | 2 +- src/CCVTAC.FSharp/ResultTracker.fs | 2 +- src/CCVTAC.FSharp/Utilities.fs | 5 +++++ 8 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index e71f51ef..4214e551 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -18,9 +18,9 @@ module InputHelper = let splitInput (input: string) : string array = let matches = userInputRegex.Matches(input) |> Seq.cast |> Seq.toArray - if matches.Length = 0 then + if isZero matches.Length then [| |] - elif matches.Length = 1 then + elif isOne matches.Length then [| input |] else let startIndices = matches |> Array.map _.Index diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 35f26b16..38480e5c 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -43,7 +43,7 @@ module Directories = (0u, ErrorList()) fileNames - if errors.Count = 0 then + if isZero errors.Count then Ok successCount else SB($"While {successCount} files were deleted successfully, some files could not be deleted:{String.newLine}") diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 7c1200b6..a4185fbc 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -26,7 +26,7 @@ module PostProcessor = |> Seq.filter isCollectionMetadataMatch |> Set.ofSeq - if fileNames.Count = 0 then + if isZero fileNames.Count then Error "No relevant files found." elif fileNames.Count > 1 then Error "Unexpectedly found more than one relevant file, so none will be processed." diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 1e7c37a2..3df09e41 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -27,7 +27,7 @@ module Renamer = |> Seq.rev |> Seq.toList - if matches.Length = 0 + if isZero matches.Length then sb else if not userSettings.QuietMode then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index e6bf94d1..9b21d9f3 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -1,5 +1,6 @@ namespace CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.Console open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing open System @@ -72,7 +73,7 @@ module Detectors = |> Seq.distinct |> Seq.toArray - if matchedValues.Length = 0 then + if isZero matchedValues.Length then defaultValue else String.Join(separator, matchedValues) diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index c8256628..94ca6364 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -133,7 +133,7 @@ type Printer(showDebug: bool) = /// Prints the requested number of blank lines. static member EmptyLines(count: byte) = - if count = 0uy + if isZero count then () else // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 66e0d871..698b31b0 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -32,7 +32,7 @@ type ResultTracker<'a>(printer: Printer) = /// Prints any failures for the current batch. member _.PrintBatchFailures() : unit = - if failures.Count = 0 then + if isZero failures.Count then _printer.Debug("No failures in batch.") else let failureLabel = if failures.Count = 1 then "failure" else "failures" diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 3e52b165..eb411f1a 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -6,12 +6,17 @@ open System.Text type SB = StringBuilder +[] module Utilities = let ofTry (f: unit -> 'a) : Result<'a, string> = try Ok (f()) with exn -> Error exn.Message +[] module NumberUtilities = + let inline isZero (x: ^a) = + x = LanguagePrimitives.GenericZero<'a> + let inline isOne (x: ^a) = x = LanguagePrimitives.GenericOne<'a> From 1c62492a977307991f33f1a8cdd58839be3e13fd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:45:25 +0900 Subject: [PATCH 123/247] Tweak makeCommand func --- src/CCVTAC.FSharp/Commands.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index 50709305..cc809b49 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -3,15 +3,15 @@ namespace CCVTAC.Console open System open System.Collections.Generic -module internal Commands = +module Commands = - let prefix: char = '\\' + let prefix = '\\' - let private makeCommand (text: string) : string = + let private makeCommand text : string = if String.hasNoText text then - raise (ArgumentException("The text cannot be null or white space.", "text")) + raise (ArgumentException("Commands cannot be null or white space.", "text")) if text.Contains ' ' then - raise (ArgumentException("The text should not contain any white space.", "text")) + raise (ArgumentException("Commands cannot contain white space.", "text")) $"%c{prefix}%s{text}" let quitCommands: string[] = From 7ff1e52f5f7786f758fcdc3596427f0cfe469965 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:48:10 +0900 Subject: [PATCH 124/247] Convert dictionary to map --- src/CCVTAC.FSharp/Commands.fs | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index cc809b49..f0985797 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -1,7 +1,6 @@ namespace CCVTAC.Console open System -open System.Collections.Generic module Commands = @@ -36,19 +35,18 @@ module Commands = let updateAudioQualityPrefix: string = makeCommand "quality-" - let summary: Dictionary = - let d = Dictionary() - d.Add(String.Join(" or ", history), "See the most recently entered URLs") - d.Add(String.Join(" or ", splitChapterToggles), "Toggles chapter splitting for the current session only") - d.Add(String.Join(" or ", embedImagesToggles), "Toggles image embedding for the current session only") - d.Add(String.Join(" or ", quietModeToggles), "Toggles quiet mode for the current session only") - d.Add(String.Join(" or ", updateDownloader), "Updates the downloader using the command specified in the settings") - d.Add(updateAudioFormatPrefix, - sprintf "Followed by a supported audio format (e.g., %sm4a), changes the audio format for the current session only" - updateAudioFormatPrefix) - d.Add(updateAudioQualityPrefix, - sprintf "Followed by a supported audio quality (e.g., %s0), changes the audio quality for the current session only" - updateAudioQualityPrefix) - d.Add(String.Join(" or ", quitCommands), "Quit the application") - d.Add(helpCommand, "See this help message") - d + let summary: Map = + [ + String.Join(" or ", history), "See the most recently entered URLs" + String.Join(" or ", splitChapterToggles), "Toggles chapter splitting for the current session only" + String.Join(" or ", embedImagesToggles), "Toggles image embedding for the current session only" + String.Join(" or ", quietModeToggles), "Toggles quiet mode for the current session only" + String.Join(" or ", updateDownloader), "Updates the downloader using the command specified in the settings" + (updateAudioFormatPrefix, + $"Followed by a supported audio format (e.g., %s{updateAudioFormatPrefix}m4a), changes the audio format for the current session only") + (updateAudioQualityPrefix, + $"Followed by a supported audio quality (e.g., %s{updateAudioQualityPrefix}0), changes the audio quality for the current session only") + String.Join(" or ", quitCommands), "Quit the application" + helpCommand, "See this help message" + ] + |> Map.ofList From 64e131736cb681845430c4835ed911b285f8c030 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:16:39 +0900 Subject: [PATCH 125/247] Minor code layout tweaks --- src/CCVTAC.FSharp/Commands.fs | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index f0985797..e552cd34 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -6,34 +6,23 @@ module Commands = let prefix = '\\' - let private makeCommand text : string = + let private toCommand text : string = if String.hasNoText text then raise (ArgumentException("Commands cannot be null or white space.", "text")) if text.Contains ' ' then raise (ArgumentException("Commands cannot contain white space.", "text")) $"%c{prefix}%s{text}" - let quitCommands: string[] = - [| makeCommand "quit"; makeCommand "q"; makeCommand "exit" |] - - let helpCommand: string = makeCommand "help" - - let settingsSummary: string[] = [| makeCommand "settings" |] - - let history: string[] = [| makeCommand "history" |] - - let updateDownloader: string[] = - [| makeCommand "update-downloader"; makeCommand "update-dl" |] - - let splitChapterToggles: string[] = [| makeCommand "split"; makeCommand "toggle-split" |] - - let embedImagesToggles: string[] = [| makeCommand "images"; makeCommand "toggle-images" |] - - let quietModeToggles: string[] = [| makeCommand "quiet"; makeCommand "toggle-quiet" |] - - let updateAudioFormatPrefix: string = makeCommand "format-" - - let updateAudioQualityPrefix: string = makeCommand "quality-" + let quitCommands = [| toCommand "quit"; toCommand "q"; toCommand "exit" |] + let helpCommand: string = toCommand "help" + let settingsSummary = [| toCommand "settings" |] + let history = [| toCommand "history" |] + let updateDownloader = [| toCommand "update-downloader"; toCommand "update-dl" |] + let splitChapterToggles = [| toCommand "split"; toCommand "toggle-split" |] + let embedImagesToggles = [| toCommand "images"; toCommand "toggle-images" |] + let quietModeToggles = [| toCommand "quiet"; toCommand "toggle-quiet" |] + let updateAudioFormatPrefix: string = toCommand "format-" + let updateAudioQualityPrefix: string = toCommand "quality-" let summary: Map = [ From d2c8894b881870252ee4479900486d6b9752d6ca Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:19:46 +0900 Subject: [PATCH 126/247] Convert arrays to lists --- src/CCVTAC.FSharp/Commands.fs | 14 +++++++------- src/CCVTAC.FSharp/Orchestrator.fs | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index e552cd34..29b9ca9f 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -13,14 +13,14 @@ module Commands = raise (ArgumentException("Commands cannot contain white space.", "text")) $"%c{prefix}%s{text}" - let quitCommands = [| toCommand "quit"; toCommand "q"; toCommand "exit" |] + let quitCommands = [ toCommand "quit"; toCommand "q"; toCommand "exit" ] let helpCommand: string = toCommand "help" - let settingsSummary = [| toCommand "settings" |] - let history = [| toCommand "history" |] - let updateDownloader = [| toCommand "update-downloader"; toCommand "update-dl" |] - let splitChapterToggles = [| toCommand "split"; toCommand "toggle-split" |] - let embedImagesToggles = [| toCommand "images"; toCommand "toggle-images" |] - let quietModeToggles = [| toCommand "quiet"; toCommand "toggle-quiet" |] + let settingsSummary = [ toCommand "settings" ] + let history = [ toCommand "history" ] + let updateDownloader = [ toCommand "update-downloader"; toCommand "update-dl" ] + let splitChapterToggles = [ toCommand "split"; toCommand "toggle-split" ] + let embedImagesToggles = [ toCommand "images"; toCommand "toggle-images" ] + let quietModeToggles = [ toCommand "quiet"; toCommand "toggle-quiet" ] let updateAudioFormatPrefix: string = toCommand "format-" let updateAudioQualityPrefix: string = toCommand "quality-" diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 3b5a6dcc..f8ce42ff 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -146,39 +146,39 @@ module Orchestrator = Ok NextAction.Continue // Quit - elif Array.caseInsensitiveContains command Commands.quitCommands then + elif List.caseInsensitiveContains command Commands.quitCommands then Ok NextAction.QuitAtUserRequest // History - elif Array.caseInsensitiveContains command Commands.history then + elif List.caseInsensitiveContains command Commands.history then history.ShowRecent printer Ok NextAction.Continue // Update downloader - elif Array.caseInsensitiveContains command Commands.updateDownloader then + elif List.caseInsensitiveContains command Commands.updateDownloader then Updater.run settings printer |> ignore Ok NextAction.Continue // Settings summary - elif Array.caseInsensitiveContains command Commands.settingsSummary then + elif List.caseInsensitiveContains command Commands.settingsSummary then Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters - elif Array.caseInsensitiveContains command Commands.splitChapterToggles then - settings <- toggleSplitChapters(settings) + elif List.caseInsensitiveContains command Commands.splitChapterToggles then + settings <- toggleSplitChapters settings printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) Ok NextAction.Continue // Toggle embed images - elif Array.caseInsensitiveContains command Commands.embedImagesToggles then - settings <- toggleEmbedImages(settings) + elif List.caseInsensitiveContains command Commands.embedImagesToggles then + settings <- toggleEmbedImages settings printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) Ok NextAction.Continue // Toggle quiet mode - elif Array.caseInsensitiveContains command Commands.quietModeToggles then - settings <- toggleQuietMode(settings) + elif List.caseInsensitiveContains command Commands.quietModeToggles then + settings <- toggleQuietMode settings printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) printer.ShowDebug(not settings.QuietMode) Ok NextAction.Continue From 77d18f6bebffc2a0167fb46cb75736dc7afa0682 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:28:49 +0900 Subject: [PATCH 127/247] Simplify help slightly --- src/CCVTAC.FSharp/Help.fs | 166 +++++++++++++++++------------------ src/CCVTAC.FSharp/Program.fs | 4 +- 2 files changed, 84 insertions(+), 86 deletions(-) diff --git a/src/CCVTAC.FSharp/Help.fs b/src/CCVTAC.FSharp/Help.fs index 3a9da3d6..7dfc27dc 100644 --- a/src/CCVTAC.FSharp/Help.fs +++ b/src/CCVTAC.FSharp/Help.fs @@ -2,87 +2,85 @@ namespace CCVTAC.Console module Help = - let Print (printer: Printer) : unit = - let helpText = """ - CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI - tool that acts as a wrapper around yt-dlp (https://github.com/yt-dlp/yt-dlp) - to enable easier downloads of audio from YouTube videos, playlists, and - channels, plus do some automatic post-processing (tagging, renaming, and - moving) too. - - While I maintain it primarily for my own use, feel free to use it yourself. - No warranties or guarantees are provided. - - FEATURES - - - Converts YouTube videos, playlists, and channels to local audio files (via yt-dlp) - - Writes ID3 tags to files where possible using available or regex-detected metadata - - Adds video metadata (channel name and URL, video URL, etc.) to files' Comment tags - - Auto-renames files via custom regex patterns (to remove media IDs, etc.) - - Optionally writes video thumbnails to files as artwork (if mogrify is installed) - - Customized behavior via a user settings file -- e.g., chapter splitting, image embedding, directories - - Saves entered URLs to a local history file - - PREREQUISITES - - • .NET 10 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/10.0) - • yt-dlp (https://github.com/yt-dlp/yt-dlp) - • [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) - • Optional: mogrify https://imagemagick.org/script/mogrify.php - (for auto-trimming album art) - - RUNNING IT - - Settings: - - A valid settings file is mandatory to use this application. - - The application will look for a file named `settings.json` in its directory. - However, you can manually specify an existing file path using the `-s` - option, such as `dotnet run -- -s `. - - If your `settings.json` file does not exist, a default file will be created in the - application directory with default settings. At minimum, you will need to - enter (1) an existing directory for temporary working files, (2) an existing - directory to which the final audio files should be moved, and (3) a path to - your history file. The other settings have sensible defaults. - - I added the `sleepSecondsBetweenDownloads` and `sleepSecondsBetweenURLs` - settings to help reduce concentrated loads on YouTube servers. Please avoid - lowering these values too much and slamming their servers with enormous, - long-running downloads (even if you feel their servers can take it). Such behavior - might get you rate-limited by YouTube. - - See the README file on the GitHub repo for more about settings. - - Using the application: - - Once your settings are ready, run the application with `dotnet run`. - Alternatively, pass `-h` or `--help` for instructions (e.g., - `dotnet run -- --help`). - - When the application is running, enter at least one YouTube media URL (video, - playlist, or channel) at the prompt and press Enter. No spaces between - items are necessary. - - You can also enter the following commands: - - "\help" to see this list of commands - - "\quit" or "\q" to quit - - "\history" to see the last few URLs you entered - - "\update-downloader" or "\update-dl" to update yt-dlp using the command in your settings - (If you start experiencing constant download errors, try this command) - - Modify the current session only (without updating the settings file): - - `\split` toggles chapter splitting - - `\images` toggles image embedding - - `\quiet` toggles quiet mode - - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the format - - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality - - Enter `\commands` to see this summary in the application. - - Reporting issues: - - If you run into any issues, feel free to create an issue on GitHub. Please provide as much - information as possible (e.g., entered URLs, system information, yt-dlp version). - """ - printer.Info(helpText, processMarkup = false) + let helpText = """ + CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI + tool that acts as a wrapper around yt-dlp (https://github.com/yt-dlp/yt-dlp) + to enable easier downloads of audio from YouTube videos, playlists, and + channels, plus do some automatic post-processing (tagging, renaming, and + moving) too. + + While I maintain it primarily for my own use, feel free to use it yourself. + No warranties or guarantees are provided. + + FEATURES + + - Converts YouTube videos, playlists, and channels to local audio files (via yt-dlp) + - Writes ID3 tags to files where possible using available or regex-detected metadata + - Adds video metadata (channel name and URL, video URL, etc.) to files' Comment tags + - Auto-renames files via custom regex patterns (to remove media IDs, etc.) + - Optionally writes video thumbnails to files as artwork (if mogrify is installed) + - Customized behavior via a user settings file -- e.g., chapter splitting, image embedding, directories + - Saves entered URLs to a local history file + + PREREQUISITES + + • .NET 10 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/10.0) + • yt-dlp (https://github.com/yt-dlp/yt-dlp) + • [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) + • Optional: mogrify https://imagemagick.org/script/mogrify.php + (for auto-trimming album art) + + RUNNING IT + + Settings: + + A valid settings file is mandatory to use this application. + + The application will look for a file named `settings.json` in its directory. + However, you can manually specify an existing file path using the `-s` + option, such as `dotnet run -- -s `. + + If your `settings.json` file does not exist, a default file will be created in the + application directory with default settings. At minimum, you will need to + enter (1) an existing directory for temporary working files, (2) an existing + directory to which the final audio files should be moved, and (3) a path to + your history file. The other settings have sensible defaults. + + I added the `sleepSecondsBetweenDownloads` and `sleepSecondsBetweenURLs` + settings to help reduce concentrated loads on YouTube servers. Please avoid + lowering these values too much and slamming their servers with enormous, + long-running downloads (even if you feel their servers can take it). Such behavior + might get you rate-limited by YouTube. + + See the README file on the GitHub repo for more about settings. + + Using the application: + + Once your settings are ready, run the application with `dotnet run`. + Alternatively, pass `-h` or `--help` for instructions (e.g., + `dotnet run -- --help`). + + When the application is running, enter at least one YouTube media URL (video, + playlist, or channel) at the prompt and press Enter. No spaces between + items are necessary. + + You can also enter the following commands: + - "\help" to see this list of commands + - "\quit" or "\q" to quit + - "\history" to see the last few URLs you entered + - "\update-downloader" or "\update-dl" to update yt-dlp using the command in your settings + (If you start experiencing constant download errors, try this command) + - Modify the current session only (without updating the settings file): + - `\split` toggles chapter splitting + - `\images` toggles image embedding + - `\quiet` toggles quiet mode + - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the format + - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality + + Enter `\commands` to see this summary in the application. + + Reporting issues: + + If you run into any issues, feel free to create an issue on GitHub. Please provide as much + information as possible (e.g., entered URLs, system information, yt-dlp version). + """ diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 12cd87f2..9bd0b325 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -19,11 +19,11 @@ module Program = | OperationError = 2 [] - let main (args: string array) : int = + let main args : int = let printer = Printer(showDebug = true) if args.Length > 0 && Array.caseInsensitiveContains args[0] helpFlags then - Help.Print printer + printer.Info Help.helpText int ExitCodes.Success else let maybeSettingsPath = From 4fc943b0f27f54d3246a9ac9a68e24a55c3d5eef Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:37:06 +0900 Subject: [PATCH 128/247] Minor history cleanup --- src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 2 +- src/CCVTAC.FSharp/History.fs | 15 ++++++++------- src/CCVTAC.FSharp/Printer.fs | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs index 777e4f37..0786c245 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -81,7 +81,7 @@ let emptyVideoMetadata = { Type = String.Empty } -let newLine = Environment.NewLine +let newLine = String.newLine [] let ``Detects album name in video description`` () = diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 0260b478..49081cd2 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -16,17 +16,17 @@ type History(filePath: string, displayCount: byte) = member this.Append(url: string, entryTime: DateTime, printer: Printer) : unit = try let serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") - File.AppendAllText(this.FilePath, serializedEntryTime + string separator + url + Environment.NewLine) - printer.Debug (sprintf "Added \"%s\" to the history log." url) + File.AppendAllText(this.FilePath, serializedEntryTime + string separator + url + String.newLine) + printer.Debug $"Added \"%s{url}\" to the history log." with ex -> - printer.Error ("Could not append URL(s) to history log: " + ex.Message) + printer.Error $"Could not append URL(s) to history log: {ex.Message}" member this.ShowRecent(printer: Printer) : unit = try // Read lines and take the last N lines in the original order let max = int this.DisplayCount let lines = - File.ReadAllLines(this.FilePath) + File.ReadAllLines this.FilePath |> Seq.rev |> Seq.truncate max |> Seq.rev @@ -40,16 +40,17 @@ type History(filePath: string, displayCount: byte) = |> Seq.groupBy fst |> Seq.map (fun (dt, pairs) -> dt, pairs |> Seq.map snd |> Seq.toList) + // TODO: This shouldn't be here. let table = Table() table.Border <- TableBorder.None table.AddColumns("Time", "URL") |> ignore table.Columns[0].PadRight(3) |> ignore - for (dateTime, urls) in historyData do + for dateTime, urls in historyData do let formattedTime = sprintf "%s" (dateTime.ToString("yyyy-MM-dd HH:mm:ss")) - let joinedUrls = String.Join(Environment.NewLine, urls) + let joinedUrls = String.Join(String.newLine, urls) table.AddRow(formattedTime, joinedUrls) |> ignore Printer.PrintTable table with ex -> - printer.Error (sprintf "Could not display recent history: %s" ex.Message) + printer.Error $"Could not display history: %s{ex.Message}" diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index 94ca6364..919a3df2 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -140,7 +140,7 @@ type Printer(showDebug: bool) = let repeats = int count - 1 if repeats <= 0 then AnsiConsole.WriteLine() - else Enumerable.Repeat(Environment.NewLine, repeats) |> String.Concat |> AnsiConsole.WriteLine + else Enumerable.Repeat(String.newLine, repeats) |> String.Concat |> AnsiConsole.WriteLine member this.GetInput(prompt: string) : string = Printer.EmptyLines 1uy From 4973edf91ea3b68776fa2e73ed85e88438470857 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:41:49 +0900 Subject: [PATCH 129/247] Minor code reorg --- src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 1 + src/CCVTAC.FSharp/InputHelper.fs | 26 ++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs index 0786c245..b54b3d2d 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -1,5 +1,6 @@ module TagDetectionTests +open CCVTAC.Console open CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console.PostProcessing open CCVTAC.Console.Settings.Settings diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 4214e551..6375e379 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -13,6 +13,19 @@ module InputHelper = type private IndexPair = { Start: int; End: int } + type InputCategory = + | Url + | Command + + type CategorizedInput = { Text: string; Category: InputCategory } + + type CategoryCounts(counts: Map) = + member _.Item + with get (category: InputCategory) = + match counts.TryGetValue category with + | true, v -> v + | _ -> 0 + /// Takes a user input string and splits it into a collection of inputs /// based upon substrings detected by the class's regular expression pattern. let splitInput (input: string) : string array = @@ -38,12 +51,6 @@ module InputHelper = |> Array.map (fun p -> input[p.Start..(p.End - 1)].Trim()) |> Array.distinct - type InputCategory = - | Url - | Command - - type CategorizedInput = { Text: string; Category: InputCategory } - let categorizeInputs (inputs: ICollection) : CategorizedInput list = inputs |> Seq.cast @@ -55,13 +62,6 @@ module InputHelper = { Text = input; Category = category }) |> List.ofSeq - type CategoryCounts(counts: Map) = - member _.Item - with get (category: InputCategory) = - match counts.TryGetValue category with - | true, v -> v - | _ -> 0 - let countCategories (inputs: CategorizedInput seq) : CategoryCounts = let counts = inputs From 4a74ff316ba3cdb3610bdcc2d6e4e3320147c847 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:45:27 +0900 Subject: [PATCH 130/247] Delete unused pipeline code --- src/CCVTAC.FSharp/InputHelper.fs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 6375e379..dffcf9ae 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -68,9 +68,6 @@ module InputHelper = |> Seq.cast |> Seq.groupBy (fun i -> i.Category) |> Seq.map (fun (k, grp) -> k, grp |> Seq.length) - // |> dict - // :?> IDictionary - // |> fun d -> Dictionary(d) |> Map.ofSeq CategoryCounts(counts) From 45a043aa476969c53d612ee345ebe7f4b978e8d2 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:45:49 +0900 Subject: [PATCH 131/247] Minor type tweaks --- src/CCVTAC.FSharp/InputHelper.fs | 10 ++++------ src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs | 1 - 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index dffcf9ae..2dafd5bc 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -1,12 +1,10 @@ namespace CCVTAC.Console open System.Text.RegularExpressions -open System.Collections.Generic module InputHelper = - let prompt = - $"Enter one or more YouTube media URLs or commands (or \"%s{Commands.helpCommand}\"):\n▶︎" + let prompt = $"Enter one or more YouTube media URLs or commands (or \"%s{Commands.helpCommand}\"):\n▶︎" /// A regular expression that detects where commands and URLs begin in input strings. let private userInputRegex = Regex(@"(?:https:|\\)", RegexOptions.Compiled) @@ -51,7 +49,7 @@ module InputHelper = |> Array.map (fun p -> input[p.Start..(p.End - 1)].Trim()) |> Array.distinct - let categorizeInputs (inputs: ICollection) : CategorizedInput list = + let categorizeInputs (inputs: string seq) : CategorizedInput list = inputs |> Seq.cast |> Seq.map (fun input -> @@ -66,8 +64,8 @@ module InputHelper = let counts = inputs |> Seq.cast - |> Seq.groupBy (fun i -> i.Category) + |> Seq.groupBy _.Category |> Seq.map (fun (k, grp) -> k, grp |> Seq.length) |> Map.ofSeq - CategoryCounts(counts) + CategoryCounts counts diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs index b1f1e8ed..639ff3d7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs @@ -1,6 +1,5 @@ namespace CCVTAC.Console.PostProcessing -open System.Collections.Generic open System.Text.Json.Serialization type VideoMetadata = { From 5a16882b5f968ad3741e7e69f8c1220620bdcd47 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:53:02 +0900 Subject: [PATCH 132/247] Move conditional to record instantiation --- src/CCVTAC.FSharp/InputHelper.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 2dafd5bc..8d6b664a 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -11,6 +11,7 @@ module InputHelper = type private IndexPair = { Start: int; End: int } + // TODO: Add input strings? type InputCategory = | Url | Command @@ -53,11 +54,10 @@ module InputHelper = inputs |> Seq.cast |> Seq.map (fun input -> - let category = - if input.StartsWith(string Commands.prefix) - then InputCategory.Command - else InputCategory.Url - { Text = input; Category = category }) + { Text = input + Category = if input.StartsWith(string Commands.prefix) + then InputCategory.Command + else InputCategory.Url }) |> List.ofSeq let countCategories (inputs: CategorizedInput seq) : CategoryCounts = From c7d9667e572d7b0da408e093fcdea2d7fd7d382e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 09:58:09 +0900 Subject: [PATCH 133/247] Minor cleanup --- src/CCVTAC.FSharp/Commands.fs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index 29b9ca9f..cd0491f5 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -7,22 +7,20 @@ module Commands = let prefix = '\\' let private toCommand text : string = - if String.hasNoText text then - raise (ArgumentException("Commands cannot be null or white space.", "text")) - if text.Contains ' ' then - raise (ArgumentException("Commands cannot contain white space.", "text")) + if String.hasNoText text then raise (ArgumentException("Commands cannot be null or white space.", "text")) + if text.Contains ' ' then raise (ArgumentException("Commands cannot contain white space.", "text")) $"%c{prefix}%s{text}" let quitCommands = [ toCommand "quit"; toCommand "q"; toCommand "exit" ] - let helpCommand: string = toCommand "help" + let helpCommand = toCommand "help" let settingsSummary = [ toCommand "settings" ] let history = [ toCommand "history" ] let updateDownloader = [ toCommand "update-downloader"; toCommand "update-dl" ] let splitChapterToggles = [ toCommand "split"; toCommand "toggle-split" ] let embedImagesToggles = [ toCommand "images"; toCommand "toggle-images" ] let quietModeToggles = [ toCommand "quiet"; toCommand "toggle-quiet" ] - let updateAudioFormatPrefix: string = toCommand "format-" - let updateAudioQualityPrefix: string = toCommand "quality-" + let updateAudioFormatPrefix = toCommand "format-" + let updateAudioQualityPrefix = toCommand "quality-" let summary: Map = [ @@ -35,7 +33,7 @@ module Commands = $"Followed by a supported audio format (e.g., %s{updateAudioFormatPrefix}m4a), changes the audio format for the current session only") (updateAudioQualityPrefix, $"Followed by a supported audio quality (e.g., %s{updateAudioQualityPrefix}0), changes the audio quality for the current session only") - String.Join(" or ", quitCommands), "Quit the application" + String.Join(" or ", quitCommands), "Quit this application" helpCommand, "See this help message" ] |> Map.ofList From 7a0b199d1ac8ea41320b84c9bb573af3c9080dc5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 10:44:49 +0900 Subject: [PATCH 134/247] Change array to list --- src/CCVTAC.FSharp/InputHelper.fs | 33 ++++++++++++++----------------- src/CCVTAC.FSharp/Orchestrator.fs | 7 +++---- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 8d6b664a..d9d4c75f 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -6,51 +6,48 @@ module InputHelper = let prompt = $"Enter one or more YouTube media URLs or commands (or \"%s{Commands.helpCommand}\"):\n▶︎" - /// A regular expression that detects where commands and URLs begin in input strings. + /// A regular expression that detects the beginnings of URLs and application commands in input strings. let private userInputRegex = Regex(@"(?:https:|\\)", RegexOptions.Compiled) type private IndexPair = { Start: int; End: int } - // TODO: Add input strings? - type InputCategory = - | Url - | Command + type InputCategory = Url | Command type CategorizedInput = { Text: string; Category: InputCategory } - type CategoryCounts(counts: Map) = + type CategoryCounts (counts: Map) = member _.Item with get (category: InputCategory) = match counts.TryGetValue category with | true, v -> v | _ -> 0 - /// Takes a user input string and splits it into a collection of inputs - /// based upon substrings detected by the class's regular expression pattern. - let splitInput (input: string) : string array = - let matches = userInputRegex.Matches(input) |> Seq.cast |> Seq.toArray + /// Takes a user input string and splits it into a collection of inputs. + let splitInputText input : string list = + let matches = userInputRegex.Matches input |> Seq.cast |> Seq.toList if isZero matches.Length then - [| |] + [ ] elif isOne matches.Length then - [| input |] + [ input ] else - let startIndices = matches |> Array.map _.Index + let startIndices = matches |> List.map _.Index let indexPairs = startIndices - |> Array.mapi (fun idx startIndex -> + |> List.mapi (fun idx startIndex -> let endIndex = if idx = startIndices.Length - 1 then input.Length else startIndices[idx + 1] - { Start = startIndex; End = endIndex }) + { Start = startIndex + End = endIndex }) indexPairs - |> Array.map (fun p -> input[p.Start..(p.End - 1)].Trim()) - |> Array.distinct + |> List.map (fun p -> input[p.Start..(p.End - 1)].Trim()) + |> List.distinct - let categorizeInputs (inputs: string seq) : CategorizedInput list = + let categorizeInputs inputs : CategorizedInput list = inputs |> Seq.cast |> Seq.map (fun input -> diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index f8ce42ff..1c4a1278 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -22,8 +22,7 @@ module Orchestrator = (categorizedInputs: CategorizedInput list) (counts: CategoryCounts) (printer: Printer) - : unit - = + : unit = if categorizedInputs.Length > 1 then let urlCount = counts[InputCategory.Url] @@ -301,9 +300,9 @@ module Orchestrator = while nextAction = NextAction.Continue do let input = printer.GetInput prompt - let splitInputs = splitInput input + let splitInputs = splitInputText input - if Array.isEmpty splitInputs then + if List.isEmpty splitInputs then printer.Error $"Invalid input. Enter only URLs or commands beginning with \"%c{Commands.prefix}\"." else let categorizedInputs = categorizeInputs splitInputs From 8f76d97122371f17103317b63a53de600aef0093 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:13:31 +0900 Subject: [PATCH 135/247] Refactor sleep func --- src/CCVTAC.FSharp/Orchestrator.fs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 1c4a1278..6807f808 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -51,20 +51,18 @@ module Orchestrator = Printer.EmptyLines 1uy let sleep (sleepSeconds: uint16) : unit = - let mutable remainingSeconds = sleepSeconds - - AnsiConsole - .Status() - .Start($"Sleeping for %d{sleepSeconds} seconds...", - fun ctx -> - ctx.Spinner(Spinner.Known.Star) |> ignore - ctx.SpinnerStyle(Style.Parse("blue")) |> ignore - - while remainingSeconds > 0us do - ctx.Status $"Sleeping for %d{remainingSeconds} seconds..." |> ignore - remainingSeconds <- remainingSeconds - 1us - Thread.Sleep 1000 - ) + let message seconds = $"Sleeping for {seconds} seconds..." + + let rec loop (remaining: uint16) (ctx: StatusContext) = + if remaining > 0us then + ctx.Status (message remaining) |> ignore + Thread.Sleep 1000 + loop (remaining - 1us) ctx + + AnsiConsole.Status().Start((message sleepSeconds), fun ctx -> + ctx.Spinner(Spinner.Known.Star) + .SpinnerStyle(Style.Parse "blue") + |> loop sleepSeconds) let processUrl (url: string) From dbf3bda92c4dab8c319c10e7a17dd8959311c4f9 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:22:27 +0900 Subject: [PATCH 136/247] Remove unneeded null check --- src/CCVTAC.FSharp/ResultTracker.fs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 698b31b0..098c8c22 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -9,8 +9,7 @@ type ResultTracker<'a>(printer: Printer) = let failures = Dictionary() - let _printer = - if isNull (box printer) then nullArg "printer" else printer + let _printer = printer static let combineErrors (errors: string list) = String.Join(" / ", errors) From f8f7d65f8f6c3d19e2cc25b26509a471d59aad9d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:22:40 +0900 Subject: [PATCH 137/247] Use pluralize func --- src/CCVTAC.FSharp/ResultTracker.fs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 098c8c22..0c26e5cd 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -34,17 +34,17 @@ type ResultTracker<'a>(printer: Printer) = if isZero failures.Count then _printer.Debug("No failures in batch.") else - let failureLabel = if failures.Count = 1 then "failure" else "failures" + let failureLabel = pluralize "failure" "failures" failures.Count _printer.Info $"%d{failures.Count} %s{failureLabel} in this batch:" - for kvp in failures do - _printer.Warning $"- %s{kvp.Key}: %s{kvp.Value}" + for pair in failures do + _printer.Warning $"- %s{pair.Key}: %s{pair.Value}" /// Prints the output for the current application session. member _.PrintSessionSummary() : unit = - let successLabel = if successCount = 1UL then "success" else "successes" - let failureLabel = if failures.Count = 1 then "failure" else "failures" + let successLabel = pluralize "success" "successes" successCount + let failureLabel = pluralize "failure" "failures" failures.Count _printer.Info $"Quitting with %d{successCount} %s{successLabel} and %d{failures.Count} %s{failureLabel}." - for kvp in failures do - _printer.Warning $"- %s{kvp.Key}: %s{kvp.Value}" + for pair in failures do + _printer.Warning $"- %s{pair.Key}: %s{pair.Value}" From e7b77e7632f3918b4b7e691ee3c888b4a41f49ba Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:25:37 +0900 Subject: [PATCH 138/247] Remove unneeded assignment --- src/CCVTAC.FSharp/ResultTracker.fs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 0c26e5cd..b24a3d57 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -9,8 +9,6 @@ type ResultTracker<'a>(printer: Printer) = let failures = Dictionary() - let _printer = printer - static let combineErrors (errors: string list) = String.Join(" / ", errors) @@ -32,19 +30,19 @@ type ResultTracker<'a>(printer: Printer) = /// Prints any failures for the current batch. member _.PrintBatchFailures() : unit = if isZero failures.Count then - _printer.Debug("No failures in batch.") + printer.Debug "No failures in batch." else let failureLabel = pluralize "failure" "failures" failures.Count - _printer.Info $"%d{failures.Count} %s{failureLabel} in this batch:" + printer.Info $"%d{failures.Count} %s{failureLabel} in this batch:" for pair in failures do - _printer.Warning $"- %s{pair.Key}: %s{pair.Value}" + printer.Warning $"- %s{pair.Key}: %s{pair.Value}" /// Prints the output for the current application session. member _.PrintSessionSummary() : unit = let successLabel = pluralize "success" "successes" successCount let failureLabel = pluralize "failure" "failures" failures.Count - _printer.Info $"Quitting with %d{successCount} %s{successLabel} and %d{failures.Count} %s{failureLabel}." + printer.Info $"Quitting with %d{successCount} %s{successLabel} and %d{failures.Count} %s{failureLabel}." for pair in failures do - _printer.Warning $"- %s{pair.Key}: %s{pair.Value}" + printer.Warning $"- %s{pair.Key}: %s{pair.Value}" From 1e5901de2d80301ad89a0ca5653a81413c230e11 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:35:33 +0900 Subject: [PATCH 139/247] Add String.startsWithIgnoreCase; other minor cleanup --- src/CCVTAC.FSharp/Orchestrator.fs | 24 +++++++++--------------- src/CCVTAC.FSharp/Utilities.fs | 14 +++++++++++--- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 6807f808..5f9b087e 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -118,10 +118,6 @@ module Orchestrator = printer.Info $"Processed '%s{url}'%s{groupClause} in %s{jobWatch.ElapsedFriendly}." Ok NextAction.Continue - - let startsWithIgnoreCase (text: string) (prefix: string) = - text.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) - let summarizeToggle settingName setting = sprintf "%s was toggled to %s for this session." settingName (if setting then "ON" else "OFF") @@ -181,10 +177,10 @@ module Orchestrator = Ok NextAction.Continue // Update audio formats prefix - elif startsWithIgnoreCase command Commands.updateAudioFormatPrefix then - let format = command.Replace(Commands.updateAudioFormatPrefix, "").ToLowerInvariant() + elif String.startsWithIgnoreCase Commands.updateAudioFormatPrefix command then + let format = command.Replace(Commands.updateAudioFormatPrefix, String.Empty).ToLowerInvariant() if String.hasNoText format then - Error "You must append one or more supported audio format separated by commas (e.g., \"m4a,opus,best\")." + Error "You must append one or more supported audio formats separated by commas (e.g., \"m4a,opus,best\")." else let updateResult = updateAudioFormat settings format match updateResult with @@ -200,9 +196,9 @@ module Orchestrator = // Ok NextAction.Continue // Update audio quality prefix - elif startsWithIgnoreCase command Commands.updateAudioQualityPrefix then + elif String.startsWithIgnoreCase Commands.updateAudioQualityPrefix command then let inputQuality = command.Replace(Commands.updateAudioQualityPrefix, "") - if String.IsNullOrEmpty inputQuality then + if String.hasNoText inputQuality then Error "You must enter a number representing an audio quality." else match Byte.TryParse inputQuality with @@ -224,9 +220,9 @@ module Orchestrator = // Unknown command else - Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." command (string Commands. - prefix - )) + Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." + command + (string Commands.prefix)) /// Processes a single user request, from input to downloading and file post-processing. @@ -242,7 +238,7 @@ module Orchestrator = let inputTime = DateTime.Now let watch = Watch() - let batchResults = ResultTracker(printer) + let batchResults = ResultTracker printer let mutable nextAction = NextAction.Continue let mutable inputIndex = 0 @@ -288,7 +284,6 @@ module Orchestrator = | Error err -> printer.Error err printer.Info "Aborting..." - () | Ok () -> () let results = ResultTracker printer @@ -308,7 +303,6 @@ module Orchestrator = summarizeInput categorizedInputs categoryCounts printer - // ProcessBatch may modify settings; reflect that by using a mutable reference nextAction <- processBatch categorizedInputs categoryCounts &settingsRef results history printer results.PrintSessionSummary() diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index eb411f1a..b348b79a 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -8,12 +8,14 @@ type SB = StringBuilder [] module Utilities = + let ofTry (f: unit -> 'a) : Result<'a, string> = try Ok (f()) with exn -> Error exn.Message [] module NumberUtilities = + let inline isZero (x: ^a) = x = LanguagePrimitives.GenericZero<'a> @@ -45,11 +47,14 @@ module String = let equalIgnoringCase x y = String.Equals(x, y, StringComparison.OrdinalIgnoreCase) - let endsWithIgnoringCase endingText (text: string) = - text.EndsWith(endingText, StringComparison.InvariantCultureIgnoreCase) + let startsWithIgnoreCase startText (text: string) = + text.StartsWith(startText, StringComparison.InvariantCultureIgnoreCase) + + let endsWithIgnoringCase endText (text: string) = + text.EndsWith(endText, StringComparison.InvariantCultureIgnoreCase) let inline fileLabel count = - NumberUtilities.pluralize "file" "files" count + pluralize "file" "files" count /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. @@ -88,16 +93,19 @@ module String = text.TrimEnd(newLine.ToCharArray()) module Seq = + let caseInsensitiveContains text (xs: string seq) : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) module List = + let isNotEmpty l = not (List.isEmpty l) let caseInsensitiveContains text (xs: string list) : bool = xs |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) module Array = + let doesNotContain x arr = Array.contains x arr |> not let caseInsensitiveContains text (xs: string array) : bool = From 31d4251f306601ab9e38cc9597d763a3d218370a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:38:20 +0900 Subject: [PATCH 140/247] Remove unused enum values --- src/CCVTAC.FSharp/Orchestrator.fs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 5f9b087e..6b4b042c 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -14,9 +14,9 @@ open System.Threading module Orchestrator = type NextAction = - | Continue = 0uy - | QuitAtUserRequest = 1uy - | QuitDueToErrors = 2uy + | Continue + | QuitAtUserRequest + | QuitDueToErrors let summarizeInput (categorizedInputs: CategorizedInput list) From e93945fa00f707f3f360f62d6716ee14d4024c8b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:50:56 +0900 Subject: [PATCH 141/247] Program module tweaks; new array utility funcs --- src/CCVTAC.FSharp/Program.fs | 14 +++++++------- src/CCVTAC.FSharp/Settings/Settings.fs | 10 +++++----- src/CCVTAC.FSharp/Utilities.fs | 4 ++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 9bd0b325..f945c798 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -1,11 +1,11 @@ namespace CCVTAC.Console -open System -open Spectre.Console open CCVTAC.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings +open System +open Spectre.Console module Program = @@ -22,18 +22,18 @@ module Program = let main args : int = let printer = Printer(showDebug = true) - if args.Length > 0 && Array.caseInsensitiveContains args[0] helpFlags then + if Array.isNotEmpty args && Array.caseInsensitiveContains args[0] helpFlags then printer.Info Help.helpText int ExitCodes.Success else - let maybeSettingsPath = - if args.Length >= 2 && Array.caseInsensitiveContains args[0] settingsFileFlags then + let settingsPath = + if Array.hasMultiple args && Array.caseInsensitiveContains args[0] settingsFileFlags then args[1] // Expected to be a settings file path else defaultSettingsFileName + |> FilePath - // match SettingsAdapter.ProcessSettings(maybeSettingsPath, printer) with - let readResult = Settings.IO.read (FilePath maybeSettingsPath) + let readResult = Settings.IO.read settingsPath match readResult with | Error e -> printer.Error e diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 66541be4..4b58cde2 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -167,19 +167,19 @@ module Settings = | :? JsonException as e -> Error $"Parse error in \"{path}\": {e.Message}" | e -> Error $"Unexpected error reading from \"{path}\": {e.Message}" - let private writeFile (FilePath file) settings = + let private writeFile (FilePath filePath) settings = let unicodeEncoder = JavaScriptEncoder.Create UnicodeRanges.All let writeIndented = true let options = JsonSerializerOptions(WriteIndented = writeIndented, Encoder = unicodeEncoder) try let json = JsonSerializer.Serialize(settings, options) - File.WriteAllText(file, json) - Ok $"A new settings file was saved to \"{file}\". Please populate it with your desired settings." + File.WriteAllText(filePath, json) + Ok $"A new settings file was saved to \"{filePath}\". Please populate it with your desired settings." with - | :? FileNotFoundException -> Error $"File \"{file}\" was not found." + | :? FileNotFoundException -> Error $"File \"{filePath}\" was not found." | :? JsonException -> Error "Failure parsing user settings to JSON." - | e -> Error $"Unexpected error writing \"{file}\": {e.Message}" + | e -> Error $"Unexpected error writing \"{filePath}\": {e.Message}" let writeDefaultFile (filePath: FilePath option) defaultFileName = let confirmedPath = diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index b348b79a..95573710 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -106,6 +106,10 @@ module List = module Array = + let isNotEmpty arr = not <| Array.isEmpty arr + + let hasMultiple arr = arr |> Array.length |> (<) 1 + let doesNotContain x arr = Array.contains x arr |> not let caseInsensitiveContains text (xs: string array) : bool = From 32415dc0d9258d18755a59660df2daa5797ad399 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:31:00 +0900 Subject: [PATCH 142/247] Write empty settings files, etc. --- src/CCVTAC.FSharp/Orchestrator.fs | 6 +- src/CCVTAC.FSharp/Program.fs | 71 ++++++++++--------- src/CCVTAC.FSharp/Settings/Settings.fs | 98 +++++++++++--------------- src/CCVTAC.FSharp/Utilities.fs | 2 +- 4 files changed, 85 insertions(+), 92 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 6b4b042c..11a75ec1 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -176,7 +176,7 @@ module Orchestrator = printer.ShowDebug(not settings.QuietMode) Ok NextAction.Continue - // Update audio formats prefix + // Update audio formats elif String.startsWithIgnoreCase Commands.updateAudioFormatPrefix command then let format = command.Replace(Commands.updateAudioFormatPrefix, String.Empty).ToLowerInvariant() if String.hasNoText format then @@ -195,7 +195,7 @@ module Orchestrator = // printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) // Ok NextAction.Continue - // Update audio quality prefix + // Update audio quality elif String.startsWithIgnoreCase Commands.updateAudioQualityPrefix command then let inputQuality = command.Replace(Commands.updateAudioQualityPrefix, "") if String.hasNoText inputQuality then @@ -300,9 +300,7 @@ module Orchestrator = else let categorizedInputs = categorizeInputs splitInputs let categoryCounts = countCategories categorizedInputs - summarizeInput categorizedInputs categoryCounts printer - nextAction <- processBatch categorizedInputs categoryCounts &settingsRef results history printer results.PrintSessionSummary() diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index f945c798..17f5f826 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -1,5 +1,6 @@ namespace CCVTAC.Console +open System.IO open CCVTAC.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings @@ -28,41 +29,47 @@ module Program = else let settingsPath = if Array.hasMultiple args && Array.caseInsensitiveContains args[0] settingsFileFlags then - args[1] // Expected to be a settings file path + args[1] // Expected to be a settings file path. else defaultSettingsFileName - |> FilePath + |> FileInfo - let readResult = Settings.IO.read settingsPath - match readResult with - | Error e -> - printer.Error e - int ExitCodes.ArgError - // | Ok None -> - // // A new settings file was created; nothing more to do - // 0 - | Ok settings -> - Settings.printSummary settings printer (Some "Settings loaded OK.") - printer.ShowDebug(not settings.QuietMode) + if not settingsPath.Exists then + match Settings.IO.writeDefaultFile settingsPath with + | Ok msg -> + printer.Info msg + int ExitCodes.Success + | Error err -> + printer.Error err + int ExitCodes.OperationError + else + let readResult = Settings.IO.read settingsPath + match readResult with + | Error e -> + printer.Error e + int ExitCodes.ArgError + | Ok settings -> + Settings.printSummary settings printer (Some "Settings loaded OK.") + printer.ShowDebug(not settings.QuietMode) - // Catch Ctrl-C (SIGINT) - Console.CancelKeyPress.Add(fun _ -> - printer.Warning("\nQuitting at user's request.") + // Catch Ctrl-C (SIGINT) + Console.CancelKeyPress.Add(fun _ -> + printer.Warning($"{String.newLine}Quitting at user's request.") - match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with - | Ok () -> () - | Error warnResult -> - printer.Error warnResult - match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with - | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." - | Error delErr -> printer.Error delErr - ) + match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with + | Ok () -> () + | Error warnResult -> + printer.Error warnResult + match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with + | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." + | Error delErr -> printer.Error delErr + ) - try - Orchestrator.start settings printer - int ExitCodes.Success - with ex -> - printer.Critical $"Fatal error: %s{ex.Message}" - AnsiConsole.WriteException ex - printer.Info "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." - int ExitCodes.OperationError + try + Orchestrator.start settings printer + int ExitCodes.Success + with ex -> + printer.Critical $"Fatal error: %s{ex.Message}" + AnsiConsole.WriteException ex + printer.Info "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." + int ExitCodes.OperationError diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 4b58cde2..83939bfd 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -7,8 +7,6 @@ open Spectre.Console module Settings = - type FilePath = FilePath of string - type RenamePattern = { [] RegexPattern : string [] ReplaceWithPattern : string @@ -50,13 +48,40 @@ module Settings = [] DownloaderUpdateCommand : string } + let private defaultSettings = + { + WorkingDirectory = String.Empty + MoveToDirectory = String.Empty + HistoryFile = String.Empty + HistoryDisplayCount = 25uy // byte + SplitChapters = true + SleepSecondsBetweenDownloads = 10us + SleepSecondsBetweenURLs = 15us + AudioFormats = [||] + AudioQuality = 0uy + QuietMode = false + EmbedImages = true + DoNotEmbedImageUploaders = [||] + IgnoreUploadYearUploaders = [||] + TagDetectionPatterns = { + Title = [||] + Artist = [||] + Album = [||] + Composer = [||] + Year = [||] + } + RenamePatterns = [||] + NormalizationForm = "C" // Recommended for compatibility between Linux and macOS. + DownloaderUpdateCommand = String.Empty + } + let summarize settings : (string * string) list = let onOrOff = function | true -> "ON" | false -> "OFF" let pluralize label count = - NumberUtilities.pluralize label $"{label}s" count + pluralize label $"{label}s" count |> sprintf "%d %s" count let tagDetectionPatternCount (patterns: TagDetectionPatterns) = @@ -115,11 +140,11 @@ module Settings = match settings with | { WorkingDirectory = dir } when String.hasNoText dir -> - Error "No working directory was specified." + Error "No working directory was specified in the settings." | { WorkingDirectory = dir } when dirMissing dir -> Error $"Working directory \"{dir}\" is missing." | { MoveToDirectory = dir } when String.hasNoText dir -> - Error "No move-to directory was specified." + Error "No move-to directory was specified in the settings." | { MoveToDirectory = dir } when dirMissing dir -> Error $"Move-to directory \"{dir}\" is missing." | { AudioQuality = q } when q < 0uy || q > 10uy -> @@ -151,70 +176,33 @@ module Settings = | s -> Ok s with e -> Error e.Message - let fileExists (FilePath path) = - match File.Exists path with - | true -> Ok() - | false -> Error $"File \"{path}\" does not exist." - - let read (FilePath path) = + let read (fileInfo: FileInfo) : Result = try - path + fileInfo.FullName |> File.ReadAllText |> deserialize |> Result.bind validate with - | :? FileNotFoundException -> Error $"File \"{path}\" was not found." - | :? JsonException as e -> Error $"Parse error in \"{path}\": {e.Message}" - | e -> Error $"Unexpected error reading from \"{path}\": {e.Message}" + | :? FileNotFoundException -> Error $"File \"{fileInfo.FullName}\" was not found." + | :? JsonException as e -> Error $"Parse error in \"{fileInfo.FullName}\": {e.Message}" + | e -> Error $"Unexpected error reading from \"{fileInfo.FullName}\": {e.Message}" - let private writeFile (FilePath filePath) settings = + let private writeFile (filePath: FileInfo) settings : Result = let unicodeEncoder = JavaScriptEncoder.Create UnicodeRanges.All let writeIndented = true let options = JsonSerializerOptions(WriteIndented = writeIndented, Encoder = unicodeEncoder) try let json = JsonSerializer.Serialize(settings, options) - File.WriteAllText(filePath, json) - Ok $"A new settings file was saved to \"{filePath}\". Please populate it with your desired settings." + File.WriteAllText(filePath.FullName, json) + Ok $"A new settings file was saved to \"{filePath.FullName}\". Please populate it with your desired settings." with - | :? FileNotFoundException -> Error $"File \"{filePath}\" was not found." + | :? FileNotFoundException -> Error $"File \"{filePath.FullName}\" was not found." | :? JsonException -> Error "Failure parsing user settings to JSON." - | e -> Error $"Unexpected error writing \"{filePath}\": {e.Message}" - - let writeDefaultFile (filePath: FilePath option) defaultFileName = - let confirmedPath = - match filePath with - | Some p -> p - | None -> FilePath (Path.Combine(AppContext.BaseDirectory, defaultFileName)) - - let defaultSettings = - { - WorkingDirectory = String.Empty - MoveToDirectory = String.Empty - HistoryFile = String.Empty - HistoryDisplayCount = 25uy // byte - SplitChapters = true - SleepSecondsBetweenDownloads = 10us - SleepSecondsBetweenURLs = 15us - AudioFormats = [||] - AudioQuality = 0uy - QuietMode = false - EmbedImages = true - DoNotEmbedImageUploaders = [||] - IgnoreUploadYearUploaders = [||] - TagDetectionPatterns = { - Title = [||] - Artist = [||] - Album = [||] - Composer = [||] - Year = [||] - } - RenamePatterns = [||] - NormalizationForm = "C" // Recommended for compatibility between Linux and macOS. - DownloaderUpdateCommand = String.Empty - } - - writeFile confirmedPath defaultSettings + | e -> Error $"Unexpected error writing \"{filePath.FullName}\": {e.Message}" + + let writeDefaultFile (fileInfo: FileInfo) : Result = + writeFile fileInfo defaultSettings module LiveUpdating = open Validation diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 95573710..509b2bc7 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -113,4 +113,4 @@ module Array = let doesNotContain x arr = Array.contains x arr |> not let caseInsensitiveContains text (xs: string array) : bool = - xs |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) + xs |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) From cbb51266017cb6f68b2f6fcd8053eeb6532210b6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:48:20 +0900 Subject: [PATCH 143/247] Add settings using --- src/CCVTAC.FSharp/Program.fs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 17f5f826..1c41d00b 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -1,11 +1,12 @@ namespace CCVTAC.Console -open System.IO open CCVTAC.Console open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings +open Settings.IO open System +open System.IO open Spectre.Console module Program = @@ -35,7 +36,7 @@ module Program = |> FileInfo if not settingsPath.Exists then - match Settings.IO.writeDefaultFile settingsPath with + match writeDefaultFile settingsPath with | Ok msg -> printer.Info msg int ExitCodes.Success @@ -43,13 +44,12 @@ module Program = printer.Error err int ExitCodes.OperationError else - let readResult = Settings.IO.read settingsPath - match readResult with - | Error e -> - printer.Error e + match read settingsPath with + | Error err -> + printer.Error err int ExitCodes.ArgError | Ok settings -> - Settings.printSummary settings printer (Some "Settings loaded OK.") + printSummary settings printer (Some "Settings loaded OK.") printer.ShowDebug(not settings.QuietMode) // Catch Ctrl-C (SIGINT) From 78d5262454362ee641cc45803fb5befed922c7d5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:51:10 +0900 Subject: [PATCH 144/247] Tweak func --- src/CCVTAC.FSharp/Utilities.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 509b2bc7..9c3c64cc 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -110,7 +110,7 @@ module Array = let hasMultiple arr = arr |> Array.length |> (<) 1 - let doesNotContain x arr = Array.contains x arr |> not + let doesNotContain x arr = not <| Array.contains x arr let caseInsensitiveContains text (xs: string array) : bool = xs |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) From fdd18b894ec58ada396453cdcd95f055db50c4bc Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:52:53 +0900 Subject: [PATCH 145/247] Minor code layout tweak --- src/CCVTAC.FSharp/Program.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 1c41d00b..26accd07 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -29,11 +29,11 @@ module Program = int ExitCodes.Success else let settingsPath = - if Array.hasMultiple args && Array.caseInsensitiveContains args[0] settingsFileFlags then - args[1] // Expected to be a settings file path. - else - defaultSettingsFileName - |> FileInfo + FileInfo <| + if Array.hasMultiple args && Array.caseInsensitiveContains args[0] settingsFileFlags then + args[1] // Expected to be a settings file path. + else + defaultSettingsFileName if not settingsPath.Exists then match writeDefaultFile settingsPath with From 6752a7990110afcc649955f57145093843e16fe5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:29:29 +0900 Subject: [PATCH 146/247] Add Seq tests --- .../CCVTAC.FSharp.Tests.fsproj | 1 + src/CCVTAC.FSharp.Tests/UtilitiesTests.fs | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/CCVTAC.FSharp.Tests/UtilitiesTests.fs diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj index e08a8441..8c93222b 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs b/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs new file mode 100644 index 00000000..0dbcd63b --- /dev/null +++ b/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs @@ -0,0 +1,46 @@ +module UtilitiesTests + +open CCVTAC.Console +open Xunit +open System + +module SeqTests = + + [] + let ``caseInsensitiveContains returns true when exact match exists`` () = + let input = ["Hello"; "World"; "Test"] + Seq.caseInsensitiveContains "Hello" input |> Assert.True + Seq.caseInsensitiveContains "World" input |> Assert.True + Seq.caseInsensitiveContains "Test" input |> Assert.True + + [] + let ``caseInsensitiveContains returns true when exists but case differs`` () = + let input = ["hello"; "WORLD"; "test"] + Seq.caseInsensitiveContains "Hello" input |> Assert.True + Seq.caseInsensitiveContains "hello" input |> Assert.True + Seq.caseInsensitiveContains "HELLO" input |> Assert.True + Seq.caseInsensitiveContains "wOrLd" input |> Assert.True + Seq.caseInsensitiveContains "tESt" input |> Assert.True + Seq.caseInsensitiveContains "TEST" input |> Assert.True + + [] + let ``caseInsensitiveContains returns false when text not in sequence`` () = + let input = ["Hello"; "World"; "Test"] + Seq.caseInsensitiveContains "Missing" input |> Assert.False + + [] + let ``caseInsensitiveContains works with empty sequence`` () = + let input = [] + Seq.caseInsensitiveContains "Any" input |> Assert.False + + [] + let ``caseInsensitiveContains handles null or empty strings`` () = + let input = [String.Empty; null; "Test"] + Seq.caseInsensitiveContains String.Empty input |> Assert.True + Seq.caseInsensitiveContains null input |> Assert.True + + [] + let ``caseInsensitiveContains handles Japanese strings`` () = + let input = ["関数型プログラミング"; "楽しいぞ"] + Seq.caseInsensitiveContains "関数型プログラミング" input |> Assert.True + Seq.caseInsensitiveContains "いや、楽しくないや" input |> Assert.False From f89a3c26bde149d52f99b5be986b72ba825ae639 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:29:37 +0900 Subject: [PATCH 147/247] Rename test --- src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs index b54b3d2d..a916a5f8 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -85,7 +85,7 @@ let emptyVideoMetadata = { let newLine = String.newLine [] -let ``Detects album name in video description`` () = +let ``Tag detection patterns detect metadata in video metadata`` () = let testArtist = "Test Artist Name (日本語入り)" let testAlbum = "Test Album Name (日本語入り)" let testTitle = "Test Title (日本語入り)" From 40dfebe834a541998cc79cdb55ce9752c31d8099 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:56:09 +0900 Subject: [PATCH 148/247] Add more utility func tests --- src/CCVTAC.FSharp.Tests/UtilitiesTests.fs | 129 +++++++++++++++++++++- 1 file changed, 124 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs b/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs index 0dbcd63b..ed7f6430 100644 --- a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs +++ b/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs @@ -4,6 +4,59 @@ open CCVTAC.Console open Xunit open System +module NumberUtilitiesTests = + + [] + let ``isZero returns true for any zero value`` () = + Assert.True <| NumberUtilities.isZero 0 + Assert.True <| NumberUtilities.isZero 0u + Assert.True <| NumberUtilities.isZero 0us + Assert.True <| NumberUtilities.isZero 0. + Assert.True <| NumberUtilities.isZero 0L + Assert.True <| NumberUtilities.isZero 0m + Assert.True <| NumberUtilities.isZero -0 + Assert.True <| NumberUtilities.isZero -0. + Assert.True <| NumberUtilities.isZero -0L + Assert.True <| NumberUtilities.isZero -0m + + [] + let ``isZero returns false for any non-zero value`` () = + Assert.False <| NumberUtilities.isZero 1 + Assert.False <| NumberUtilities.isOne -1 + Assert.False <| NumberUtilities.isOne Int64.MinValue + Assert.False <| NumberUtilities.isOne Int64.MaxValue + Assert.False <| NumberUtilities.isOne 2 + Assert.False <| NumberUtilities.isZero 1u + Assert.False <| NumberUtilities.isZero 1us + Assert.False <| NumberUtilities.isZero 0.0000000000001 + Assert.False <| NumberUtilities.isZero 1. + Assert.False <| NumberUtilities.isZero 1L + Assert.False <| NumberUtilities.isZero 1m + + [] + let ``isOne returns true for any one value`` () = + Assert.True <| NumberUtilities.isOne 1 + Assert.True <| NumberUtilities.isOne 1u + Assert.True <| NumberUtilities.isOne 1us + Assert.True <| NumberUtilities.isOne 1. + Assert.True <| NumberUtilities.isOne 1L + Assert.True <| NumberUtilities.isOne 1m + + [] + let ``isOne returns false for any non-one value`` () = + Assert.False <| NumberUtilities.isOne 0 + Assert.False <| NumberUtilities.isOne -1 + Assert.False <| NumberUtilities.isOne Int64.MinValue + Assert.False <| NumberUtilities.isOne Int64.MaxValue + Assert.False <| NumberUtilities.isOne 2 + Assert.False <| NumberUtilities.isOne 0u + Assert.False <| NumberUtilities.isOne 16u + Assert.False <| NumberUtilities.isOne 0us + Assert.False <| NumberUtilities.isOne -0. + Assert.False <| NumberUtilities.isOne 0.001 + Assert.False <| NumberUtilities.isOne 0L + Assert.False <| NumberUtilities.isOne 0m + module SeqTests = [] @@ -11,7 +64,7 @@ module SeqTests = let input = ["Hello"; "World"; "Test"] Seq.caseInsensitiveContains "Hello" input |> Assert.True Seq.caseInsensitiveContains "World" input |> Assert.True - Seq.caseInsensitiveContains "Test" input |> Assert.True + Seq.caseInsensitiveContains "Test" input |> Assert.True [] let ``caseInsensitiveContains returns true when exists but case differs`` () = @@ -20,8 +73,8 @@ module SeqTests = Seq.caseInsensitiveContains "hello" input |> Assert.True Seq.caseInsensitiveContains "HELLO" input |> Assert.True Seq.caseInsensitiveContains "wOrLd" input |> Assert.True - Seq.caseInsensitiveContains "tESt" input |> Assert.True - Seq.caseInsensitiveContains "TEST" input |> Assert.True + Seq.caseInsensitiveContains "tESt" input |> Assert.True + Seq.caseInsensitiveContains "TEST" input |> Assert.True [] let ``caseInsensitiveContains returns false when text not in sequence`` () = @@ -30,8 +83,7 @@ module SeqTests = [] let ``caseInsensitiveContains works with empty sequence`` () = - let input = [] - Seq.caseInsensitiveContains "Any" input |> Assert.False + Seq.caseInsensitiveContains "Any" [] |> Assert.False [] let ``caseInsensitiveContains handles null or empty strings`` () = @@ -44,3 +96,70 @@ module SeqTests = let input = ["関数型プログラミング"; "楽しいぞ"] Seq.caseInsensitiveContains "関数型プログラミング" input |> Assert.True Seq.caseInsensitiveContains "いや、楽しくないや" input |> Assert.False + +module ListTests = + + [] + let ``caseInsensitiveContains returns true when exact match exists`` () = + let input = ["Hello"; "World"; "Test"] + List.caseInsensitiveContains "Hello" input |> Assert.True + List.caseInsensitiveContains "World" input |> Assert.True + List.caseInsensitiveContains "Test" input |> Assert.True + + [] + let ``caseInsensitiveContains returns true when exists but case differs`` () = + let input = ["hello"; "WORLD"; "test"] + List.caseInsensitiveContains "Hello" input |> Assert.True + List.caseInsensitiveContains "hello" input |> Assert.True + List.caseInsensitiveContains "HELLO" input |> Assert.True + List.caseInsensitiveContains "wOrLd" input |> Assert.True + List.caseInsensitiveContains "tESt" input |> Assert.True + List.caseInsensitiveContains "TEST" input |> Assert.True + + [] + let ``caseInsensitiveContains returns false when text not in sequence`` () = + let input = ["Hello"; "World"; "Test"] + List.caseInsensitiveContains "Missing" input |> Assert.False + + [] + let ``caseInsensitiveContains works with empty sequence`` () = + List.caseInsensitiveContains "Any" [] |> Assert.False + + [] + let ``caseInsensitiveContains handles null or empty strings`` () = + let input = [String.Empty; null; "Test"] + List.caseInsensitiveContains String.Empty input |> Assert.True + List.caseInsensitiveContains null input |> Assert.True + + [] + let ``caseInsensitiveContains handles Japanese strings`` () = + let input = ["関数型プログラミング"; "楽しいぞ"] + List.caseInsensitiveContains "関数型プログラミング" input |> Assert.True + List.caseInsensitiveContains "いや、楽しくないや" input |> Assert.False + +module ArrayTests = + + module HasMultiple = + + [] + let ``hasMultiple returns true for array with more than one element`` () = + Array.hasMultiple [| 1; 2; 3 |] |> Assert.True + + [] + let ``hasMultiple returns false for empty array`` () = + Array.hasMultiple [||] |> Assert.False + + [] + let ``hasMultiple returns false for single-element array`` () = + Array.hasMultiple [| 0 |] |> Assert.False + + [] + let ``hasMultiple works with different types of arrays`` () = + Array.hasMultiple [| "hello"; "world" |] |> Assert.True + Array.hasMultiple [| 1.0; 2.0; 3.0 |] |> Assert.True + Array.hasMultiple [| false; true; true |] |> Assert.True + Array.hasMultiple [| Array.sum; Array.length |] |> Assert.True + + [] + let ``hasMultiple handles large arrays`` () = + Array.init 100 id |> Array.hasMultiple |> Assert.True From cebf6a4b705a032c7fec82283a4243bdb94b7cae Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:39:27 +0900 Subject: [PATCH 149/247] Move assertions to beginning of lines --- src/CCVTAC.FSharp.Tests/UtilitiesTests.fs | 77 ++++++++++++----------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs b/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs index ed7f6430..4e697da6 100644 --- a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs +++ b/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs @@ -28,6 +28,7 @@ module NumberUtilitiesTests = Assert.False <| NumberUtilities.isOne 2 Assert.False <| NumberUtilities.isZero 1u Assert.False <| NumberUtilities.isZero 1us + Assert.False <| NumberUtilities.isZero -0.0000000000001 Assert.False <| NumberUtilities.isZero 0.0000000000001 Assert.False <| NumberUtilities.isZero 1. Assert.False <| NumberUtilities.isZero 1L @@ -62,80 +63,80 @@ module SeqTests = [] let ``caseInsensitiveContains returns true when exact match exists`` () = let input = ["Hello"; "World"; "Test"] - Seq.caseInsensitiveContains "Hello" input |> Assert.True - Seq.caseInsensitiveContains "World" input |> Assert.True - Seq.caseInsensitiveContains "Test" input |> Assert.True + Assert.True <| Seq.caseInsensitiveContains "Hello" input + Assert.True <| Seq.caseInsensitiveContains "World" input + Assert.True <| Seq.caseInsensitiveContains "Test" input [] let ``caseInsensitiveContains returns true when exists but case differs`` () = let input = ["hello"; "WORLD"; "test"] - Seq.caseInsensitiveContains "Hello" input |> Assert.True - Seq.caseInsensitiveContains "hello" input |> Assert.True - Seq.caseInsensitiveContains "HELLO" input |> Assert.True - Seq.caseInsensitiveContains "wOrLd" input |> Assert.True - Seq.caseInsensitiveContains "tESt" input |> Assert.True - Seq.caseInsensitiveContains "TEST" input |> Assert.True + Assert.True <| Seq.caseInsensitiveContains "Hello" input + Assert.True <| Seq.caseInsensitiveContains "hello" input + Assert.True <| Seq.caseInsensitiveContains "HELLO" input + Assert.True <| Seq.caseInsensitiveContains "wOrLd" input + Assert.True <| Seq.caseInsensitiveContains "tESt" input + Assert.True <| Seq.caseInsensitiveContains "TEST" input [] let ``caseInsensitiveContains returns false when text not in sequence`` () = let input = ["Hello"; "World"; "Test"] - Seq.caseInsensitiveContains "Missing" input |> Assert.False + Assert.False <| Seq.caseInsensitiveContains "Missing" input [] let ``caseInsensitiveContains works with empty sequence`` () = - Seq.caseInsensitiveContains "Any" [] |> Assert.False + Assert.False <| Seq.caseInsensitiveContains "Any" [] [] let ``caseInsensitiveContains handles null or empty strings`` () = let input = [String.Empty; null; "Test"] - Seq.caseInsensitiveContains String.Empty input |> Assert.True - Seq.caseInsensitiveContains null input |> Assert.True + Assert.True <| Seq.caseInsensitiveContains String.Empty input + Assert.True <| Seq.caseInsensitiveContains null input [] let ``caseInsensitiveContains handles Japanese strings`` () = let input = ["関数型プログラミング"; "楽しいぞ"] - Seq.caseInsensitiveContains "関数型プログラミング" input |> Assert.True - Seq.caseInsensitiveContains "いや、楽しくないや" input |> Assert.False + Assert.True <| Seq.caseInsensitiveContains "関数型プログラミング" input + Assert.False <| Seq.caseInsensitiveContains "いや、楽しくないや" input module ListTests = [] let ``caseInsensitiveContains returns true when exact match exists`` () = let input = ["Hello"; "World"; "Test"] - List.caseInsensitiveContains "Hello" input |> Assert.True - List.caseInsensitiveContains "World" input |> Assert.True - List.caseInsensitiveContains "Test" input |> Assert.True + Assert.True <| List.caseInsensitiveContains "Hello" input + Assert.True <| List.caseInsensitiveContains "World" input + Assert.True <| List.caseInsensitiveContains "Test" input [] let ``caseInsensitiveContains returns true when exists but case differs`` () = let input = ["hello"; "WORLD"; "test"] - List.caseInsensitiveContains "Hello" input |> Assert.True - List.caseInsensitiveContains "hello" input |> Assert.True - List.caseInsensitiveContains "HELLO" input |> Assert.True - List.caseInsensitiveContains "wOrLd" input |> Assert.True - List.caseInsensitiveContains "tESt" input |> Assert.True - List.caseInsensitiveContains "TEST" input |> Assert.True + Assert.True <| List.caseInsensitiveContains "Hello" input + Assert.True <| List.caseInsensitiveContains "hello" input + Assert.True <| List.caseInsensitiveContains "HELLO" input + Assert.True <| List.caseInsensitiveContains "wOrLd" input + Assert.True <| List.caseInsensitiveContains "tESt" input + Assert.True <| List.caseInsensitiveContains "TEST" input [] let ``caseInsensitiveContains returns false when text not in sequence`` () = let input = ["Hello"; "World"; "Test"] - List.caseInsensitiveContains "Missing" input |> Assert.False + Assert.False <| List.caseInsensitiveContains "Missing" input [] let ``caseInsensitiveContains works with empty sequence`` () = - List.caseInsensitiveContains "Any" [] |> Assert.False + Assert.False <| List.caseInsensitiveContains "Any" [] [] let ``caseInsensitiveContains handles null or empty strings`` () = let input = [String.Empty; null; "Test"] - List.caseInsensitiveContains String.Empty input |> Assert.True - List.caseInsensitiveContains null input |> Assert.True + Assert.True <| List.caseInsensitiveContains String.Empty input + Assert.True <| List.caseInsensitiveContains null input [] let ``caseInsensitiveContains handles Japanese strings`` () = let input = ["関数型プログラミング"; "楽しいぞ"] - List.caseInsensitiveContains "関数型プログラミング" input |> Assert.True - List.caseInsensitiveContains "いや、楽しくないや" input |> Assert.False + Assert.True <| List.caseInsensitiveContains "関数型プログラミング" input + Assert.False <| List.caseInsensitiveContains "いや、楽しくないや" input module ArrayTests = @@ -143,23 +144,23 @@ module ArrayTests = [] let ``hasMultiple returns true for array with more than one element`` () = - Array.hasMultiple [| 1; 2; 3 |] |> Assert.True + Assert.True <| Array.hasMultiple [| 1; 2; 3 |] [] let ``hasMultiple returns false for empty array`` () = - Array.hasMultiple [||] |> Assert.False + Assert.False <| Array.hasMultiple [||] [] let ``hasMultiple returns false for single-element array`` () = - Array.hasMultiple [| 0 |] |> Assert.False + Assert.False <| Array.hasMultiple [| 0 |] [] let ``hasMultiple works with different types of arrays`` () = - Array.hasMultiple [| "hello"; "world" |] |> Assert.True - Array.hasMultiple [| 1.0; 2.0; 3.0 |] |> Assert.True - Array.hasMultiple [| false; true; true |] |> Assert.True - Array.hasMultiple [| Array.sum; Array.length |] |> Assert.True + Assert.True <| Array.hasMultiple [| "hello"; "world" |] + Assert.True <| Array.hasMultiple [| 1.0; 2.0; 3.0 |] + Assert.True <| Array.hasMultiple [| false; true; true |] + Assert.True <| Array.hasMultiple [| Array.sum; Array.length |] [] let ``hasMultiple handles large arrays`` () = - Array.init 100 id |> Array.hasMultiple |> Assert.True + Assert.True <| Array.hasMultiple (Array.init 100 id) From 78dedab81dcda46a85f1773e6bb196c283a0d13e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:26:04 +0900 Subject: [PATCH 150/247] Use pattern matching --- src/CCVTAC.FSharp/InputHelper.fs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index d9d4c75f..a76a1822 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -26,11 +26,10 @@ module InputHelper = let splitInputText input : string list = let matches = userInputRegex.Matches input |> Seq.cast |> Seq.toList - if isZero matches.Length then - [ ] - elif isOne matches.Length then - [ input ] - else + match matches with + | [] -> [] + | [_] -> [input] + | _ -> let startIndices = matches |> List.map _.Index let indexPairs = From 95c534f2582bc269597e20f755ff248791469e95 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:26:26 +0900 Subject: [PATCH 151/247] Use isEmpty over zero-length checks --- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 3df09e41..f3de789d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -27,7 +27,7 @@ module Renamer = |> Seq.rev |> Seq.toList - if isZero matches.Length + if List.isEmpty matches then sb else if not userSettings.QuietMode then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs index 9b21d9f3..49974852 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs @@ -73,7 +73,7 @@ module Detectors = |> Seq.distinct |> Seq.toArray - if isZero matchedValues.Length then + if Array.isEmpty matchedValues then defaultValue else String.Join(separator, matchedValues) From 1b8bb745aabc49a94c26f410e485f8b69820adc5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:37:53 +0900 Subject: [PATCH 152/247] Use List.map over Seq.map --- src/CCVTAC.FSharp/InputHelper.fs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index a76a1822..5d3a3631 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -32,7 +32,7 @@ module InputHelper = | _ -> let startIndices = matches |> List.map _.Index - let indexPairs = + let indexPairs : IndexPair list = startIndices |> List.mapi (fun idx startIndex -> let endIndex = @@ -48,13 +48,11 @@ module InputHelper = let categorizeInputs inputs : CategorizedInput list = inputs - |> Seq.cast - |> Seq.map (fun input -> + |> List.map (fun input -> { Text = input Category = if input.StartsWith(string Commands.prefix) then InputCategory.Command else InputCategory.Url }) - |> List.ofSeq let countCategories (inputs: CategorizedInput seq) : CategoryCounts = let counts = From b12642678276e67c7e855b3129430a4fb9281f34 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:14:37 +0900 Subject: [PATCH 153/247] Add renamer tests --- .../CCVTAC.FSharp.Tests.fsproj | 1 + src/CCVTAC.FSharp.Tests/RenamerTests.fs | 46 +++++++++++++++++++ src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 7 +-- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/CCVTAC.FSharp.Tests/RenamerTests.fs diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj index 8c93222b..0da76e8b 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj @@ -10,6 +10,7 @@ + diff --git a/src/CCVTAC.FSharp.Tests/RenamerTests.fs b/src/CCVTAC.FSharp.Tests/RenamerTests.fs new file mode 100644 index 00000000..19e031bc --- /dev/null +++ b/src/CCVTAC.FSharp.Tests/RenamerTests.fs @@ -0,0 +1,46 @@ +module RenamerTests + +open CCVTAC.Console +open CCVTAC.Console.Settings.Settings +open CCVTAC.Console.PostProcessing +open System +open System.Text +open Xunit + +module UpdateTextViaPatternsTests = + + [] + let ``Renames filenames per given rename patterns`` () = + let patterns : RenamePattern list = + [ + { RegexPattern = "\s\[[\w_-]{11}\](?=\.\w{3,5})" + ReplaceWithPattern = String.Empty + Summary = "Remove trailing video IDs (run first)" } + { RegexPattern = "\s{2,}" + ReplaceWithPattern = " " + Summary = "Remove multiple spaces" } + { RegexPattern = " \(字幕\)" + ReplaceWithPattern = String.Empty + Summary = "Remove 字幕 label" } + { RegexPattern = "^(.+?)「(.+?)」(([12]\d{3}))" + ReplaceWithPattern = "%<1>s - %<2>s [%<3>s]" + Summary = "ARTIST「TITLE」(YEAR)" } + { RegexPattern = "\s+(?=\.\w{3,5})" + ReplaceWithPattern = String.Empty + Summary = "Remove trailing spaces before the file extension" } + ] + + let fileName = StringBuilder "ARTIST「TITLE」(1923)    (字幕) [5B1rB894B1U].m4a" + + let expected = "ARTIST - TITLE [1923].m4a" + + let actual = + List.fold + (fun sb pattern -> Renamer.updateTextViaPatterns true (Printer false) sb pattern) + fileName + patterns + |> _.ToString() + + Assert.Equal(expected, actual) + + diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index f3de789d..4bdd5b3d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -17,7 +17,7 @@ module Renamer = | "KC" -> NormalizationForm.FormKC | _ -> NormalizationForm.FormC - let private updateTextViaPatterns userSettings (printer: Printer) (sb: SB) (renamePattern: RenamePattern) = + let updateTextViaPatterns isQuietMode (printer: Printer) (sb: SB) (renamePattern: RenamePattern) = let regex = Regex renamePattern.RegexPattern let matches = @@ -30,7 +30,7 @@ module Renamer = if List.isEmpty matches then sb else - if not userSettings.QuietMode then + if not isQuietMode then let patternSummary = if String.hasNoText renamePattern.Summary then $"`%s{renamePattern.RegexPattern}` (no description)" @@ -79,7 +79,7 @@ module Renamer = let newFileName = userSettings.RenamePatterns |> Array.fold - (fun (sb: SB) -> updateTextViaPatterns userSettings printer sb) + (fun (sb: SB) -> updateTextViaPatterns userSettings.QuietMode printer sb) (SB audioFile.Name) |> _.ToString() @@ -89,6 +89,7 @@ module Renamer = |> _.Normalize(toNormalizationForm userSettings.NormalizationForm) File.Move(audioFile.FullName, destinationPath) + printer.Debug $"• From: \"%s{audioFile.Name}\"" printer.Debug $" To: \"%s{newFileName}\"" with ex -> From 5ae62e82c9852d1195fba309d52f2b9fd56304c1 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:29:42 +0900 Subject: [PATCH 154/247] Update readme --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99ee70c2..e943468f 100644 --- a/README.md +++ b/README.md @@ -206,5 +206,10 @@ If you run into any issues, feel free to create an issue on GitHub. Please provi ## History -The first incarnation of this application was written in C#. After picking up F# and functional programming out of curiosity in 2024 and creating other tools (such as Audio Tag Tools) with it, I become curious about its OOP capabilities as well. I rewrote this application in OOP F# (initially using LLMs reduce the work and time necessary, though a *lot* of cleanup was necessary). Ultimately, I prefer the F# code over the C# code, so I kept this version. It is not idiomatic F#, but that's kind of the point as well. 😄 +The first incarnation of this application was written in C#. +After picking up F# out of curiosity in 2024 and using it to create other tools (such as [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in a functional programming style, I become curious about F#'s OOP capabilities as well. + +As an experiment to both test OOP F# and leverage LLMs more, I rewrote this application in OOP F#, only using LLMs for the initial conversion, which greatly reduced the time necessary, though a *lot* of cleanup was necessary. Ultimately, I was surprised to see that I preferred the F# code over the C#, so I decided to keep this tool in F#. + +Due to this, the code is not particularly idiomatic F#. I'll probably tweak it over time to gradually bring it closer to the functional style, but the tool is perfectly viable and works well in its current blended-style form. From c6a29cda8d05cf55b1d1443996a56938a28c1c5a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:36:41 +0900 Subject: [PATCH 155/247] Minor logic tweaks --- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 4bdd5b3d..3cfa7617 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -32,10 +32,10 @@ module Renamer = else if not isQuietMode then let patternSummary = - if String.hasNoText renamePattern.Summary then - $"`%s{renamePattern.RegexPattern}` (no description)" - else + if String.hasText renamePattern.Summary then $"\"%s{renamePattern.Summary}\"" + else + $"`%s{renamePattern.RegexPattern}` (no description)" printer.Debug $"Rename pattern %s{patternSummary} matched × %d{matches.Length}." @@ -54,7 +54,7 @@ module Renamer = then m.Groups[i + 1].Value.Trim() else String.Empty (searchFor, replaceWith)) - |> Seq.fold (fun (sbRep: SB) -> sbRep.Replace) (SB renamePattern.ReplaceWithPattern) + |> Seq.fold (fun (sb': SB) -> sb'.Replace) (SB renamePattern.ReplaceWithPattern) |> _.ToString() sb.Insert(m.Index, replacementText) |> ignore From a0a932fedae4358165b6debc65932434cbda7223 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:27:58 +0900 Subject: [PATCH 156/247] Update readme --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e943468f..d1c6bf12 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ You can copy and paste the sample settings file below to a JSON file named `sett
Click here to expand! +> [!IMPORTANT] +> When entering regular expressions in the JSON, you must enter two backslashes for each one you want to include. For example, to match a whitespace character, use `\\s` instead of `\s`. + ```js { // Mandatory. The working directory for temporary files. @@ -71,7 +74,7 @@ You can copy and paste the sample settings file below to a JSON file named `sett // The audio formats (codec) audio should be extracted to. // Options: best, aac, alac, flac, m4a, mp3, opus, vorbis, wav. // Not all options are available for all videos. - "audioFormats": ["best"], + "audioFormats": ["m4a", "best"], // The audio quality to use, with 10 being the lowest and 0 being the highest. "audioQuality": 0, @@ -139,7 +142,7 @@ You can copy and paste the sample settings file below to a JSON file named `sett // Which video metadata field should be searched, `title` or `description`? "searchField": "description", - // An arbitrary summary to the rule. If quiet mode is off, this name will appear + // An arbitrary summary of the rule. If quiet mode is off, this name will appear // in the output when this pattern is matched. "summary": "Topic style" } From c7e4b8600f3bcab414ae8cf5c081e6b61a871aec Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:29:20 +0900 Subject: [PATCH 157/247] Rename test --- src/CCVTAC.FSharp.Tests/RenamerTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp.Tests/RenamerTests.fs b/src/CCVTAC.FSharp.Tests/RenamerTests.fs index 19e031bc..e6288412 100644 --- a/src/CCVTAC.FSharp.Tests/RenamerTests.fs +++ b/src/CCVTAC.FSharp.Tests/RenamerTests.fs @@ -10,7 +10,7 @@ open Xunit module UpdateTextViaPatternsTests = [] - let ``Renames filenames per given rename patterns`` () = + let ``Renames files per specified rename patterns`` () = let patterns : RenamePattern list = [ { RegexPattern = "\s\[[\w_-]{11}\](?=\.\w{3,5})" From 71baa51de8269102a750c6bd2efcf5ffbe2de239 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:15:41 +0900 Subject: [PATCH 158/247] Use list over seq --- src/CCVTAC.FSharp/InputHelper.fs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 5d3a3631..7492d5d2 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -54,12 +54,11 @@ module InputHelper = then InputCategory.Command else InputCategory.Url }) - let countCategories (inputs: CategorizedInput seq) : CategoryCounts = + let countCategories (inputs: CategorizedInput list) : CategoryCounts = let counts = inputs - |> Seq.cast - |> Seq.groupBy _.Category - |> Seq.map (fun (k, grp) -> k, grp |> Seq.length) + |> List.groupBy _.Category + |> List.map (fun (k, grp) -> k, grp |> Seq.length) |> Map.ofSeq CategoryCounts counts From 323c34225e48f866b7386a293cfef95ca7f3a6c1 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:16:07 +0900 Subject: [PATCH 159/247] Collection func tweaks --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 4 ++-- src/CCVTAC.FSharp/Utilities.fs | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 38480e5c..472c52cd 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -43,7 +43,7 @@ module Directories = (0u, ErrorList()) fileNames - if isZero errors.Count then + if Seq.isEmpty errors then Ok successCount else SB($"While {successCount} files were deleted successfully, some files could not be deleted:{String.newLine}") diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index a4185fbc..a792a985 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -26,9 +26,9 @@ module PostProcessor = |> Seq.filter isCollectionMetadataMatch |> Set.ofSeq - if isZero fileNames.Count then + if Seq.isEmpty fileNames then Error "No relevant files found." - elif fileNames.Count > 1 then + elif Seq.hasMultiple fileNames then Error "Unexpectedly found more than one relevant file, so none will be processed." else let fileName = fileNames.Single() diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Utilities.fs index 9c3c64cc..e45a9443 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Utilities.fs @@ -94,6 +94,12 @@ module String = module Seq = + let isNotEmpty l = not (Seq.isEmpty l) + + let doesNotContain x seq = not <| Seq.contains x seq + + let hasMultiple seq = seq |> Seq.length |> (<) 1 + let caseInsensitiveContains text (xs: string seq) : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) @@ -108,9 +114,9 @@ module Array = let isNotEmpty arr = not <| Array.isEmpty arr - let hasMultiple arr = arr |> Array.length |> (<) 1 - let doesNotContain x arr = not <| Array.contains x arr + let hasMultiple arr = arr |> Array.length |> (<) 1 + let caseInsensitiveContains text (xs: string array) : bool = xs |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) From 69cb00fb1c05b310144829f9202ddece8390a90d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:30:34 +0900 Subject: [PATCH 160/247] Minor code layout tweaks and such --- src/CCVTAC.FSharp/Downloading/Downloading.fs | 5 +-- src/CCVTAC.FSharp/Orchestrator.fs | 36 ++++++++------------ 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloading.fs b/src/CCVTAC.FSharp/Downloading/Downloading.fs index 8413e813..c0120d1a 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloading.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloading.fs @@ -1,7 +1,8 @@ namespace CCVTAC.Console.Downloading +open System.Text.RegularExpressions + module public Downloading = - open System.Text.RegularExpressions type MediaType = | Video of Id: string @@ -15,7 +16,7 @@ module public Downloading = | m when m.Success -> Some (List.tail [for g in m.Groups -> g.Value]) | _ -> None - let mediaTypeWithIds url = + let mediaTypeWithIds url : Result = match url with | RegexMatch @"(?<=v=|v\=)([\w-]{11})(?:&list=([\w_-]+))" [videoId; playlistId] -> Ok (PlaylistVideo (videoId, playlistId)) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 11a75ec1..45ad52dd 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -13,6 +13,7 @@ open System open System.Threading module Orchestrator = + type NextAction = | Continue | QuitAtUserRequest @@ -28,41 +29,29 @@ module Orchestrator = let urlCount = counts[InputCategory.Url] let cmdCount = counts[InputCategory.Command] - let urlSummary = - match urlCount with - | 1 -> "1 URL" - | n when n > 1 -> $"%d{n} URLs" - | _ -> String.Empty - - let commandSummary = - match cmdCount with - | 1 -> "1 command" - | n when n > 1 -> $"%d{n} commands" - | _ -> String.Empty - - let connector = - if String.allHaveText [urlSummary; commandSummary] then " and " else String.Empty - - printer.Info $"Batch of %s{urlSummary}%s{connector}%s{commandSummary} entered." + let urlSummary = match urlCount with 1 -> "1 URL" | n -> $"%d{n} URLs" + let commandSummary = match cmdCount with 1 -> "1 command" | n -> $"%d{n} commands" + let conjunction = if String.allHaveText [urlSummary; commandSummary] then " and " else String.Empty + printer.Info $"Batch of %s{urlSummary}%s{conjunction}%s{commandSummary} entered:" for input in categorizedInputs do printer.Info $" • %s{input.Text}" Printer.EmptyLines 1uy - let sleep (sleepSeconds: uint16) : unit = + let sleep seconds : unit = let message seconds = $"Sleeping for {seconds} seconds..." - let rec loop (remaining: uint16) (ctx: StatusContext) = + let rec loop remaining (ctx: StatusContext) = if remaining > 0us then ctx.Status (message remaining) |> ignore Thread.Sleep 1000 loop (remaining - 1us) ctx - AnsiConsole.Status().Start((message sleepSeconds), fun ctx -> + AnsiConsole.Status().Start((message seconds), fun ctx -> ctx.Spinner(Spinner.Known.Star) .SpinnerStyle(Style.Parse "blue") - |> loop sleepSeconds) + |> loop seconds) let processUrl (url: string) @@ -82,7 +71,9 @@ module Orchestrator = | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. sleep settings.SleepSecondsBetweenURLs - printer.Info($"Slept for %d{settings.SleepSecondsBetweenURLs} second(s).", appendLines = 1uy) + printer.Info( + $"{String.newLine}Slept for %d{settings.SleepSecondsBetweenURLs} second(s).", + appendLines = 1uy) if batchSize > 1 then printer.Info $"Processing group %d{urlIndex} of %d{batchSize}..." @@ -251,7 +242,8 @@ module Orchestrator = | InputCategory.Command -> processCommand input.Text &settings history printer | InputCategory.Url -> - processUrl input.Text settings resultTracker history inputTime categoryCounts[InputCategory.Url] inputIndex printer + processUrl input.Text settings resultTracker history inputTime + categoryCounts[InputCategory.Url] inputIndex printer batchResults.RegisterResult(input.Text, result) From 1ce2cf4945538643e0aa68566c1ce537596eca84 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:53:51 +0900 Subject: [PATCH 161/247] Rewrite input batch summary --- src/CCVTAC.FSharp/Orchestrator.fs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 45ad52dd..0bdea464 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -30,9 +30,14 @@ module Orchestrator = let cmdCount = counts[InputCategory.Command] let urlSummary = match urlCount with 1 -> "1 URL" | n -> $"%d{n} URLs" - let commandSummary = match cmdCount with 1 -> "1 command" | n -> $"%d{n} commands" - let conjunction = if String.allHaveText [urlSummary; commandSummary] then " and " else String.Empty - printer.Info $"Batch of %s{urlSummary}%s{conjunction}%s{commandSummary} entered:" + let cmdSummary = match cmdCount with 1 -> "1 command" | n -> $"%d{n} commands" + + printer.Info <| + match counts[InputCategory.Url], counts[InputCategory.Command] with + | u, c when u > 0 && c > 0 -> $"Batch of %s{urlSummary} and %s{cmdSummary} entered:" + | u, _ when u > 0 -> $"Batch of %s{urlSummary} entered:" + | _, c when c > 0 -> $"Batch of %s{cmdSummary} entered:" + | _, _ -> "No URLs or commands were entered!" for input in categorizedInputs do printer.Info $" • %s{input.Text}" From 9461b2a4ebfbb35e690d54f8ca9e5780b1d59b05 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:14:03 +0900 Subject: [PATCH 162/247] Sleep func returns string --- src/CCVTAC.FSharp/Orchestrator.fs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 0bdea464..a234a3a5 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -33,7 +33,8 @@ module Orchestrator = let cmdSummary = match cmdCount with 1 -> "1 command" | n -> $"%d{n} commands" printer.Info <| - match counts[InputCategory.Url], counts[InputCategory.Command] with + match counts[InputCategory.Url], + counts[InputCategory.Command] with | u, c when u > 0 && c > 0 -> $"Batch of %s{urlSummary} and %s{cmdSummary} entered:" | u, _ when u > 0 -> $"Batch of %s{urlSummary} entered:" | _, c when c > 0 -> $"Batch of %s{cmdSummary} entered:" @@ -44,7 +45,7 @@ module Orchestrator = Printer.EmptyLines 1uy - let sleep seconds : unit = + let sleep seconds : string = let message seconds = $"Sleeping for {seconds} seconds..." let rec loop remaining (ctx: StatusContext) = @@ -58,6 +59,8 @@ module Orchestrator = .SpinnerStyle(Style.Parse "blue") |> loop seconds) + $"Slept for %d{seconds} second(s)." + let processUrl (url: string) (settings: UserSettings) @@ -76,9 +79,7 @@ module Orchestrator = | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. sleep settings.SleepSecondsBetweenURLs - printer.Info( - $"{String.newLine}Slept for %d{settings.SleepSecondsBetweenURLs} second(s).", - appendLines = 1uy) + |> fun msg -> printer.Info($"{String.newLine}{msg}", appendLines = 1uy) if batchSize > 1 then printer.Info $"Processing group %d{urlIndex} of %d{batchSize}..." From 3e2e1783b9cb77df529d703eebc67e5639344067 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:14:34 +0900 Subject: [PATCH 163/247] Partial application power, make-up! --- src/CCVTAC.FSharp/Orchestrator.fs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index a234a3a5..dcd7f47b 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -128,6 +128,8 @@ module Orchestrator = (printer: Printer) : Result = + let checkCommand = List.caseInsensitiveContains command + // Help if String.equalIgnoringCase Commands.helpCommand command then for kvp in Commands.summary do @@ -136,38 +138,38 @@ module Orchestrator = Ok NextAction.Continue // Quit - elif List.caseInsensitiveContains command Commands.quitCommands then + elif checkCommand Commands.quitCommands then Ok NextAction.QuitAtUserRequest // History - elif List.caseInsensitiveContains command Commands.history then + elif checkCommand Commands.history then history.ShowRecent printer Ok NextAction.Continue // Update downloader - elif List.caseInsensitiveContains command Commands.updateDownloader then + elif checkCommand Commands.updateDownloader then Updater.run settings printer |> ignore Ok NextAction.Continue // Settings summary - elif List.caseInsensitiveContains command Commands.settingsSummary then + elif checkCommand Commands.settingsSummary then Settings.printSummary settings printer None Ok NextAction.Continue // Toggle split chapters - elif List.caseInsensitiveContains command Commands.splitChapterToggles then + elif checkCommand Commands.splitChapterToggles then settings <- toggleSplitChapters settings printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) Ok NextAction.Continue // Toggle embed images - elif List.caseInsensitiveContains command Commands.embedImagesToggles then + elif checkCommand Commands.embedImagesToggles then settings <- toggleEmbedImages settings printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) Ok NextAction.Continue // Toggle quiet mode - elif List.caseInsensitiveContains command Commands.quietModeToggles then + elif checkCommand Commands.quietModeToggles then settings <- toggleQuietMode settings printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) printer.ShowDebug(not settings.QuietMode) From 9baad174cdc443a2b55899daace3662191b969fb Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:09:30 +0900 Subject: [PATCH 164/247] Make sleep func purer --- src/CCVTAC.FSharp/Orchestrator.fs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index dcd7f47b..0800ba6f 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -45,21 +45,21 @@ module Orchestrator = Printer.EmptyLines 1uy - let sleep seconds : string = - let message seconds = $"Sleeping for {seconds} seconds..." - + let private sleep (workingMsg: uint16 -> string) (doneMsg: uint16 -> string) seconds : string = let rec loop remaining (ctx: StatusContext) = if remaining > 0us then - ctx.Status (message remaining) |> ignore + ctx.Status (workingMsg remaining) |> ignore Thread.Sleep 1000 loop (remaining - 1us) ctx - AnsiConsole.Status().Start((message seconds), fun ctx -> - ctx.Spinner(Spinner.Known.Star) - .SpinnerStyle(Style.Parse "blue") - |> loop seconds) + AnsiConsole + .Status() + .Start((workingMsg seconds), fun ctx -> + ctx.Spinner(Spinner.Known.Star) + .SpinnerStyle(Style.Parse "blue") + |> loop seconds) - $"Slept for %d{seconds} second(s)." + doneMsg seconds let processUrl (url: string) @@ -78,7 +78,11 @@ module Orchestrator = Ok NextAction.QuitDueToErrors | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. - sleep settings.SleepSecondsBetweenURLs + let secondsLabel = NumberUtilities.pluralize "second" "seconds" settings.SleepSecondsBetweenURLs + sleep + (fun seconds -> $"Sleeping for {seconds} {secondsLabel}...") + (fun seconds -> $"Slept for {seconds} {secondsLabel}.") + settings.SleepSecondsBetweenURLs |> fun msg -> printer.Info($"{String.newLine}{msg}", appendLines = 1uy) if batchSize > 1 then From f11c472c85ee15a94f79b2ed944c2c6ae5b53659 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:48:52 +0900 Subject: [PATCH 165/247] More sleep tweaks --- src/CCVTAC.FSharp/Orchestrator.fs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 0800ba6f..33d56ffa 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -45,21 +45,21 @@ module Orchestrator = Printer.EmptyLines 1uy - let private sleep (workingMsg: uint16 -> string) (doneMsg: uint16 -> string) seconds : string = + let private sleep workingMsgFn doneMsgFn seconds : string = let rec loop remaining (ctx: StatusContext) = if remaining > 0us then - ctx.Status (workingMsg remaining) |> ignore + ctx.Status (workingMsgFn remaining) |> ignore Thread.Sleep 1000 loop (remaining - 1us) ctx AnsiConsole .Status() - .Start((workingMsg seconds), fun ctx -> + .Start((workingMsgFn seconds), fun ctx -> ctx.Spinner(Spinner.Known.Star) .SpinnerStyle(Style.Parse "blue") |> loop seconds) - doneMsg seconds + doneMsgFn seconds let processUrl (url: string) @@ -78,11 +78,13 @@ module Orchestrator = Ok NextAction.QuitDueToErrors | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. - let secondsLabel = NumberUtilities.pluralize "second" "seconds" settings.SleepSecondsBetweenURLs - sleep - (fun seconds -> $"Sleeping for {seconds} {secondsLabel}...") - (fun seconds -> $"Slept for {seconds} {secondsLabel}.") - settings.SleepSecondsBetweenURLs + settings.SleepSecondsBetweenURLs + |> pluralize "second" "seconds" + |> fun secondsLabel -> + sleep + (fun seconds -> $"Sleeping for {seconds} {secondsLabel}...") + (fun seconds -> $"Slept for {seconds} {secondsLabel}.") + settings.SleepSecondsBetweenURLs |> fun msg -> printer.Info($"{String.newLine}{msg}", appendLines = 1uy) if batchSize > 1 then From 603e5346af547db04dab8cab8fae8f92d0ac342f Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:59:42 +0900 Subject: [PATCH 166/247] Move sleep func --- src/CCVTAC.FSharp/Orchestrator.fs | 18 ------------------ src/CCVTAC.FSharp/Shared.fs | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 33d56ffa..4e98b222 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -7,10 +7,8 @@ open CCVTAC.Console.PostProcessing open CCVTAC.Console.Settings open CCVTAC.Console.Settings.Settings open CCVTAC.Console.Settings.Settings.LiveUpdating -open Spectre.Console open Startwatch.Library open System -open System.Threading module Orchestrator = @@ -45,22 +43,6 @@ module Orchestrator = Printer.EmptyLines 1uy - let private sleep workingMsgFn doneMsgFn seconds : string = - let rec loop remaining (ctx: StatusContext) = - if remaining > 0us then - ctx.Status (workingMsgFn remaining) |> ignore - Thread.Sleep 1000 - loop (remaining - 1us) ctx - - AnsiConsole - .Status() - .Start((workingMsgFn seconds), fun ctx -> - ctx.Spinner(Spinner.Known.Star) - .SpinnerStyle(Style.Parse "blue") - |> loop seconds) - - doneMsgFn seconds - let processUrl (url: string) (settings: UserSettings) diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index 092cd378..10bc07f5 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -1,7 +1,27 @@ namespace CCVTAC.Console + +open System.Threading +open Spectre.Console + [] module Shared = let audioExtensions = [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] + + let sleep workingMsgFn doneMsgFn seconds : string = + let rec loop remaining (ctx: StatusContext) = + if remaining > 0us then + ctx.Status (workingMsgFn remaining) |> ignore + Thread.Sleep 1000 + loop (remaining - 1us) ctx + + AnsiConsole + .Status() + .Start((workingMsgFn seconds), fun ctx -> + ctx.Spinner(Spinner.Known.Star) + .SpinnerStyle(Style.Parse "blue") + |> loop seconds) + + doneMsgFn seconds From f0afebcc781beb4430753ae4d6f1780abe65e938 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:07:18 +0900 Subject: [PATCH 167/247] Reorganize and rename utility modules --- .../CCVTAC.FSharp.Tests.fsproj | 2 +- .../{UtilitiesTests.fs => ExtensionsTests.fs} | 84 +++++++++---------- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 2 +- .../{Utilities.fs => Extensions.fs} | 10 +-- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 4 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- .../PostProcessing/Tagging/TaggingSet.fs | 2 +- src/CCVTAC.FSharp/Shared.fs | 7 +- 9 files changed, 55 insertions(+), 60 deletions(-) rename src/CCVTAC.FSharp.Tests/{UtilitiesTests.fs => ExtensionsTests.fs} (70%) rename src/CCVTAC.FSharp/{Utilities.fs => Extensions.fs} (95%) diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj index 0da76e8b..45042fdf 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj @@ -9,7 +9,7 @@ - + diff --git a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs similarity index 70% rename from src/CCVTAC.FSharp.Tests/UtilitiesTests.fs rename to src/CCVTAC.FSharp.Tests/ExtensionsTests.fs index 4e697da6..8b701259 100644 --- a/src/CCVTAC.FSharp.Tests/UtilitiesTests.fs +++ b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs @@ -1,62 +1,62 @@ -module UtilitiesTests +module ExtensionsTests open CCVTAC.Console open Xunit open System -module NumberUtilitiesTests = +module NumericsTests = [] let ``isZero returns true for any zero value`` () = - Assert.True <| NumberUtilities.isZero 0 - Assert.True <| NumberUtilities.isZero 0u - Assert.True <| NumberUtilities.isZero 0us - Assert.True <| NumberUtilities.isZero 0. - Assert.True <| NumberUtilities.isZero 0L - Assert.True <| NumberUtilities.isZero 0m - Assert.True <| NumberUtilities.isZero -0 - Assert.True <| NumberUtilities.isZero -0. - Assert.True <| NumberUtilities.isZero -0L - Assert.True <| NumberUtilities.isZero -0m + Assert.True <| Numerics.isZero 0 + Assert.True <| Numerics.isZero 0u + Assert.True <| Numerics.isZero 0us + Assert.True <| Numerics.isZero 0. + Assert.True <| Numerics.isZero 0L + Assert.True <| Numerics.isZero 0m + Assert.True <| Numerics.isZero -0 + Assert.True <| Numerics.isZero -0. + Assert.True <| Numerics.isZero -0L + Assert.True <| Numerics.isZero -0m [] let ``isZero returns false for any non-zero value`` () = - Assert.False <| NumberUtilities.isZero 1 - Assert.False <| NumberUtilities.isOne -1 - Assert.False <| NumberUtilities.isOne Int64.MinValue - Assert.False <| NumberUtilities.isOne Int64.MaxValue - Assert.False <| NumberUtilities.isOne 2 - Assert.False <| NumberUtilities.isZero 1u - Assert.False <| NumberUtilities.isZero 1us - Assert.False <| NumberUtilities.isZero -0.0000000000001 - Assert.False <| NumberUtilities.isZero 0.0000000000001 - Assert.False <| NumberUtilities.isZero 1. - Assert.False <| NumberUtilities.isZero 1L - Assert.False <| NumberUtilities.isZero 1m + Assert.False <| Numerics.isZero 1 + Assert.False <| Numerics.isOne -1 + Assert.False <| Numerics.isOne Int64.MinValue + Assert.False <| Numerics.isOne Int64.MaxValue + Assert.False <| Numerics.isOne 2 + Assert.False <| Numerics.isZero 1u + Assert.False <| Numerics.isZero 1us + Assert.False <| Numerics.isZero -0.0000000000001 + Assert.False <| Numerics.isZero 0.0000000000001 + Assert.False <| Numerics.isZero 1. + Assert.False <| Numerics.isZero 1L + Assert.False <| Numerics.isZero 1m [] let ``isOne returns true for any one value`` () = - Assert.True <| NumberUtilities.isOne 1 - Assert.True <| NumberUtilities.isOne 1u - Assert.True <| NumberUtilities.isOne 1us - Assert.True <| NumberUtilities.isOne 1. - Assert.True <| NumberUtilities.isOne 1L - Assert.True <| NumberUtilities.isOne 1m + Assert.True <| Numerics.isOne 1 + Assert.True <| Numerics.isOne 1u + Assert.True <| Numerics.isOne 1us + Assert.True <| Numerics.isOne 1. + Assert.True <| Numerics.isOne 1L + Assert.True <| Numerics.isOne 1m [] let ``isOne returns false for any non-one value`` () = - Assert.False <| NumberUtilities.isOne 0 - Assert.False <| NumberUtilities.isOne -1 - Assert.False <| NumberUtilities.isOne Int64.MinValue - Assert.False <| NumberUtilities.isOne Int64.MaxValue - Assert.False <| NumberUtilities.isOne 2 - Assert.False <| NumberUtilities.isOne 0u - Assert.False <| NumberUtilities.isOne 16u - Assert.False <| NumberUtilities.isOne 0us - Assert.False <| NumberUtilities.isOne -0. - Assert.False <| NumberUtilities.isOne 0.001 - Assert.False <| NumberUtilities.isOne 0L - Assert.False <| NumberUtilities.isOne 0m + Assert.False <| Numerics.isOne 0 + Assert.False <| Numerics.isOne -1 + Assert.False <| Numerics.isOne Int64.MinValue + Assert.False <| Numerics.isOne Int64.MaxValue + Assert.False <| Numerics.isOne 2 + Assert.False <| Numerics.isOne 0u + Assert.False <| Numerics.isOne 16u + Assert.False <| Numerics.isOne 0us + Assert.False <| Numerics.isOne -0. + Assert.False <| Numerics.isOne 0.001 + Assert.False <| Numerics.isOne 0L + Assert.False <| Numerics.isOne 0m module SeqTests = diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 9faf79b8..0aba6612 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -8,7 +8,7 @@ - + diff --git a/src/CCVTAC.FSharp/Utilities.fs b/src/CCVTAC.FSharp/Extensions.fs similarity index 95% rename from src/CCVTAC.FSharp/Utilities.fs rename to src/CCVTAC.FSharp/Extensions.fs index e45a9443..826825af 100644 --- a/src/CCVTAC.FSharp/Utilities.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -7,14 +7,7 @@ open System.Text type SB = StringBuilder [] -module Utilities = - - let ofTry (f: unit -> 'a) : Result<'a, string> = - try Ok (f()) - with exn -> Error exn.Message - -[] -module NumberUtilities = +module Numerics = let inline isZero (x: ^a) = x = LanguagePrimitives.GenericZero<'a> @@ -26,7 +19,6 @@ module NumberUtilities = if isOne count then ifOne else ifNotOne module String = - let newLine = Environment.NewLine let hasNoText text = diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 472c52cd..7b3f453f 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -13,7 +13,7 @@ module Directories = /// Counts the number of audio files in a directory let audioFileCount (directory: string) = DirectoryInfo(directory).EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioFileExtensions) |> Seq.length /// Returns the filenames in a given directory, optionally ignoring specific filenames @@ -72,7 +72,7 @@ module Directories = if Array.isEmpty fileNames then Ok () else - let fileLabel = NumberUtilities.pluralize "file" "files" fileNames.Length + let fileLabel = Numerics.pluralize "file" "files" fileNames.Length SB($"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":{String.newLine}") .AppendLine diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 4a01b005..052acd02 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -136,7 +136,7 @@ module Mover = let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioFileExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 3cfa7617..ccaec3eb 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -67,7 +67,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioFileExtensions) |> List.ofSeq if List.isEmpty audioFiles then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 5ad2b128..303e174a 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -33,7 +33,7 @@ module TaggingSets = let fileHasSupportedExtension (f: string) = match Path.GetExtension f with | Null -> false - | NonNull (x: string) -> Seq.caseInsensitiveContains x audioExtensions + | NonNull (x: string) -> Seq.caseInsensitiveContains x audioFileExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index 10bc07f5..4400afb8 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -1,15 +1,18 @@ namespace CCVTAC.Console - open System.Threading open Spectre.Console [] module Shared = - let audioExtensions = + let audioFileExtensions = [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] + let ofTry (f: unit -> 'a) : Result<'a, string> = + try Ok (f()) + with exn -> Error exn.Message + let sleep workingMsgFn doneMsgFn seconds : string = let rec loop remaining (ctx: StatusContext) = if remaining > 0us then From 7cf21ba4828d0f191378a7bad78a325675a1a84d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:24:34 +0900 Subject: [PATCH 168/247] Don't auto-open Numerics module --- src/CCVTAC.FSharp/Extensions.fs | 11 +++++------ src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/Printer.fs | 2 +- src/CCVTAC.FSharp/ResultTracker.fs | 8 ++++---- src/CCVTAC.FSharp/Settings/Settings.fs | 16 ++++++++-------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 826825af..2212fe11 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -6,14 +6,13 @@ open System.Text type SB = StringBuilder -[] module Numerics = - let inline isZero (x: ^a) = - x = LanguagePrimitives.GenericZero<'a> + let inline isZero (n: ^a) = + n = LanguagePrimitives.GenericZero<'a> - let inline isOne (x: ^a) = - x = LanguagePrimitives.GenericOne<'a> + let inline isOne (n: ^a) = + n = LanguagePrimitives.GenericOne<'a> let inline pluralize ifOne ifNotOne count = if isOne count then ifOne else ifNotOne @@ -46,7 +45,7 @@ module String = text.EndsWith(endText, StringComparison.InvariantCultureIgnoreCase) let inline fileLabel count = - pluralize "file" "files" count + Numerics.pluralize "file" "files" count /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 4e98b222..ce787f2b 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -61,7 +61,7 @@ module Orchestrator = | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. settings.SleepSecondsBetweenURLs - |> pluralize "second" "seconds" + |> Numerics.pluralize "second" "seconds" |> fun secondsLabel -> sleep (fun seconds -> $"Sleeping for {seconds} {secondsLabel}...") diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index 919a3df2..ec3c26fb 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -133,7 +133,7 @@ type Printer(showDebug: bool) = /// Prints the requested number of blank lines. static member EmptyLines(count: byte) = - if isZero count + if Numerics.isZero count then () else // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index b24a3d57..90845b70 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -29,18 +29,18 @@ type ResultTracker<'a>(printer: Printer) = /// Prints any failures for the current batch. member _.PrintBatchFailures() : unit = - if isZero failures.Count then + if Numerics.isZero failures.Count then printer.Debug "No failures in batch." else - let failureLabel = pluralize "failure" "failures" failures.Count + let failureLabel = Numerics.pluralize "failure" "failures" failures.Count printer.Info $"%d{failures.Count} %s{failureLabel} in this batch:" for pair in failures do printer.Warning $"- %s{pair.Key}: %s{pair.Value}" /// Prints the output for the current application session. member _.PrintSessionSummary() : unit = - let successLabel = pluralize "success" "successes" successCount - let failureLabel = pluralize "failure" "failures" failures.Count + let successLabel = Numerics.pluralize "success" "successes" successCount + let failureLabel = Numerics.pluralize "failure" "failures" failures.Count printer.Info $"Quitting with %d{successCount} %s{successLabel} and %d{failures.Count} %s{failureLabel}." diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 83939bfd..b5ba764c 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -80,8 +80,8 @@ module Settings = | true -> "ON" | false -> "OFF" - let pluralize label count = - pluralize label $"{label}s" count + let simplePluralize label count = + Numerics.pluralize label $"{label}s" count |> sprintf "%d %s" count let tagDetectionPatternCount (patterns: TagDetectionPatterns) = @@ -100,12 +100,12 @@ module Settings = ("Quiet mode", onOrOff settings.QuietMode) ("Audio formats", String.Join(", ", settings.AudioFormats)) ("Audio quality (10 up to 0)", settings.AudioQuality |> sprintf "%B") - ("Sleep between URLs", settings.SleepSecondsBetweenURLs |> int |> pluralize "second") - ("Sleep between downloads", settings.SleepSecondsBetweenDownloads |> int |> pluralize "second") - ("Ignore-upload-year channels", settings.IgnoreUploadYearUploaders.Length |> pluralize "channel") - ("Do-not-embed-image channels", settings.DoNotEmbedImageUploaders.Length |> pluralize "channel") - ("Tag-detection patterns", tagDetectionPatternCount settings.TagDetectionPatterns |> pluralize "pattern") - ("Rename patterns", settings.RenamePatterns.Length |> pluralize "pattern") + ("Sleep between URLs", settings.SleepSecondsBetweenURLs |> int |> simplePluralize "second") + ("Sleep between downloads", settings.SleepSecondsBetweenDownloads |> int |> simplePluralize "second") + ("Ignore-upload-year channels", settings.IgnoreUploadYearUploaders.Length |> simplePluralize "channel") + ("Do-not-embed-image channels", settings.DoNotEmbedImageUploaders.Length |> simplePluralize "channel") + ("Tag-detection patterns", tagDetectionPatternCount settings.TagDetectionPatterns |> simplePluralize "pattern") + ("Rename patterns", settings.RenamePatterns.Length |> simplePluralize "pattern") ] let printSummary settings (printer: Printer) headerOpt : unit = From 1b556ffbd66d45fd80bd20a55a113cb9ba4e04d5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:28:43 +0900 Subject: [PATCH 169/247] Use existing fileLabel function --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 7b3f453f..b2798f67 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -72,7 +72,7 @@ module Directories = if Array.isEmpty fileNames then Ok () else - let fileLabel = Numerics.pluralize "file" "files" fileNames.Length + let fileLabel = String.fileLabel fileNames.Length SB($"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":{String.newLine}") .AppendLine From 1a18add4ad10a0a1037085f030d70ab07d3f6aca Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:02:33 +0900 Subject: [PATCH 170/247] Use String.fileLabel to help improve grammar --- src/CCVTAC.FSharp/Extensions.fs | 6 +++-- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 6 ++--- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 4 ++-- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 23 +++++++++---------- .../PostProcessing/PostProcessing.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- .../PostProcessing/Tagging/Tagger.fs | 2 +- src/CCVTAC.FSharp/Program.fs | 2 +- 9 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 2212fe11..6daa01a0 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -44,8 +44,10 @@ module String = let endsWithIgnoringCase endText (text: string) = text.EndsWith(endText, StringComparison.InvariantCultureIgnoreCase) - let inline fileLabel count = - Numerics.pluralize "file" "files" count + let inline fileLabel descriptor count = + match descriptor with + | None -> $"""%d{count} %s{Numerics.pluralize "file" "files" count}""" + | Some d -> $"""%d{count} %s{d} {Numerics.pluralize "file" "files" count}""" /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index b2798f67..84725b22 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -46,7 +46,7 @@ module Directories = if Seq.isEmpty errors then Ok successCount else - SB($"While {successCount} files were deleted successfully, some files could not be deleted:{String.newLine}") + SB($"{String.fileLabel None successCount} deleted successfully, but some files could not be deleted:{String.newLine}") .AppendLine (fileNames |> Array.truncate showMaxErrors @@ -72,9 +72,7 @@ module Directories = if Array.isEmpty fileNames then Ok () else - let fileLabel = String.fileLabel fileNames.Length - - SB($"Unexpectedly found {fileNames.Length} {fileLabel} in working directory \"{dirName}\":{String.newLine}") + SB($"Unexpectedly found {String.fileLabel None fileNames.Length} in working directory \"{dirName}\":{String.newLine}") .AppendLine (fileNames |> Array.truncate showMax diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index ce787f2b..352db847 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -268,7 +268,7 @@ module Orchestrator = match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> - printer.Info $"%d{deletedCount} file(s) deleted." + printer.Info $"%s{String.fileLabel None deletedCount} deleted." | Error err -> printer.Error err printer.Info "Aborting..." diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index 2df550db..a3ce61ad 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -39,7 +39,7 @@ module Deleter = let collectionFileNames = match getCollectionFiles collectionMetadata workingDirectory with | Ok files -> - printer.Debug $"Found {files.Length} collection files." + printer.Debug $"""Found {String.fileLabel (Some "collection") files.Length}.""" files | Error err -> printer.Warning err @@ -50,6 +50,6 @@ module Deleter = if Array.isEmpty allFileNames then printer.Warning "No files to delete were found." else - printer.Debug $"Deleting {allFileNames.Length} temporary files..." + printer.Debug $"""Deleting {String.fileLabel (Some "temporary") allFileNames.Length}...""" deleteAll allFileNames printer printer.Info "Deleted temporary files." diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 052acd02..d20c46cc 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -38,18 +38,18 @@ module Mover = (moveToDir: string) (overwrite: bool) (printer: Printer) - : uint32 * uint32 = // TODO: Need a custom type for clarity. + : int * int = // TODO: Need a custom type for clarity. - let mutable successCount = 0u - let mutable failureCount = 0u + let mutable successCount = 0 + let mutable failureCount = 0 for file in audioFiles do try File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) - successCount <- successCount + 1u + successCount <- successCount + 1 printer.Debug $"• Moved \"%s{file.Name}\"" with ex -> - failureCount <- failureCount + 1u + failureCount <- failureCount + 1 printer.Error $"• Error moving file \"%s{file.Name}\": %s{ex.Message}" (successCount, failureCount) @@ -142,18 +142,17 @@ module Mover = if audioFileNames.IsEmpty then printer.Error "No audio filenames to move were found." else - let toMoveFileLabel = String.fileLabel audioFileNames.Length - printer.Debug $"Moving %d{audioFileNames.Length} audio %s{toMoveFileLabel} to \"%s{fullMoveToDir}\"..." + let fileCountMsg = String.fileLabel (Some "audio") + + printer.Debug $"Moving %s{fileCountMsg audioFileNames.Length} to \"%s{fullMoveToDir}\"..." let moveSuccessCount, moveFailureCount = moveAudioFiles audioFileNames fullMoveToDir overwrite printer - let movedFileLabel = String.fileLabel moveSuccessCount - printer.Info $"Moved %d{moveSuccessCount} audio %s{movedFileLabel} in %s{watch.ElapsedFriendly}." + printer.Info $"Moved %s{fileCountMsg moveSuccessCount} in %s{watch.ElapsedFriendly}." - if moveFailureCount > 0u then - let failFileLabel = String.fileLabel moveFailureCount - printer.Warning $"However, %d{moveFailureCount} audio %s{failFileLabel} could not be moved." + if moveFailureCount > 0 then + printer.Warning $"However, %s{fileCountMsg moveFailureCount} could not be moved." match moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir audioFileNames.Length overwrite with diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index a792a985..4b8fdab5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -84,7 +84,7 @@ module PostProcessor = printer.Error err printer.Info "Will delete the remaining files..." match Directories.deleteAllFiles 20 workingDirectory with - | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." + | Ok deletedCount -> printer.Info $"%s{String.fileLabel None deletedCount} deleted." | Error e -> printer.Error e | Error e -> printer.Error($"Tagging error(s) preventing further post-processing: {e}") diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index ccaec3eb..fb01be47 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -73,7 +73,7 @@ module Renamer = if List.isEmpty audioFiles then printer.Warning "No audio files to rename were found." else - printer.Debug $"Renaming %d{audioFiles.Length} audio file(s)..." + printer.Debug $"""Renaming %s{String.fileLabel (Some "audio") audioFiles.Length}...""" for audioFile in audioFiles do let newFileName = diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index fd3a694d..ef451873 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -178,7 +178,7 @@ module Tagger = : unit = - printer.Debug $"%d{taggingSet.AudioFilePaths.Length} audio file(s) with resource ID %s{taggingSet.ResourceId}" + printer.Debug $"""%s{String.fileLabel (Some "audio") taggingSet.AudioFilePaths.Length} with resource ID %s{taggingSet.ResourceId}""" match parseVideoJson taggingSet with | Ok videoData -> diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index 26accd07..f383eae3 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -61,7 +61,7 @@ module Program = | Error warnResult -> printer.Error warnResult match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with - | Ok deletedCount -> printer.Info $"%d{deletedCount} file(s) deleted." + | Ok deletedCount -> printer.Info $"%s{String.fileLabel None deletedCount} deleted." | Error delErr -> printer.Error delErr ) From 8e8c967fae80acd5d84d8d52218856b90f2477bf Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:10:23 +0900 Subject: [PATCH 171/247] Move pluralize func --- src/CCVTAC.FSharp/Extensions.fs | 11 ++++++----- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- src/CCVTAC.FSharp/ResultTracker.fs | 6 +++--- src/CCVTAC.FSharp/Settings/Settings.fs | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 6daa01a0..12965e76 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -14,10 +14,8 @@ module Numerics = let inline isOne (n: ^a) = n = LanguagePrimitives.GenericOne<'a> - let inline pluralize ifOne ifNotOne count = - if isOne count then ifOne else ifNotOne - module String = + let newLine = Environment.NewLine let hasNoText text = @@ -44,10 +42,13 @@ module String = let endsWithIgnoringCase endText (text: string) = text.EndsWith(endText, StringComparison.InvariantCultureIgnoreCase) + let inline pluralize ifOne ifNotOne count = + if Numerics.isOne count then ifOne else ifNotOne + let inline fileLabel descriptor count = match descriptor with - | None -> $"""%d{count} %s{Numerics.pluralize "file" "files" count}""" - | Some d -> $"""%d{count} %s{d} {Numerics.pluralize "file" "files" count}""" + | None -> $"""%d{count} %s{pluralize "file" "files" count}""" + | Some d -> $"""%d{count} %s{d} {pluralize "file" "files" count}""" /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 352db847..1fcb8905 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -61,7 +61,7 @@ module Orchestrator = | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. settings.SleepSecondsBetweenURLs - |> Numerics.pluralize "second" "seconds" + |> String.pluralize "second" "seconds" |> fun secondsLabel -> sleep (fun seconds -> $"Sleeping for {seconds} {secondsLabel}...") diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 90845b70..e7c5fe41 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -32,15 +32,15 @@ type ResultTracker<'a>(printer: Printer) = if Numerics.isZero failures.Count then printer.Debug "No failures in batch." else - let failureLabel = Numerics.pluralize "failure" "failures" failures.Count + let failureLabel = String.pluralize "failure" "failures" failures.Count printer.Info $"%d{failures.Count} %s{failureLabel} in this batch:" for pair in failures do printer.Warning $"- %s{pair.Key}: %s{pair.Value}" /// Prints the output for the current application session. member _.PrintSessionSummary() : unit = - let successLabel = Numerics.pluralize "success" "successes" successCount - let failureLabel = Numerics.pluralize "failure" "failures" failures.Count + let successLabel = String.pluralize "success" "successes" successCount + let failureLabel = String.pluralize "failure" "failures" failures.Count printer.Info $"Quitting with %d{successCount} %s{successLabel} and %d{failures.Count} %s{failureLabel}." diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index b5ba764c..a9cf4da6 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -81,7 +81,7 @@ module Settings = | false -> "OFF" let simplePluralize label count = - Numerics.pluralize label $"{label}s" count + String.pluralize label $"{label}s" count |> sprintf "%d %s" count let tagDetectionPatternCount (patterns: TagDetectionPatterns) = From 48ac08dd2ce238d2daec2a3f448a402ab629cb4e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:16:22 +0900 Subject: [PATCH 172/247] Minor code tweaks to extensions --- src/CCVTAC.FSharp/Extensions.fs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 12965e76..36ded00f 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -66,10 +66,10 @@ module String = seq { yield! Path.GetInvalidFileNameChars() yield! Path.GetInvalidPathChars() - yield Path.PathSeparator - yield Path.DirectorySeparatorChar - yield Path.AltDirectorySeparatorChar - yield Path.VolumeSeparatorChar + yield Path.PathSeparator + yield Path.DirectorySeparatorChar + yield Path.AltDirectorySeparatorChar + yield Path.VolumeSeparatorChar yield! custom } |> Set.ofSeq @@ -88,7 +88,7 @@ module String = module Seq = - let isNotEmpty l = not (Seq.isEmpty l) + let isNotEmpty seq = not (Seq.isEmpty seq) let doesNotContain x seq = not <| Seq.contains x seq @@ -99,10 +99,10 @@ module Seq = module List = - let isNotEmpty l = not (List.isEmpty l) + let isNotEmpty list = not (List.isEmpty list) - let caseInsensitiveContains text (xs: string list) : bool = - xs |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) + let caseInsensitiveContains text (list: string list) : bool = + list |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) module Array = @@ -112,5 +112,5 @@ module Array = let hasMultiple arr = arr |> Array.length |> (<) 1 - let caseInsensitiveContains text (xs: string array) : bool = - xs |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) + let caseInsensitiveContains text (arr: string array) : bool = + arr |> Array.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) From 5c9e75ff3329464173fffad5351df8fc68dbaaef Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:26:49 +0900 Subject: [PATCH 173/247] Delete duplicated code --- src/CCVTAC.FSharp/Orchestrator.fs | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 1fcb8905..ab2d906a 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -31,8 +31,7 @@ module Orchestrator = let cmdSummary = match cmdCount with 1 -> "1 command" | n -> $"%d{n} commands" printer.Info <| - match counts[InputCategory.Url], - counts[InputCategory.Command] with + match counts[InputCategory.Url], counts[InputCategory.Command] with | u, c when u > 0 && c > 0 -> $"Batch of %s{urlSummary} and %s{cmdSummary} entered:" | u, _ when u > 0 -> $"Batch of %s{urlSummary} entered:" | _, c when c > 0 -> $"Batch of %s{cmdSummary} entered:" @@ -171,16 +170,11 @@ module Orchestrator = else let updateResult = updateAudioFormat settings format match updateResult with - | Error e -> Error e + | Error err -> Error err | Ok x -> settings <- x printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) Ok NextAction.Continue - // if updateResult.IsError then Error updateResult.ErrorValue - // else - // settings <- updateResult.ResultValue - // printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) - // Ok NextAction.Continue // Update audio quality elif String.startsWithIgnoreCase Commands.updateAudioQualityPrefix command then @@ -192,16 +186,11 @@ module Orchestrator = | true, quality -> let updateResult = updateAudioQuality settings quality match updateResult with - | Error e -> Error e - | Ok x -> - settings <- x + | Error err -> Error err + | Ok updatedSettings -> + settings <- updatedSettings printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) Ok NextAction.Continue - // if updateResult.IsError then Error updateResult.ErrorValue - // else - // settings <- updateResult.ResultValue - // printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) - // Ok NextAction.Continue | _ -> Error $"\"%s{inputQuality}\" is an invalid quality value." From f72530b613cedc2fb494e8d1c85ae3515d16b45a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:34:16 +0900 Subject: [PATCH 174/247] Avoid mutable settings! --- src/CCVTAC.FSharp/Commands.fs | 1 + src/CCVTAC.FSharp/Orchestrator.fs | 108 ++++++++++++++----------- src/CCVTAC.FSharp/Settings/Settings.fs | 2 +- 3 files changed, 62 insertions(+), 49 deletions(-) diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.FSharp/Commands.fs index cd0491f5..bb5a8587 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.FSharp/Commands.fs @@ -33,6 +33,7 @@ module Commands = $"Followed by a supported audio format (e.g., %s{updateAudioFormatPrefix}m4a), changes the audio format for the current session only") (updateAudioQualityPrefix, $"Followed by a supported audio quality (e.g., %s{updateAudioQualityPrefix}0), changes the audio quality for the current session only") + String.Join(" or ", settingsSummary), "See the current settings" String.Join(" or ", quitCommands), "Quit this application" helpCommand, "See this help message" ] diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index ab2d906a..0011d93c 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -17,6 +17,10 @@ module Orchestrator = | QuitAtUserRequest | QuitDueToErrors + type BatchResults = + { NextAction: NextAction + UpdatedSettings: UserSettings option } + let summarizeInput (categorizedInputs: CategorizedInput list) (counts: CategoryCounts) @@ -51,12 +55,12 @@ module Orchestrator = (batchSize: int) (urlIndex: int) (printer: Printer) - : Result = + : Result = match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with | Error err -> printer.Error err - Ok NextAction.QuitDueToErrors + Ok { NextAction = NextAction.QuitDueToErrors; UpdatedSettings = None } | Ok () -> if urlIndex > 1 then // Don't sleep for the first URL. settings.SleepSecondsBetweenURLs @@ -86,12 +90,12 @@ module Orchestrator = resultTracker.RegisterResult(url, downloadResult) match downloadResult with - | Error e -> - let errorMsg = $"Download error: %s{e}" + | Error err -> + let errorMsg = $"Download error: %s{err}" printer.Error errorMsg Error errorMsg - | Ok s -> - printer.Debug $"Successfully downloaded \"%s{s}\" format." + | Ok msg -> + printer.Debug $"Successfully downloaded \"%s{msg}\" format." PostProcessor.run settings mediaType printer let groupClause = @@ -100,7 +104,7 @@ module Orchestrator = else String.Empty printer.Info $"Processed '%s{url}'%s{groupClause} in %s{jobWatch.ElapsedFriendly}." - Ok NextAction.Continue + Ok { NextAction = NextAction.Continue; UpdatedSettings = None } let summarizeToggle settingName setting = sprintf "%s was toggled to %s for this session." settingName (if setting then "ON" else "OFF") @@ -110,10 +114,10 @@ module Orchestrator = let processCommand (command: string) - (settings: byref) + (settings: UserSettings) (history: History) (printer: Printer) - : Result = + : Result = let checkCommand = List.caseInsensitiveContains command @@ -122,45 +126,45 @@ module Orchestrator = for kvp in Commands.summary do printer.Info(kvp.Key) printer.Info $" %s{kvp.Value}" - Ok NextAction.Continue + Ok { NextAction = NextAction.Continue; UpdatedSettings = None } // Quit elif checkCommand Commands.quitCommands then - Ok NextAction.QuitAtUserRequest + Ok { NextAction = NextAction.QuitAtUserRequest; UpdatedSettings = None } // History elif checkCommand Commands.history then history.ShowRecent printer - Ok NextAction.Continue + Ok { NextAction = NextAction.Continue; UpdatedSettings = None } // Update downloader elif checkCommand Commands.updateDownloader then Updater.run settings printer |> ignore - Ok NextAction.Continue + Ok { NextAction = NextAction.Continue; UpdatedSettings = None } // Settings summary elif checkCommand Commands.settingsSummary then Settings.printSummary settings printer None - Ok NextAction.Continue + Ok { NextAction = NextAction.Continue; UpdatedSettings = None } // Toggle split chapters elif checkCommand Commands.splitChapterToggles then - settings <- toggleSplitChapters settings - printer.Info(summarizeToggle "Split Chapters" settings.SplitChapters) - Ok NextAction.Continue + let newSettings = toggleSplitChapters settings + printer.Info(summarizeToggle "Split Chapters" newSettings.SplitChapters) + Ok { NextAction = NextAction.Continue; UpdatedSettings = Some newSettings } // Toggle embed images elif checkCommand Commands.embedImagesToggles then - settings <- toggleEmbedImages settings - printer.Info(summarizeToggle "Embed Images" settings.EmbedImages) - Ok NextAction.Continue + let newSettings = toggleEmbedImages settings + printer.Info(summarizeToggle "Embed Images" newSettings.EmbedImages) + Ok { NextAction = NextAction.Continue; UpdatedSettings = Some newSettings } // Toggle quiet mode elif checkCommand Commands.quietModeToggles then - settings <- toggleQuietMode settings - printer.Info(summarizeToggle "Quiet Mode" settings.QuietMode) - printer.ShowDebug(not settings.QuietMode) - Ok NextAction.Continue + let newSettings = toggleQuietMode settings + printer.Info(summarizeToggle "Quiet Mode" newSettings.QuietMode) + printer.ShowDebug(not newSettings.QuietMode) + Ok { NextAction = NextAction.Continue; UpdatedSettings = Some newSettings } // Update audio formats elif String.startsWithIgnoreCase Commands.updateAudioFormatPrefix command then @@ -171,34 +175,33 @@ module Orchestrator = let updateResult = updateAudioFormat settings format match updateResult with | Error err -> Error err - | Ok x -> - settings <- x - printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", settings.AudioFormats))) - Ok NextAction.Continue + | Ok newSettings -> + printer.Info(summarizeUpdate "Audio Formats" (String.Join(", ", newSettings.AudioFormats))) + Ok { NextAction = NextAction.Continue; UpdatedSettings = Some newSettings } // Update audio quality elif String.startsWithIgnoreCase Commands.updateAudioQualityPrefix command then - let inputQuality = command.Replace(Commands.updateAudioQualityPrefix, "") + let inputQuality = command.Replace(Commands.updateAudioQualityPrefix, String.Empty) if String.hasNoText inputQuality then - Error "You must enter a number representing an audio quality." + Error "You must enter a number representing an audio quality between 10 (lowest) and 0 (highest)." else match Byte.TryParse inputQuality with | true, quality -> let updateResult = updateAudioQuality settings quality match updateResult with - | Error err -> Error err + | Error err -> + Error err | Ok updatedSettings -> - settings <- updatedSettings - printer.Info(summarizeUpdate "Audio Quality" (settings.AudioQuality.ToString())) - Ok NextAction.Continue + printer.Info(summarizeUpdate "Audio Quality" (updatedSettings.AudioQuality.ToString())) + Ok { NextAction = NextAction.Continue; UpdatedSettings = Some updatedSettings } | _ -> Error $"\"%s{inputQuality}\" is an invalid quality value." // Unknown command else - Error (sprintf "\"%s\" is not a valid command. Enter \"%scommands\" to see a list of commands." + Error <| sprintf "\"%s\" is not a valid command. Enter \"%shelp\" to see a list of commands." command - (string Commands.prefix)) + (string Commands.prefix) /// Processes a single user request, from input to downloading and file post-processing. @@ -206,17 +209,18 @@ module Orchestrator = let processBatch (categorizedInputs: CategorizedInput list) (categoryCounts: CategoryCounts) - (settings: byref) + (settings: UserSettings) (resultTracker: ResultTracker) (history: History) (printer: Printer) - : NextAction = + : BatchResults = let inputTime = DateTime.Now let watch = Watch() - let batchResults = ResultTracker printer + let batchResults = ResultTracker printer let mutable nextAction = NextAction.Continue let mutable inputIndex = 0 + let mutable currentSettings = settings for input in categorizedInputs do let mutable stop = false @@ -225,17 +229,21 @@ module Orchestrator = let result = match input.Category with | InputCategory.Command -> - processCommand input.Text &settings history printer + processCommand input.Text currentSettings history printer | InputCategory.Url -> - processUrl input.Text settings resultTracker history inputTime + processUrl input.Text currentSettings resultTracker history inputTime categoryCounts[InputCategory.Url] inputIndex printer batchResults.RegisterResult(input.Text, result) match result with - | Error e -> printer.Error e - | Ok action -> - nextAction <- action + | Error e -> + printer.Error e + | Ok r -> + nextAction <- r.NextAction + match r.UpdatedSettings with + | None -> () + | Some us -> currentSettings <- us if nextAction <> NextAction.Continue then stop <- true @@ -246,9 +254,9 @@ module Orchestrator = watch.ElapsedFriendly) batchResults.PrintBatchFailures() - nextAction + { NextAction = nextAction; UpdatedSettings = Some currentSettings } - /// Ensures the download environment is ready, then initiates the UI input and download process. + /// Ensures the download environment is ready, then initiates the input and download process. let start (settings: UserSettings) (printer: Printer) : unit = // The working directory should start empty. Give the user a chance to empty it. match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with @@ -266,7 +274,7 @@ module Orchestrator = let results = ResultTracker printer let history = History(settings.HistoryFile, settings.HistoryDisplayCount) let mutable nextAction = NextAction.Continue - let mutable settingsRef = settings + let mutable currentSettings = settings while nextAction = NextAction.Continue do let input = printer.GetInput prompt @@ -278,7 +286,11 @@ module Orchestrator = let categorizedInputs = categorizeInputs splitInputs let categoryCounts = countCategories categorizedInputs summarizeInput categorizedInputs categoryCounts printer - nextAction <- processBatch categorizedInputs categoryCounts &settingsRef results history printer + let batchResult = processBatch categorizedInputs categoryCounts currentSettings results history printer + nextAction <- batchResult.NextAction + match batchResult.UpdatedSettings with + | None -> () + | Some us -> currentSettings <- us results.PrintSessionSummary() diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index a9cf4da6..b1acaa79 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -99,7 +99,7 @@ module Settings = ("Embed images", onOrOff settings.EmbedImages) ("Quiet mode", onOrOff settings.QuietMode) ("Audio formats", String.Join(", ", settings.AudioFormats)) - ("Audio quality (10 up to 0)", settings.AudioQuality |> sprintf "%B") + ("Audio quality (10 up to 0)", settings.AudioQuality |> sprintf "%d") ("Sleep between URLs", settings.SleepSecondsBetweenURLs |> int |> simplePluralize "second") ("Sleep between downloads", settings.SleepSecondsBetweenDownloads |> int |> simplePluralize "second") ("Ignore-upload-year channels", settings.IgnoreUploadYearUploaders.Length |> simplePluralize "channel") From 69f302d73f723dc0934ec42f9fc9c21d67c14eba Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:55:48 +0900 Subject: [PATCH 175/247] Very minor tweaks --- src/CCVTAC.FSharp/Orchestrator.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 0011d93c..6461ce1e 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -254,7 +254,8 @@ module Orchestrator = watch.ElapsedFriendly) batchResults.PrintBatchFailures() - { NextAction = nextAction; UpdatedSettings = Some currentSettings } + { NextAction = nextAction + UpdatedSettings = Some currentSettings } /// Ensures the download environment is ready, then initiates the input and download process. let start (settings: UserSettings) (printer: Printer) : unit = @@ -262,12 +263,11 @@ module Orchestrator = match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with | Error err -> printer.Error err - match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with | Ok deletedCount -> printer.Info $"%s{String.fileLabel None deletedCount} deleted." - | Error err -> - printer.Error err + | Error err' -> + printer.Error err' printer.Info "Aborting..." | Ok () -> () @@ -289,8 +289,8 @@ module Orchestrator = let batchResult = processBatch categorizedInputs categoryCounts currentSettings results history printer nextAction <- batchResult.NextAction match batchResult.UpdatedSettings with + | Some s -> currentSettings <- s | None -> () - | Some us -> currentSettings <- us results.PrintSessionSummary() From 22d414f3ad8948c8341b2bc0b41be0b8b31e4638 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:37:26 +0900 Subject: [PATCH 176/247] Relocate audioFileExtensions list --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 3 ++- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 9 +++++++-- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 3 ++- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 3 ++- src/CCVTAC.FSharp/Shared.fs | 3 --- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 4dbaa9bc..c4415548 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -2,6 +2,7 @@ namespace CCVTAC.Console.Downloading open CCVTAC.Console open CCVTAC.Console.ExternalTools.Runner +open CCVTAC.Console.IoUtilities open CCVTAC.Console.IoUtilities.Directories open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.ExternalTools @@ -107,7 +108,7 @@ module Downloader = let mutable errors = match downloadResult with Error err -> [err] | Ok _ -> [] - if audioFileCount userSettings.WorkingDirectory = 0 then + if audioFileCount userSettings.WorkingDirectory Files.audioFileExtensions = 0 then "No audio files were downloaded." :: errors |> String.concat String.newLine |> Error diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 84725b22..ad2c39d6 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -3,6 +3,11 @@ namespace CCVTAC.Console.IoUtilities open System.IO open CCVTAC.Console +module Files = + + let audioFileExtensions = + [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] + module Directories = type private ErrorList = string ResizeArray @@ -11,9 +16,9 @@ module Directories = let private allFilesSearchPattern = "*" /// Counts the number of audio files in a directory - let audioFileCount (directory: string) = + let audioFileCount (directory: string) (includedExtensions: string list) = DirectoryInfo(directory).EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension includedExtensions) |> Seq.length /// Returns the filenames in a given directory, optionally ignoring specific filenames diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index d20c46cc..7967564e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -136,7 +136,7 @@ module Mover = let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index fb01be47..ba99db0e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -1,6 +1,7 @@ namespace CCVTAC.Console.PostProcessing open CCVTAC.Console +open CCVTAC.Console.IoUtilities open CCVTAC.Console.Settings.Settings open System open System.IO @@ -67,7 +68,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) |> List.ofSeq if List.isEmpty audioFiles then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 303e174a..9eb405a4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -1,6 +1,7 @@ namespace CCVTAC.Console.PostProcessing.Tagging open CCVTAC.Console +open CCVTAC.Console.IoUtilities open System.IO open System.Text.RegularExpressions @@ -33,7 +34,7 @@ module TaggingSets = let fileHasSupportedExtension (f: string) = match Path.GetExtension f with | Null -> false - | NonNull (x: string) -> Seq.caseInsensitiveContains x audioFileExtensions + | NonNull (x: string) -> Seq.caseInsensitiveContains x Files.audioFileExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index 4400afb8..57e4ac31 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -6,9 +6,6 @@ open Spectre.Console [] module Shared = - let audioFileExtensions = - [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] - let ofTry (f: unit -> 'a) : Result<'a, string> = try Ok (f()) with exn -> Error exn.Message From fcbcd2b5ceb35cac91ae44004757d500647063d1 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:37:42 +0900 Subject: [PATCH 177/247] Rename run to runTool for clarity --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 4 ++-- src/CCVTAC.FSharp/Downloading/Updater.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index c4415548..1a3d4f53 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -90,7 +90,7 @@ module Downloader = let commandWithArgs = $"{programName} {args}" let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory - downloadResult <- Runner.run downloadSettings [1] printer + downloadResult <- runTool downloadSettings [1] printer match downloadResult with | Ok result -> @@ -124,7 +124,7 @@ module Downloader = let args = generateDownloadArgs None userSettings None (Some [supplementaryUrl]) let commandWithArgs = $"{programName} {args}" let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory - let supplementaryDownloadResult = Runner.run downloadSettings [1] printer + let supplementaryDownloadResult = runTool downloadSettings [1] printer match supplementaryDownloadResult with | Ok _ -> diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.FSharp/Downloading/Updater.fs index 2256ee92..1b6ed9c5 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.FSharp/Downloading/Updater.fs @@ -13,7 +13,7 @@ module Updater = else let toolSettings = ToolSettings.create userSettings.DownloaderUpdateCommand userSettings.WorkingDirectory - match Runner.run toolSettings [] printer with + match Runner.runTool toolSettings [] printer with | Ok result -> if result.ExitCode <> 0 then printer.Warning("Tool updated with minor issues.") diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 1dee86a3..22ca63d8 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -20,7 +20,7 @@ module Runner = /// Additional exit codes, other than 0, that can be treated as non-failures /// Printer for logging /// A Result instance containing the exit code and any warnings or else an error message. - let run toolSettings (otherSuccessExitCodes: int list) (printer: Printer) + let runTool toolSettings otherSuccessExitCodes (printer: Printer) : Result = let watch = Watch() diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs index 2d9b5385..98e2f45b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -12,4 +12,4 @@ module ImageProcessor = $"{programName} -trim -fuzz 10%% *.jpg" workingDirectory - Runner.run toolSettings [] printer |> ignore + Runner.runTool toolSettings [] printer |> ignore From 352b564bc0a2d9ec913efc09709fa6b0516eb909 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:30:11 +0900 Subject: [PATCH 178/247] Remove struct attribute --- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 9eb405a4..4ce4a5c2 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -8,7 +8,6 @@ open System.Text.RegularExpressions /// Contains all the data necessary for tagging a related set of files. module TaggingSets = - [] type TaggingSet = { ResourceId: string AudioFilePaths: string list @@ -44,13 +43,13 @@ module TaggingSets = |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) |> Seq.filter (fun (_, fileNames) -> let isSupportedExtension = fileNames |> Seq.exists fileHasSupportedExtension - let jsonCount = fileNames |> Seq.filter (String.endsWithIgnoringCase jsonFileExt) |> Seq.length + let jsonCount = fileNames |> Seq.filter (String.endsWithIgnoringCase jsonFileExt) |> Seq.length let imageCount = fileNames |> Seq.filter (String.endsWithIgnoringCase imageFileExt) |> Seq.length isSupportedExtension && jsonCount = 1 && imageCount = 1) |> Seq.map (fun (key, files) -> let audioFiles = files |> Seq.filter fileHasSupportedExtension - let jsonFile = files |> Seq.find (String.endsWithIgnoringCase jsonFileExt) - let imageFile = files |> Seq.find (String.endsWithIgnoringCase imageFileExt) + let jsonFile = files |> Seq.find (String.endsWithIgnoringCase jsonFileExt) + let imageFile = files |> Seq.find (String.endsWithIgnoringCase imageFileExt) { ResourceId = key AudioFilePaths = audioFiles |> Seq.toList JsonFilePath = jsonFile From e0d4ca0b32db44f7067c04b65bc2dd129fc266c1 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:33:36 +0900 Subject: [PATCH 179/247] More code tweaks --- .../PostProcessing/Tagging/TaggingSet.fs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 4ce4a5c2..f0b96b65 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -19,7 +19,7 @@ module TaggingSets = /// Create a collection of TaggingSets from a collection of file paths related to several video IDs. /// Files that don't match the requirements will be ignored. - let createSets (filePaths: string seq) : TaggingSet list = + let createSets filePaths : TaggingSet list = if Seq.isEmpty filePaths then [] else @@ -30,10 +30,10 @@ module TaggingSets = let fileNamesWithVideoIdsRegex = Regex(@".+\[([\w_\-]{11})\](?:.*)?\.(\w+)", RegexOptions.Compiled) - let fileHasSupportedExtension (f: string) = - match Path.GetExtension f with + let fileHasSupportedExtension (file: string) = + match Path.GetExtension file with | Null -> false - | NonNull (x: string) -> Seq.caseInsensitiveContains x Files.audioFileExtensions + | NonNull (ext: string) -> Seq.caseInsensitiveContains ext Files.audioFileExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match @@ -41,16 +41,18 @@ module TaggingSets = |> Seq.map (fun m -> m.Captures |> Seq.cast |> Seq.head) |> Seq.groupBy _.Groups[1].Value |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) - |> Seq.filter (fun (_, fileNames) -> - let isSupportedExtension = fileNames |> Seq.exists fileHasSupportedExtension - let jsonCount = fileNames |> Seq.filter (String.endsWithIgnoringCase jsonFileExt) |> Seq.length - let imageCount = fileNames |> Seq.filter (String.endsWithIgnoringCase imageFileExt) |> Seq.length - isSupportedExtension && jsonCount = 1 && imageCount = 1) - |> Seq.map (fun (key, files) -> + |> Seq.filter (fun (_, files) -> + let isSupportedExt = files |> Seq.exists fileHasSupportedExtension + let jsonCount = files |> Seq.filter (String.endsWithIgnoringCase jsonFileExt) |> Seq.length + let imageCount = files |> Seq.filter (String.endsWithIgnoringCase imageFileExt) |> Seq.length + isSupportedExt + && Numerics.isOne jsonCount + && Numerics.isOne imageCount) + |> Seq.map (fun (videoId, files) -> let audioFiles = files |> Seq.filter fileHasSupportedExtension let jsonFile = files |> Seq.find (String.endsWithIgnoringCase jsonFileExt) let imageFile = files |> Seq.find (String.endsWithIgnoringCase imageFileExt) - { ResourceId = key + { ResourceId = videoId AudioFilePaths = audioFiles |> Seq.toList JsonFilePath = jsonFile ImageFilePath = imageFile }) From f5dbe1d1f0e6dd3c29d11b446c1f10f0d89ebda6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:54:27 +0900 Subject: [PATCH 180/247] Again with the tweaks --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 1 - src/CCVTAC.FSharp/Printer.fs | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 7967564e..79713edf 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -7,7 +7,6 @@ open CCVTAC.Console open CCVTAC.Console.PostProcessing open System open System.IO -open System.Linq open System.Text.Json open System.Text.RegularExpressions open Startwatch.Library diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.FSharp/Printer.fs index ec3c26fb..4607a870 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.FSharp/Printer.fs @@ -22,7 +22,7 @@ type Printer(showDebug: bool) = static let colors : Dictionary = Dictionary() |> fun d -> - d.Add(Level.Critical, { Foreground = Some "white"; Background = Some "red3"; Bold = true }) + d.Add(Level.Critical, { Foreground = Some "white"; Background = Some "red3"; Bold = true }) d.Add(Level.Error, { Foreground = Some "red"; Background = None; Bold = false }) d.Add(Level.Warning, { Foreground = Some "yellow"; Background = None; Bold = false }) d.Add(Level.Info, { Foreground = None; Background = None; Bold = false }) @@ -43,7 +43,10 @@ type Printer(showDebug: bool) = /// Escape text so Spectre markup and format strings are safe. static member private EscapeText(text: string) : string = - text.Replace("{", "{{").Replace("}", "}}").Replace("[", "[[").Replace("]", "]]") + text.Replace("{", "{{") + .Replace("}", "}}") + .Replace("[", "[[") + .Replace("]", "]]") static member private AddMarkup(message: string, colors: ColorFormat) : string = match colors.Foreground, colors.Background, colors.Bold with From 926e8928147d8c05e431fb2960138bc303b8e167 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:15:21 +0900 Subject: [PATCH 181/247] Simplify category counts --- src/CCVTAC.FSharp/InputHelper.fs | 23 +++++++++-------------- src/CCVTAC.FSharp/Orchestrator.fs | 4 ++-- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 7492d5d2..9f7344a2 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -15,12 +15,10 @@ module InputHelper = type CategorizedInput = { Text: string; Category: InputCategory } - type CategoryCounts (counts: Map) = - member _.Item - with get (category: InputCategory) = - match counts.TryGetValue category with - | true, v -> v - | _ -> 0 + let countCategoryItems (category: InputCategory) (counts: Map) = + match counts.TryGetValue category with + | true, c -> c + | _ -> 0 /// Takes a user input string and splits it into a collection of inputs. let splitInputText input : string list = @@ -54,11 +52,8 @@ module InputHelper = then InputCategory.Command else InputCategory.Url }) - let countCategories (inputs: CategorizedInput list) : CategoryCounts = - let counts = - inputs - |> List.groupBy _.Category - |> List.map (fun (k, grp) -> k, grp |> Seq.length) - |> Map.ofSeq - - CategoryCounts counts + let countCategories (inputs: CategorizedInput list) : Map = + inputs + |> List.groupBy _.Category + |> List.map (fun (k, grp) -> k, grp |> Seq.length) + |> Map.ofSeq diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 6461ce1e..7860554f 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -23,7 +23,7 @@ module Orchestrator = let summarizeInput (categorizedInputs: CategorizedInput list) - (counts: CategoryCounts) + (counts: Map) (printer: Printer) : unit = @@ -208,7 +208,7 @@ module Orchestrator = /// Returns the next action the application should take (e.g., continue or quit). let processBatch (categorizedInputs: CategorizedInput list) - (categoryCounts: CategoryCounts) + (categoryCounts: Map) (settings: UserSettings) (resultTracker: ResultTracker) (history: History) From 8cbf8c37d53558f0d331baa946ba8f61d928411a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:20:35 +0900 Subject: [PATCH 182/247] Add and use hasMultiple funcs --- src/CCVTAC.FSharp/Extensions.fs | 8 +++++--- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 2 +- src/CCVTAC.FSharp/Orchestrator.fs | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 36ded00f..566b3b23 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -99,10 +99,12 @@ module Seq = module List = - let isNotEmpty list = not (List.isEmpty list) + let isNotEmpty lst = not (List.isEmpty lst) - let caseInsensitiveContains text (list: string list) : bool = - list |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) + let hasMultiple lst = lst |> List.length |> (<) 1 + + let caseInsensitiveContains text (lst: string list) : bool = + lst |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) module Array = diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 22ca63d8..1ce3dd67 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -29,7 +29,7 @@ module Runner = let splitCommandWithArgs = toolSettings.CommandWithArgs.Split([|' '|], 2) let processStartInfo = ProcessStartInfo splitCommandWithArgs[0] - processStartInfo.Arguments <- if splitCommandWithArgs.Length > 1 + processStartInfo.Arguments <- if Array.hasMultiple splitCommandWithArgs then splitCommandWithArgs[1] else String.Empty processStartInfo.UseShellExecute <- false diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 7860554f..e1f82473 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -27,7 +27,7 @@ module Orchestrator = (printer: Printer) : unit = - if categorizedInputs.Length > 1 then + if List.hasMultiple categorizedInputs then let urlCount = counts[InputCategory.Url] let cmdCount = counts[InputCategory.Command] From 199920d907bfb0f6c495f981941ca1dd0703a177 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:25:12 +0900 Subject: [PATCH 183/247] Minor stylistic updates --- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 4 ++-- src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index a3ce61ad..1e84a2ea 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -24,9 +24,9 @@ module Deleter = |> Array.iter (fun fileName -> try File.Delete fileName - printer.Debug($"• Deleted \"{fileName}\"") + printer.Debug $"• Deleted \"{fileName}\"" with - | ex -> printer.Error($"• Deletion error: {ex.Message}") + | ex -> printer.Error $"• Deletion error: {ex.Message}" ) let run diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs index 98e2f45b..b5d2cadb 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs @@ -7,9 +7,5 @@ module ImageProcessor = let private programName = "mogrify" let run workingDirectory printer : unit = - let toolSettings = - ToolSettings.create - $"{programName} -trim -fuzz 10%% *.jpg" - workingDirectory - + let toolSettings = workingDirectory |> ToolSettings.create $"{programName} -trim -fuzz 10%% *.jpg" Runner.runTool toolSettings [] printer |> ignore From 29147c94041f4e1f3c31573d13adff0d1a4e1d58 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:21:43 +0900 Subject: [PATCH 184/247] Revert "Simplify category counts" (due to regression bug) This reverts commit 926e8928147d8c05e431fb2960138bc303b8e167. --- src/CCVTAC.FSharp/InputHelper.fs | 23 ++++++++++++++--------- src/CCVTAC.FSharp/Orchestrator.fs | 4 ++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.FSharp/InputHelper.fs index 9f7344a2..7492d5d2 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.FSharp/InputHelper.fs @@ -15,10 +15,12 @@ module InputHelper = type CategorizedInput = { Text: string; Category: InputCategory } - let countCategoryItems (category: InputCategory) (counts: Map) = - match counts.TryGetValue category with - | true, c -> c - | _ -> 0 + type CategoryCounts (counts: Map) = + member _.Item + with get (category: InputCategory) = + match counts.TryGetValue category with + | true, v -> v + | _ -> 0 /// Takes a user input string and splits it into a collection of inputs. let splitInputText input : string list = @@ -52,8 +54,11 @@ module InputHelper = then InputCategory.Command else InputCategory.Url }) - let countCategories (inputs: CategorizedInput list) : Map = - inputs - |> List.groupBy _.Category - |> List.map (fun (k, grp) -> k, grp |> Seq.length) - |> Map.ofSeq + let countCategories (inputs: CategorizedInput list) : CategoryCounts = + let counts = + inputs + |> List.groupBy _.Category + |> List.map (fun (k, grp) -> k, grp |> Seq.length) + |> Map.ofSeq + + CategoryCounts counts diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index e1f82473..e8d5a0a3 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -23,7 +23,7 @@ module Orchestrator = let summarizeInput (categorizedInputs: CategorizedInput list) - (counts: Map) + (counts: CategoryCounts) (printer: Printer) : unit = @@ -208,7 +208,7 @@ module Orchestrator = /// Returns the next action the application should take (e.g., continue or quit). let processBatch (categorizedInputs: CategorizedInput list) - (categoryCounts: Map) + (categoryCounts: CategoryCounts) (settings: UserSettings) (resultTracker: ResultTracker) (history: History) From df68b223657328177fa869ade0a9a7edb0280854 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:16:23 +0900 Subject: [PATCH 185/247] Fix arg bug by using mutable set --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 1a3d4f53..88cf7a19 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -32,7 +32,7 @@ module Downloader = | Some f when f = "best" -> String.Empty | Some f -> $"-f {f}" - let args = + let mutable args = match mediaType with | None -> [ $"--flat-playlist {writeJsonArg} {trimFileNamesArg}" ] @@ -46,20 +46,20 @@ module Downloader = |> Set.ofList if settings.QuietMode then - args.Add "--quiet --no-warnings" |> ignore + args <- args.Add "--quiet --no-warnings" + // No MediaType indicates that this is a supplemental metadata-only download. match mediaType with | Some mt -> if settings.SplitChapters then - args.Add("--split-chapters") |> ignore + args <- args.Add "--split-chapters" if not mt.IsVideo && not mt.IsPlaylistVideo then - args.Add($"--sleep-interval {settings.SleepSecondsBetweenDownloads}") |> ignore + args <- args.Add $"--sleep-interval {settings.SleepSecondsBetweenDownloads}" if mt.IsStandardPlaylist then - args.Add + args <- args.Add """-o "%(uploader).80B - %(playlist).80B - %(playlist_autonumber)s - %(title).150B [%(id)s].%(ext)s" --playlist-reverse""" - |> ignore | None -> () let extraArgs = defaultArg additionalArgs [] |> Set.ofList From 7305bdeb4235661cd75cea2eff26e28eca284592 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:30:05 +0900 Subject: [PATCH 186/247] Add type-related todo --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 88cf7a19..19bf5c02 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -49,6 +49,7 @@ module Downloader = args <- args.Add "--quiet --no-warnings" // No MediaType indicates that this is a supplemental metadata-only download. + // TODO: Add a union type to more clearly indicate this difference. match mediaType with | Some mt -> if settings.SplitChapters then From ff4d1add48921dc83cb945f046ea60ca56af94c1 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:30:22 +0900 Subject: [PATCH 187/247] Rename parameter --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 19bf5c02..7505b3db 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -22,7 +22,7 @@ module Downloader = /// audioFormat: one of the supported audio format codes (or null for none) /// mediaType: Some MediaType for normal downloads, None for metadata-only supplementary downloads /// additionalArgs: optional extra args (e.g., the URL) - let generateDownloadArgs audioFormat settings (mediaType: MediaType option) additionalArgs : string = + let generateDownloadArgs audioFormat userSettings (mediaType: MediaType option) additionalArgs : string = let writeJsonArg = "--write-info-json" let trimFileNamesArg = "--trim-filenames 250" @@ -38,25 +38,25 @@ module Downloader = [ $"--flat-playlist {writeJsonArg} {trimFileNamesArg}" ] | Some _ -> [ $"--extract-audio {formatArg}" - $"--audio-quality {settings.AudioQuality}" + $"--audio-quality {userSettings.AudioQuality}" "--write-thumbnail --convert-thumbnails jpg" writeJsonArg trimFileNamesArg "--retries 2" ] |> Set.ofList - if settings.QuietMode then + if userSettings.QuietMode then args <- args.Add "--quiet --no-warnings" // No MediaType indicates that this is a supplemental metadata-only download. // TODO: Add a union type to more clearly indicate this difference. match mediaType with | Some mt -> - if settings.SplitChapters then + if userSettings.SplitChapters then args <- args.Add "--split-chapters" if not mt.IsVideo && not mt.IsPlaylistVideo then - args <- args.Add $"--sleep-interval {settings.SleepSecondsBetweenDownloads}" + args <- args.Add $"--sleep-interval {userSettings.SleepSecondsBetweenDownloads}" if mt.IsStandardPlaylist then args <- args.Add From b2bd320951175aa897cc0b983b518a8a1e881d96 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:04:35 +0900 Subject: [PATCH 188/247] More functional download process --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 1 + src/CCVTAC.FSharp/Downloading/Downloader.fs | 116 +++++++++----------- src/CCVTAC.FSharp/Orchestrator.fs | 14 ++- src/CCVTAC.FSharp/ResultTracker.fs | 10 ++ src/CCVTAC.FSharp/Settings/Settings.fs | 8 +- 5 files changed, 76 insertions(+), 73 deletions(-) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 0aba6612..125038ed 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -42,6 +42,7 @@ + diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 7505b3db..bb2687e8 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -7,6 +7,7 @@ open CCVTAC.Console.IoUtilities.Directories open CCVTAC.Console.Downloading.Downloading open CCVTAC.Console.ExternalTools open CCVTAC.Console.Settings.Settings +open FsToolkit.ErrorHandling open System module Downloader = @@ -14,8 +15,11 @@ module Downloader = [] let private programName = "yt-dlp" - type Urls = { Primary: string - Supplementary: string option } + type PrimaryUrl = PrimaryUrl of string + type SupplementaryUrl = SupplementaryUrl of string option + + type Urls = { Primary: PrimaryUrl + Metadata: SupplementaryUrl } // TODO: Is the audioFormat not in the settings? /// Generate the entire argument string for the download tool. @@ -71,74 +75,60 @@ module Downloader = /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. - let run (mediaType: MediaType) userSettings (printer: Printer) : Result = + let downloadMedia (printer: Printer) (mediaType: MediaType) userSettings (PrimaryUrl url) + : Result = + if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") - let rawUrls = generateDownloadUrl(mediaType) + let rec loop (errors: string list) formats = + match formats with + | [] -> + Error errors + | format :: fs -> + let args = generateDownloadArgs (Some format) userSettings (Some mediaType) (Some [url]) + let commandWithArgs = $"{programName} {args}" + let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory - let urls = - { Primary = rawUrls[0] - Supplementary = if rawUrls.Length = 2 then Some rawUrls[1] else None } + let downloadResult = runTool downloadSettings [1] printer + let filesDownloaded = audioFileCount userSettings.WorkingDirectory Files.audioFileExtensions > 0 - let mutable downloadResult : Result = Error String.Empty - let mutable successfulFormat = String.Empty - let mutable stopped = false + match downloadResult, filesDownloaded with + | Ok result, true -> + Ok (List.append [$"Successfully downloaded the \"{format}\" format."] errors) + | Ok result, false -> + Error (List.append errors [$"The downloader reported OK for \"{format}\", but no audio files were downloaded."]) + | Error err, true -> + Error (List.append errors [$"Error was reported for \"{format}\", but audio files were unexpectedly found."]) + | Error err, false -> + loop (List.append errors [$"Error was reported for \"{format}\", and no audio files were downloaded."]) fs - for format in userSettings.AudioFormats do - if not stopped then - let args = generateDownloadArgs (Some format) userSettings (Some mediaType) (Some [urls.Primary]) - let commandWithArgs = $"{programName} {args}" - let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory + loop [] userSettings.AudioFormats - downloadResult <- runTool downloadSettings [1] printer - - match downloadResult with - | Ok result -> - successfulFormat <- format - - if result.ExitCode <> 0 then - printer.Warning "Downloading completed with minor issues." - match result.Error with - | Some w -> printer.Warning w - | None -> () - - stopped <- true - | Error e -> - printer.Debug $"Failure downloading \"%s{format}\" format: %s{e}" - - let mutable errors = match downloadResult with Error err -> [err] | Ok _ -> [] - - if audioFileCount userSettings.WorkingDirectory Files.audioFileExtensions = 0 then - "No audio files were downloaded." :: errors - |> String.concat String.newLine - |> Error - else - // Continue to post-processing if errors. - if List.isNotEmpty errors then - errors |> List.iter printer.Error - printer.Info "Post-processing will still be attempted." - else - // Attempt a metadata-only supplementary download. - match urls.Supplementary with - | Some supplementaryUrl -> - let args = generateDownloadArgs None userSettings None (Some [supplementaryUrl]) - let commandWithArgs = $"{programName} {args}" - let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory - let supplementaryDownloadResult = runTool downloadSettings [1] printer - - match supplementaryDownloadResult with - | Ok _ -> - printer.Info "Supplementary metadata download completed OK." - | Error err -> - printer.Error "Supplementary metadata download failed." - errors <- List.append [err] errors - | None -> () - - if List.isEmpty errors then - Ok successfulFormat - else - Error (String.Join(" / ", errors)) + let downloadMetadata (printer: Printer) (mediaType: MediaType) userSettings (SupplementaryUrl url) + : Result = + match url with + | Some url' -> + let args = generateDownloadArgs None userSettings None (Some [url']) + let commandWithArgs = $"{programName} {args}" + let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory + let supplementaryDownloadResult = runTool downloadSettings [1] printer + match supplementaryDownloadResult with + | Ok _ -> Error ["Supplementary metadata download completed OK."] + | Error err -> Error [$"Supplementary metadata download failed: {err}"] + | None -> Ok ["No supplementary link found."] + /// Completes the actual download process. + /// Returns a Result that, if successful, contains the name of the successfully downloaded format. + let run (mediaType: MediaType) userSettings (printer: Printer) : Result = + result { + let rawUrls = generateDownloadUrl mediaType + let urls = + { Primary = PrimaryUrl rawUrls[0] + Metadata = SupplementaryUrl <| if rawUrls.Length = 2 then Some rawUrls[1] else None } + let! mediaDownloadResult = downloadMedia printer mediaType userSettings urls.Primary + let! metadataDownloadResult = downloadMetadata printer mediaType userSettings urls.Metadata + return! Ok metadataDownloadResult + } diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index e8d5a0a3..2def1236 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -90,12 +90,14 @@ module Orchestrator = resultTracker.RegisterResult(url, downloadResult) match downloadResult with - | Error err -> - let errorMsg = $"Download error: %s{err}" - printer.Error errorMsg - Error errorMsg - | Ok msg -> - printer.Debug $"Successfully downloaded \"%s{msg}\" format." + | Error errs -> + errs + |> List.map (sprintf "Media download error: %s") + |> String.concat String.newLine + |> Error + | Ok msgs -> + printer.Debug "Media download(s) successful!" + msgs |> List.iter printer.Info PostProcessor.run settings mediaType printer let groupClause = diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index e7c5fe41..0fe09869 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -27,6 +27,16 @@ type ResultTracker<'a>(printer: Printer) = if not (failures.TryAdd(input, e)) then failures[input] <- e + /// Logs the result for a specific corresponding input. + member _.RegisterResult(input: string, result: Result<'a list, string list>) : unit = + match result with + | Ok _ -> + successCount <- successCount + 1UL + | Error e -> + let msg = if e.Length > 0 then List.head e else String.Empty + if not (failures.TryAdd(input, msg)) then + failures[input] <- msg + /// Prints any failures for the current batch. member _.PrintBatchFailures() : unit = if Numerics.isZero failures.Count then diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index b1acaa79..6a8fabca 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -33,7 +33,7 @@ module Settings = [] MoveToDirectory: string [] HistoryFile: string [] HistoryDisplayCount: byte - [] AudioFormats: string array + [] AudioFormats: string list [] AudioQuality: byte [] SplitChapters: bool [] SleepSecondsBetweenDownloads: uint16 @@ -57,7 +57,7 @@ module Settings = SplitChapters = true SleepSecondsBetweenDownloads = 10us SleepSecondsBetweenURLs = 15us - AudioFormats = [||] + AudioFormats = [] AudioQuality = 0uy QuietMode = false EmbedImages = true @@ -152,7 +152,7 @@ module Settings = | { NormalizationForm = nf } when not(supportedNormalizationForms |> List.contains (nf.ToUpperInvariant())) -> let okFormats = String.Join(", ", supportedNormalizationForms) Error $"\"{nf}\" is an invalid normalization form. Use one of the following: {okFormats}." - | { AudioFormats = fmt } when not (fmt |> Array.forall validAudioFormat) -> + | { AudioFormats = fmt } when not (fmt |> List.forall validAudioFormat) -> let formats = String.Join(", ", fmt) let approved = supportedAudioFormats |> String.concat ", " Error $"Audio formats (\"%s{formats}\") include an unsupported audio format.{String.newLine}Only the following supported formats: {approved}." @@ -217,7 +217,7 @@ module Settings = { settings with QuietMode = not settings.QuietMode } let updateAudioFormat settings (newFormat: string) = - let updatedSettings = { settings with AudioFormats = newFormat.Split ',' } + let updatedSettings = { settings with AudioFormats = newFormat.Split ',' |> List.ofArray } validate updatedSettings let updateAudioQuality settings newQuality = From f2ccd81c78ed683a67897005a251c58ef1504c4a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:08:19 +0900 Subject: [PATCH 189/247] Use lists in the settings --- src/CCVTAC.FSharp.Tests/TagDetectionTests.fs | 10 +++--- src/CCVTAC.FSharp/Extensions.fs | 2 ++ src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- .../PostProcessing/Tagging/Tagger.fs | 4 +-- src/CCVTAC.FSharp/Settings/Settings.fs | 32 +++++++++---------- 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs index a916a5f8..c8c804d0 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs @@ -132,11 +132,11 @@ let ``Tag detection patterns detect metadata in video metadata`` () = } let tagDetectionPatterns = { - Title = [| titlePattern |] - Artist = [| artistPattern |] - Album = [| albumPattern |] - Composer = [| composerPattern |] - Year = [| yearPattern |] + Title = [ titlePattern ] + Artist = [ artistPattern ] + Album = [ albumPattern ] + Composer = [ composerPattern ] + Year = [ yearPattern ] } match TagDetection.detectArtist videoMetadata None tagDetectionPatterns with diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 566b3b23..424b21bd 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -101,6 +101,8 @@ module List = let isNotEmpty lst = not (List.isEmpty lst) + let doesNotContain x lst = not <| List.contains x lst + let hasMultiple lst = lst |> List.length |> (<) 1 let caseInsensitiveContains text (lst: string list) : bool = diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index ba99db0e..dfd747b4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -79,7 +79,7 @@ module Renamer = for audioFile in audioFiles do let newFileName = userSettings.RenamePatterns - |> Array.fold + |> List.fold (fun (sb: SB) -> updateTextViaPatterns userSettings.QuietMode printer sb) (SB audioFile.Name) |> _.ToString() diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index ef451873..15c1d296 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -60,7 +60,7 @@ module Tagger = printer.Error $"Error writing image to the audio file: %s{ex.Message}" let private releaseYear userSettings videoMetadata : uint32 option = - if userSettings.IgnoreUploadYearUploaders |> Array.caseInsensitiveContains videoMetadata.Uploader + if userSettings.IgnoreUploadYearUploaders |> List.caseInsensitiveContains videoMetadata.Uploader then None elif videoMetadata.UploadDate.Length <> 4 then None @@ -157,7 +157,7 @@ module Tagger = // Artwork embedding match imageFilePath with | Some path -> - if settings.EmbedImages && settings.DoNotEmbedImageUploaders |> Array.doesNotContain videoData.Uploader + if settings.EmbedImages && settings.DoNotEmbedImageUploaders |> List.doesNotContain videoData.Uploader then printer.Info "Embedding artwork." writeImage taggedFile path printer diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index 6a8fabca..f1bfc102 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -21,11 +21,11 @@ module Settings = } type TagDetectionPatterns = { - [] Title : TagDetectionPattern array - [] Artist : TagDetectionPattern array - [] Album : TagDetectionPattern array - [] Composer : TagDetectionPattern array - [] Year : TagDetectionPattern array + [] Title : TagDetectionPattern list + [] Artist : TagDetectionPattern list + [] Album : TagDetectionPattern list + [] Composer : TagDetectionPattern list + [] Year : TagDetectionPattern list } type UserSettings = { @@ -40,10 +40,10 @@ module Settings = [] SleepSecondsBetweenURLs: uint16 [] QuietMode: bool [] EmbedImages: bool - [] DoNotEmbedImageUploaders: string array - [] IgnoreUploadYearUploaders: string array + [] DoNotEmbedImageUploaders: string list + [] IgnoreUploadYearUploaders: string list [] TagDetectionPatterns: TagDetectionPatterns - [] RenamePatterns: RenamePattern array + [] RenamePatterns: RenamePattern list [] NormalizationForm : string [] DownloaderUpdateCommand : string } @@ -61,16 +61,16 @@ module Settings = AudioQuality = 0uy QuietMode = false EmbedImages = true - DoNotEmbedImageUploaders = [||] - IgnoreUploadYearUploaders = [||] + DoNotEmbedImageUploaders = [] + IgnoreUploadYearUploaders = [] TagDetectionPatterns = { - Title = [||] - Artist = [||] - Album = [||] - Composer = [||] - Year = [||] + Title = [] + Artist = [] + Album = [] + Composer = [] + Year = [] } - RenamePatterns = [||] + RenamePatterns = [] NormalizationForm = "C" // Recommended for compatibility between Linux and macOS. DownloaderUpdateCommand = String.Empty } From baabf659974ecf9e29e164d9b93d1eb30f7fb6f7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:46:18 +0900 Subject: [PATCH 190/247] Check downloader exit code --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index bb2687e8..eae6701f 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -81,7 +81,7 @@ module Downloader = if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") - let rec loop (errors: string list) formats = + let rec loop errors formats = match formats with | [] -> Error errors @@ -95,17 +95,23 @@ module Downloader = match downloadResult, filesDownloaded with | Ok result, true -> - Ok (List.append [$"Successfully downloaded the \"{format}\" format."] errors) + if result.ExitCode = 0 then + Ok (List.append [$"Successfully downloaded the \"{format}\" format."] errors) + else + match result.Error with + | Some warning -> [$"Successfully downloaded the \"{format}\" format, but with minor issues: {warning}"] + | None -> ["Downloading completed with unknown minor issues."] + |> Ok | Ok result, false -> Error (List.append errors [$"The downloader reported OK for \"{format}\", but no audio files were downloaded."]) | Error err, true -> - Error (List.append errors [$"Error was reported for \"{format}\", but audio files were unexpectedly found."]) + Error (List.append errors [$"Error was reported for \"{format}\", but audio files were unexpectedly found: {err}"]) | Error err, false -> - loop (List.append errors [$"Error was reported for \"{format}\", and no audio files were downloaded."]) fs + loop (List.append errors [$"Error was reported for \"{format}\" format. No audio files were downloaded. {err}"]) fs loop [] userSettings.AudioFormats - let downloadMetadata (printer: Printer) (mediaType: MediaType) userSettings (SupplementaryUrl url) + let downloadMetadata (printer: Printer) userSettings (SupplementaryUrl url) : Result = match url with @@ -128,7 +134,7 @@ module Downloader = let urls = { Primary = PrimaryUrl rawUrls[0] Metadata = SupplementaryUrl <| if rawUrls.Length = 2 then Some rawUrls[1] else None } - let! mediaDownloadResult = downloadMedia printer mediaType userSettings urls.Primary - let! metadataDownloadResult = downloadMetadata printer mediaType userSettings urls.Metadata + let! _ = downloadMedia printer mediaType userSettings urls.Primary + let! metadataDownloadResult = downloadMetadata printer userSettings urls.Metadata return! Ok metadataDownloadResult } From 1ed7ce2fb04b287bd35c0311628089533dcfe646 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:11:23 +0900 Subject: [PATCH 191/247] Use anonymous record type --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 2 +- src/CCVTAC.FSharp/ExternalTools/Runner.fs | 26 ++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index eae6701f..3d38110c 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -124,7 +124,7 @@ module Downloader = match supplementaryDownloadResult with | Ok _ -> Error ["Supplementary metadata download completed OK."] | Error err -> Error [$"Supplementary metadata download failed: {err}"] - | None -> Ok ["No supplementary link found."] + | None -> Ok ["No supplementary metadata link found."] /// Completes the actual download process. /// Returns a Result that, if successful, contains the name of the successfully downloaded format. diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.FSharp/ExternalTools/Runner.fs index 1ce3dd67..fb3f9187 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.FSharp/ExternalTools/Runner.fs @@ -26,13 +26,13 @@ module Runner = let watch = Watch() printer.Info $"Running {toolSettings.CommandWithArgs}..." - let splitCommandWithArgs = toolSettings.CommandWithArgs.Split([|' '|], 2) + let command = + toolSettings.CommandWithArgs.Split([|' '|], 2) + |> fun arr -> {| Tool = arr[0] + Args = if Array.hasMultiple arr then arr[1] else String.Empty |} - let processStartInfo = ProcessStartInfo splitCommandWithArgs[0] - processStartInfo.Arguments <- if Array.hasMultiple splitCommandWithArgs - then splitCommandWithArgs[1] - else String.Empty - processStartInfo.UseShellExecute <- false + let processStartInfo = ProcessStartInfo command.Tool + processStartInfo.Arguments <- command.Args processStartInfo.RedirectStandardOutput <- false processStartInfo.RedirectStandardError <- true processStartInfo.CreateNoWindow <- true @@ -40,17 +40,17 @@ module Runner = match Process.Start processStartInfo with | Null -> - Error $"Could not locate {splitCommandWithArgs[0]}." + Error $"Could not locate or start {command.Tool}." | NonNull process' -> let error = process'.StandardError.ReadToEnd() process'.WaitForExit() - printer.Info $"{splitCommandWithArgs[0]} finished in {watch.ElapsedFriendly}." + printer.Info $"{command.Tool} finished in {watch.ElapsedFriendly}." - let trimmedErrors = if String.hasText error - then Some (String.trimTerminalLineBreak error) - else None + let trimmedError = if String.hasText error + then Some (String.trimTerminalLineBreak error) + else None if isSuccessExitCode otherSuccessExitCodes process'.ExitCode - then Ok { ExitCode = process'.ExitCode; Error = trimmedErrors } - else Error $"{splitCommandWithArgs[0]} exited with code {process'.ExitCode}: {trimmedErrors}." + then Ok { ExitCode = process'.ExitCode; Error = trimmedError } + else Error $"{command.Tool} exited with code {process'.ExitCode}: {trimmedError}." From 52ccc3c0df35969b6f18f93c6983d2ac3684f391 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:49:31 +0900 Subject: [PATCH 192/247] Improve handling of download results --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 31 +++++++++++-------- .../PostProcessing/Tagging/Tagger.fs | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 3d38110c..f2f393b7 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -81,11 +81,11 @@ module Downloader = if not mediaType.IsVideo && not mediaType.IsPlaylistVideo then printer.Info("Please wait for multiple videos to be downloaded...") - let rec loop errors formats = - match formats with + let rec loop errors audioFormats = + match audioFormats with | [] -> Error errors - | format :: fs -> + | format :: formats -> let args = generateDownloadArgs (Some format) userSettings (Some mediaType) (Some [url]) let commandWithArgs = $"{programName} {args}" let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory @@ -95,19 +95,24 @@ module Downloader = match downloadResult, filesDownloaded with | Ok result, true -> - if result.ExitCode = 0 then - Ok (List.append [$"Successfully downloaded the \"{format}\" format."] errors) - else - match result.Error with - | Some warning -> [$"Successfully downloaded the \"{format}\" format, but with minor issues: {warning}"] - | None -> ["Downloading completed with unknown minor issues."] - |> Ok + Ok <| + $"Successfully downloaded the \"{format}\" format." + :: match result.Error with + | Some err -> [$"However, a minor issue was reported: {err}"] + | None -> [] | Ok result, false -> - Error (List.append errors [$"The downloader reported OK for \"{format}\", but no audio files were downloaded."]) + Error <| + $"The \"{format}\" format download was reported as successful, but no audio files were downloaded!" + :: match result.Error with + | Some err -> [$"Error: {err}"] + | None -> [] | Error err, true -> - Error (List.append errors [$"Error was reported for \"{format}\", but audio files were unexpectedly found: {err}"]) + Error <| + [$"The downloader reported failure for \"{format}\", yet audio files were unexpectedly downloaded!" + $"Error: {err}"] | Error err, false -> - loop (List.append errors [$"Error was reported for \"{format}\" format. No audio files were downloaded. {err}"]) fs + let newErr = $"A download error was reported for the \"{format}\" format, and no audio files were downloaded. {err}" + loop (List.append errors [newErr]) formats loop [] userSettings.AudioFormats diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 15c1d296..ded5f164 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -178,7 +178,7 @@ module Tagger = : unit = - printer.Debug $"""%s{String.fileLabel (Some "audio") taggingSet.AudioFilePaths.Length} with resource ID %s{taggingSet.ResourceId}""" + printer.Debug $"""Found %s{String.fileLabel (Some "audio") taggingSet.AudioFilePaths.Length} with resource ID %s{taggingSet.ResourceId}.""" match parseVideoJson taggingSet with | Ok videoData -> From aaf5b8ac260fd753ede9a055d148e52d8b77620e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:55:41 +0900 Subject: [PATCH 193/247] Command updates; tweaks --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index f2f393b7..945437eb 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -74,7 +74,6 @@ module Downloader = mediaTypeWithIds url /// Completes the actual download process. - /// Returns a Result that, if successful, contains the name of the successfully downloaded format. let downloadMedia (printer: Printer) (mediaType: MediaType) userSettings (PrimaryUrl url) : Result = @@ -120,19 +119,18 @@ module Downloader = : Result = match url with + | None -> Ok ["No supplementary metadata link found."] | Some url' -> let args = generateDownloadArgs None userSettings None (Some [url']) let commandWithArgs = $"{programName} {args}" let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory - let supplementaryDownloadResult = runTool downloadSettings [1] printer + let metadataDownloadResult = runTool downloadSettings [1] printer - match supplementaryDownloadResult with + match metadataDownloadResult with | Ok _ -> Error ["Supplementary metadata download completed OK."] | Error err -> Error [$"Supplementary metadata download failed: {err}"] - | None -> Ok ["No supplementary metadata link found."] - /// Completes the actual download process. - /// Returns a Result that, if successful, contains the name of the successfully downloaded format. + /// Undertakes the actual download process. let run (mediaType: MediaType) userSettings (printer: Printer) : Result = result { let rawUrls = generateDownloadUrl mediaType From deb840766265f6fd9e51c26d8b5dfd44ef210f73 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:58:45 +0900 Subject: [PATCH 194/247] Relocate download types --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 6 ------ src/CCVTAC.FSharp/Downloading/Downloading.fs | 8 +++++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 945437eb..7ebe06ee 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -15,12 +15,6 @@ module Downloader = [] let private programName = "yt-dlp" - type PrimaryUrl = PrimaryUrl of string - type SupplementaryUrl = SupplementaryUrl of string option - - type Urls = { Primary: PrimaryUrl - Metadata: SupplementaryUrl } - // TODO: Is the audioFormat not in the settings? /// Generate the entire argument string for the download tool. /// audioFormat: one of the supported audio format codes (or null for none) diff --git a/src/CCVTAC.FSharp/Downloading/Downloading.fs b/src/CCVTAC.FSharp/Downloading/Downloading.fs index c0120d1a..780d81c7 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloading.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloading.fs @@ -2,7 +2,7 @@ open System.Text.RegularExpressions -module public Downloading = +module Downloading = type MediaType = | Video of Id: string @@ -11,6 +11,12 @@ module public Downloading = | ReleasePlaylist of Id: string | Channel of Id: string + type PrimaryUrl = PrimaryUrl of string + type SupplementaryUrl = SupplementaryUrl of string option + + type Urls = { Primary: PrimaryUrl + Metadata: SupplementaryUrl } + let private (|RegexMatch|_|) pattern input = match Regex.Match(input, pattern) with | m when m.Success -> Some (List.tail [for g in m.Groups -> g.Value]) From 4d1cb2ab7e12ec61dc9a0a4dbcba70fffc429fb6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:59:48 +0900 Subject: [PATCH 195/247] Delete func comments --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 7ebe06ee..a0c8e589 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -15,11 +15,6 @@ module Downloader = [] let private programName = "yt-dlp" - // TODO: Is the audioFormat not in the settings? - /// Generate the entire argument string for the download tool. - /// audioFormat: one of the supported audio format codes (or null for none) - /// mediaType: Some MediaType for normal downloads, None for metadata-only supplementary downloads - /// additionalArgs: optional extra args (e.g., the URL) let generateDownloadArgs audioFormat userSettings (mediaType: MediaType option) additionalArgs : string = let writeJsonArg = "--write-info-json" let trimFileNamesArg = "--trim-filenames 250" @@ -67,7 +62,6 @@ module Downloader = let wrapUrlInMediaType url : Result = mediaTypeWithIds url - /// Completes the actual download process. let downloadMedia (printer: Printer) (mediaType: MediaType) userSettings (PrimaryUrl url) : Result = @@ -124,7 +118,6 @@ module Downloader = | Ok _ -> Error ["Supplementary metadata download completed OK."] | Error err -> Error [$"Supplementary metadata download failed: {err}"] - /// Undertakes the actual download process. let run (mediaType: MediaType) userSettings (printer: Printer) : Result = result { let rawUrls = generateDownloadUrl mediaType From 32a6703718ca458ff6690d0e1af52fee049288cd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:37:33 +0900 Subject: [PATCH 196/247] Modify text --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index ded5f164..89f647ed 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -159,12 +159,12 @@ module Tagger = | Some path -> if settings.EmbedImages && settings.DoNotEmbedImageUploaders |> List.doesNotContain videoData.Uploader then - printer.Info "Embedding artwork." + printer.Info "Embedding artwork..." writeImage taggedFile path printer else printer.Debug "Skipping artwork embedding." | None -> - printer.Debug "Skipping artwork embedding." + printer.Debug "Skipping artwork embedding as none was found." taggedFile.Save() printer.Debug $"Wrote tags to \"%s{audioFileName}\"." From 7cb24c25c5c0fa69cef5d1d401b652a0a1847816 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:30:47 +0900 Subject: [PATCH 197/247] Update readme --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d1c6bf12..5ab69bc0 100644 --- a/README.md +++ b/README.md @@ -209,10 +209,8 @@ If you run into any issues, feel free to create an issue on GitHub. Please provi ## History -The first incarnation of this application was written in C#. +The first incarnation of this application was written in C#. However, after picking up [F#](https://fsharp.org/) out of curiosity about it and functional programming (FP) in 2024 and subsequently using it successfully to create other tools (mainly [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in an FP style, I become curious about F#'s OOP capabilities as well. -After picking up F# out of curiosity in 2024 and using it to create other tools (such as [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in a functional programming style, I become curious about F#'s OOP capabilities as well. +As an experiment to both test OOP-style F# and LLMs more, I rewrote this application in OOP F#, using LLMs only for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C#, so I decided to keep this tool in F# permanently. -As an experiment to both test OOP F# and leverage LLMs more, I rewrote this application in OOP F#, only using LLMs for the initial conversion, which greatly reduced the time necessary, though a *lot* of cleanup was necessary. Ultimately, I was surprised to see that I preferred the F# code over the C#, so I decided to keep this tool in F#. - -Due to this, the code is not particularly idiomatic F#. I'll probably tweak it over time to gradually bring it closer to the functional style, but the tool is perfectly viable and works well in its current blended-style form. +Due to this background, the code is not particularly idiomatic F#, but it is perfectly viable in its current blended-style form. That said, I'll probably tweak it over time to gradually to introduce more FP. From a3e4bec268b7b09c431cd5fef9ac92e8e7f9daac Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:45:36 +0900 Subject: [PATCH 198/247] Clearer code in delete-file func --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 89f647ed..07062138 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -28,8 +28,10 @@ module Tagger = with ex -> Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}." + /// If a video was split into sub-videos, then the original video is unneeded and should be deleted. let private deleteSourceFile taggingSet (printer: Printer) : TaggingSet = - if taggingSet.AudioFilePaths.Length <= 1 then taggingSet + if not (List.hasMultiple taggingSet.AudioFilePaths) then + taggingSet else let largestFileInfo = taggingSet.AudioFilePaths From eb5f698113f8fa02aac51ecaac48bd8316f3767d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:45:58 +0900 Subject: [PATCH 199/247] Rename func; minor code layout tweak --- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 07062138..85fdc9f7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -49,7 +49,7 @@ module Tagger = printer.Error $"Error deleting pre-split source file \"%s{largestFileInfo.Name}\": %s{ex.Message}" taggingSet - let private writeImage (taggedFile: TaggedFile) imageFilePath (printer: Printer) = + let private writeImageToFile (taggedFile: TaggedFile) imageFilePath (printer: Printer) = if String.hasNoText imageFilePath then printer.Error "No image file path was provided, so cannot add an image to the file." else @@ -62,10 +62,10 @@ module Tagger = printer.Error $"Error writing image to the audio file: %s{ex.Message}" let private releaseYear userSettings videoMetadata : uint32 option = - if userSettings.IgnoreUploadYearUploaders |> List.caseInsensitiveContains videoMetadata.Uploader - then None - elif videoMetadata.UploadDate.Length <> 4 - then None + if userSettings.IgnoreUploadYearUploaders |> List.caseInsensitiveContains videoMetadata.Uploader then + None + elif videoMetadata.UploadDate.Length <> 4 then + None else match UInt32.TryParse(videoMetadata.UploadDate.Substring(0, 4)) with | true, parsed -> Some parsed @@ -162,7 +162,7 @@ module Tagger = if settings.EmbedImages && settings.DoNotEmbedImageUploaders |> List.doesNotContain videoData.Uploader then printer.Info "Embedding artwork..." - writeImage taggedFile path printer + writeImageToFile taggedFile path printer else printer.Debug "Skipping artwork embedding." | None -> From 3b7b90c88b415ae55f677803a6ffee4255dc3b3e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:17:52 +0900 Subject: [PATCH 200/247] Combine nested trys --- .../PostProcessing/Tagging/Tagger.fs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 85fdc9f7..38b55a7a 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -19,14 +19,12 @@ module Tagger = let private parseVideoJson taggingSet : Result = try let json = File.ReadAllText taggingSet.JsonFilePath - try - match JsonSerializer.Deserialize json with - | Null -> Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"." - | NonNull v -> Ok v - with - | :? JsonException as ex -> Error $"%s{ex.Message}%s{String.newLine}%s{ex.StackTrace}" - with ex -> - Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{ex.Message}." + match JsonSerializer.Deserialize json with + | Null -> Error $"Deserialized JSON was null for \"%s{taggingSet.JsonFilePath}\"." + | NonNull v -> Ok v + with + | :? JsonException as exn -> Error $"%s{exn.Message}%s{String.newLine}%s{exn.StackTrace}" + | exn -> Error $"Error reading JSON file \"%s{taggingSet.JsonFilePath}\": %s{exn.Message}." /// If a video was split into sub-videos, then the original video is unneeded and should be deleted. let private deleteSourceFile taggingSet (printer: Printer) : TaggingSet = @@ -78,7 +76,7 @@ module Tagger = (imageFilePath: string option) (collectionData: CollectionMetadata option) (printer: Printer) - = + : unit = let audioFileName = Path.GetFileName audioFilePath printer.Debug $"Current audio file: \"%s{audioFileName}\"" @@ -177,8 +175,7 @@ module Tagger = (collectionJson: CollectionMetadata option) (embedImages: bool) (printer: Printer) - : unit - = + : unit = printer.Debug $"""Found %s{String.fileLabel (Some "audio") taggingSet.AudioFilePaths.Length} with resource ID %s{taggingSet.ResourceId}.""" From aedc142db6f3bce2afb5d5a60b626c55f2b5f988 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:33:44 +0900 Subject: [PATCH 201/247] Use 'detected' instead of 'found' --- .../PostProcessing/Tagging/Tagger.fs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 38b55a7a..1d76ee7c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -91,7 +91,7 @@ module Tagger = else match TagDetection.detectTitle videoData (Some videoData.Title) patterns with | Some title -> - printer.Debug $"• Found title \"%s{title}\"" + printer.Debug $"• Detected title \"%s{title}\"" taggedFile.Tag.Title <- title | None -> printer.Debug "No title was found." @@ -109,7 +109,7 @@ module Tagger = match TagDetection.detectArtist videoData None patterns with | None -> () | Some artist -> - printer.Debug $"• Found artist \"%s{artist}\"" + printer.Debug $"• Detected artist \"%s{artist}\"" taggedFile.Tag.Performers <- [| artist |] // Album @@ -121,14 +121,14 @@ module Tagger = match TagDetection.detectAlbum videoData collectionTitle patterns with | None -> () | Some album -> - printer.Debug $"• Found album \"%s{album}\"" + printer.Debug $"• Detected album \"%s{album}\"" taggedFile.Tag.Album <- album // Composers match TagDetection.detectComposers videoData patterns with | None -> () | Some composers -> - printer.Debug $"• Found composer(s) \"%s{composers}\"" + printer.Debug $"• Detected composer(s) \"%s{composers}\"" taggedFile.Tag.Composers <- [| composers |] // Track number @@ -148,7 +148,7 @@ module Tagger = match TagDetection.detectReleaseYear videoData defaultYear patterns with | None -> () | Some year -> - printer.Debug $"• Found year \"%d{year}\"" + printer.Debug $"• Detected year \"%d{year}\"" taggedFile.Tag.Year <- year // Comment @@ -157,14 +157,15 @@ module Tagger = // Artwork embedding match imageFilePath with | Some path -> - if settings.EmbedImages && settings.DoNotEmbedImageUploaders |> List.doesNotContain videoData.Uploader + if settings.EmbedImages + && settings.DoNotEmbedImageUploaders |> List.doesNotContain videoData.Uploader then printer.Info "Embedding artwork..." writeImageToFile taggedFile path printer else printer.Debug "Skipping artwork embedding." | None -> - printer.Debug "Skipping artwork embedding as none was found." + printer.Debug "Skipping artwork embedding as no artwork was found." taggedFile.Save() printer.Debug $"Wrote tags to \"%s{audioFileName}\"." From e7a06b9ed4f418ceabe08101763dfb818765e18c Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:41:57 +0900 Subject: [PATCH 202/247] Add try/with to tag-saving --- .../PostProcessing/Tagging/Tagger.fs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index 1d76ee7c..dbcdcc45 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -1,8 +1,5 @@ namespace CCVTAC.Console.PostProcessing.Tagging -open System -open System.IO -open System.Text.Json open CCVTAC.Console open CCVTAC.Console.Settings.Settings open CCVTAC.Console.PostProcessing @@ -11,6 +8,9 @@ open CCVTAC.Console.Downloading.Downloading open Startwatch.Library open TaggingSets open MetadataUtilities +open System +open System.IO +open System.Text.Json type TaggedFile = TagLib.File @@ -43,8 +43,8 @@ module Tagger = { taggingSet with AudioFilePaths = taggingSet.AudioFilePaths |> List.except [largestFileInfo.FullName] } - with ex -> - printer.Error $"Error deleting pre-split source file \"%s{largestFileInfo.Name}\": %s{ex.Message}" + with exn -> + printer.Error $"Error deleting pre-split source file \"%s{largestFileInfo.Name}\": %s{exn.Message}" taggingSet let private writeImageToFile (taggedFile: TaggedFile) imageFilePath (printer: Printer) = @@ -56,8 +56,8 @@ module Tagger = pics[0] <- TagLib.Picture imageFilePath taggedFile.Tag.Pictures <- pics printer.Debug "Image written to file tags OK." - with ex -> - printer.Error $"Error writing image to the audio file: %s{ex.Message}" + with exn -> + printer.Error $"Error writing image to the audio file: %s{exn.Message}" let private releaseYear userSettings videoMetadata : uint32 option = if userSettings.IgnoreUploadYearUploaders |> List.caseInsensitiveContains videoMetadata.Uploader then @@ -167,8 +167,11 @@ module Tagger = | None -> printer.Debug "Skipping artwork embedding as no artwork was found." - taggedFile.Save() - printer.Debug $"Wrote tags to \"%s{audioFileName}\"." + try + taggedFile.Save() + printer.Debug $"Wrote tags to \"%s{audioFileName}\"." + with exn -> + printer.Error $"Failed to save tags: ${exn.Message}" let private processTaggingSet (settings: UserSettings) From 7bedcd789a64baff148a92a72bd99f21ea068ef4 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:43:20 +0900 Subject: [PATCH 203/247] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ab69bc0..02e6bcd2 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,6 @@ If you run into any issues, feel free to create an issue on GitHub. Please provi The first incarnation of this application was written in C#. However, after picking up [F#](https://fsharp.org/) out of curiosity about it and functional programming (FP) in 2024 and subsequently using it successfully to create other tools (mainly [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in an FP style, I become curious about F#'s OOP capabilities as well. -As an experiment to both test OOP-style F# and LLMs more, I rewrote this application in OOP F#, using LLMs only for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C#, so I decided to keep this tool in F# permanently. +As an experiment to both test OOP-style F# and LLMs more, I rewrote this application in OOP F#, using LLMs only for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C# (and how much less code there was too), so I decided to keep this tool in F# permanently. Due to this background, the code is not particularly idiomatic F#, but it is perfectly viable in its current blended-style form. That said, I'll probably tweak it over time to gradually to introduce more FP. From d42a34037b224ac0089410633beef428376f0cbc Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:53:34 +0900 Subject: [PATCH 204/247] Unify exception bindings' names to 'exn' --- src/CCVTAC.FSharp/History.fs | 8 ++++---- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 3 ++- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 8 ++++---- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 3 ++- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 4 ++-- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 4 ++-- src/CCVTAC.FSharp/Program.fs | 6 +++--- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 49081cd2..b230b9a6 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -18,8 +18,8 @@ type History(filePath: string, displayCount: byte) = let serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") File.AppendAllText(this.FilePath, serializedEntryTime + string separator + url + String.newLine) printer.Debug $"Added \"%s{url}\" to the history log." - with ex -> - printer.Error $"Could not append URL(s) to history log: {ex.Message}" + with exn -> + printer.Error $"Could not append URL(s) to history log: {exn.Message}" member this.ShowRecent(printer: Printer) : unit = try @@ -52,5 +52,5 @@ type History(filePath: string, displayCount: byte) = table.AddRow(formattedTime, joinedUrls) |> ignore Printer.PrintTable table - with ex -> - printer.Error $"Could not display history: %s{ex.Message}" + with exn -> + printer.Error $"Could not display history: %s{exn.Message}" diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index ad2c39d6..e17a127d 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -44,7 +44,8 @@ module Directories = (fun (s: uint, errs: ErrorList) fileName -> try File.Delete fileName (s + 1u, errs) - with ex -> errs.Add ex.Message; s, errs) + with exn -> + errs.Add exn.Message; s, errs) (0u, ErrorList()) fileNames diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index 1e84a2ea..7a5acea4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -13,7 +13,7 @@ module Deleter = | None -> Ok [||] | Some metadata -> try Ok (Directory.GetFiles(workingDirectory, $"*{metadata.Id}*")) - with ex -> Error $"Error collecting filenames: {ex.Message}" + with exn -> Error $"Error collecting filenames: {exn.Message}" let private deleteAll (fileNames: string array) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 79713edf..ec5afcc1 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -47,9 +47,9 @@ module Mover = File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) successCount <- successCount + 1 printer.Debug $"• Moved \"%s{file.Name}\"" - with ex -> + with exn -> failureCount <- failureCount + 1 - printer.Error $"• Error moving file \"%s{file.Name}\": %s{ex.Message}" + printer.Error $"• Error moving file \"%s{file.Name}\": %s{exn.Message}" (successCount, failureCount) @@ -74,8 +74,8 @@ module Mover = let dest = Path.Combine(moveToDir, $"%s{baseFileName.Trim()}.jpg") fileInfo.MoveTo(dest, overwrite = overwrite) Ok $"Image file \"{fileInfo.Name}\" was moved." - with ex -> - Error $"Error copying the image file: %s{ex.Message}" + with exn -> + Error $"Error copying the image file: %s{exn.Message}" let private getParsedVideoJson (taggingSet: TaggingSet) : Result = try diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 4b8fdab5..ca6fdcca 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -45,7 +45,8 @@ module PostProcessor = if List.isEmpty taggingSets then Error $"No tagging sets were created using working directory \"%s{directoryName}\"." else Ok taggingSets - with ex -> Error $"Error reading working files in \"{directoryName}\": %s{ex.Message}" + with exn -> + Error $"Error reading working files in \"{directoryName}\": %s{exn.Message}" let run settings mediaType (printer: Printer) : unit = let watch = Watch() diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index dfd747b4..8d254bfa 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -93,7 +93,7 @@ module Renamer = printer.Debug $"• From: \"%s{audioFile.Name}\"" printer.Debug $" To: \"%s{newFileName}\"" - with ex -> - printer.Error $"• Error renaming \"%s{audioFile.Name}\": %s{ex.Message}" + with exn -> + printer.Error $"• Error renaming \"%s{audioFile.Name}\": %s{exn.Message}" printer.Info $"Renaming done in %s{watch.ElapsedFriendly}." diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index dbcdcc45..a4909c5d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -196,8 +196,8 @@ module Tagger = for audioPath in finalTaggingSet.AudioFilePaths do try tagSingleFile settings videoData audioPath imagePath collectionJson printer - with ex -> - printer.Error $"Error tagging file: %s{ex.Message}" + with exn -> + printer.Error $"Error tagging file: %s{exn.Message}" | Error err -> printer.Error $"Error deserializing video metadata from \"%s{taggingSet.JsonFilePath}\": {err}" diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index f383eae3..f880e8dd 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -68,8 +68,8 @@ module Program = try Orchestrator.start settings printer int ExitCodes.Success - with ex -> - printer.Critical $"Fatal error: %s{ex.Message}" - AnsiConsole.WriteException ex + with exn -> + printer.Critical $"Fatal error: %s{exn.Message}" + AnsiConsole.WriteException exn printer.Info "Please help improve this tool by reporting this error and any relevant URLs at https://github.com/codeconscious/ccvtac/issues." int ExitCodes.OperationError From 39598227e6bb3fc0bc099dec0ec42c8610a5275b Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:31:53 +0900 Subject: [PATCH 205/247] Prefer 'item' over 'group' in message --- src/CCVTAC.FSharp/Orchestrator.fs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 2def1236..0c7ac72d 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -73,7 +73,7 @@ module Orchestrator = |> fun msg -> printer.Info($"{String.newLine}{msg}", appendLines = 1uy) if batchSize > 1 then - printer.Info $"Processing group %d{urlIndex} of %d{batchSize}..." + printer.Info $"Processing item %d{urlIndex} of %d{batchSize}..." let jobWatch = Watch() @@ -102,11 +102,13 @@ module Orchestrator = let groupClause = if batchSize > 1 - then $" (group %d{urlIndex} of %d{batchSize})" + then $" (item %d{urlIndex} of %d{batchSize})" else String.Empty printer.Info $"Processed '%s{url}'%s{groupClause} in %s{jobWatch.ElapsedFriendly}." - Ok { NextAction = NextAction.Continue; UpdatedSettings = None } + + Ok { NextAction = NextAction.Continue + UpdatedSettings = None } let summarizeToggle settingName setting = sprintf "%s was toggled to %s for this session." settingName (if setting then "ON" else "OFF") From 08fed1ce463ebbb39ebac31665fa70ae0d6c3d32 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:52:43 +0900 Subject: [PATCH 206/247] Minor code tweak --- src/CCVTAC.FSharp/Orchestrator.fs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 0c7ac72d..86d09c4d 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -171,7 +171,7 @@ module Orchestrator = Ok { NextAction = NextAction.Continue; UpdatedSettings = Some newSettings } // Update audio formats - elif String.startsWithIgnoreCase Commands.updateAudioFormatPrefix command then + elif command |> String.startsWithIgnoreCase Commands.updateAudioFormatPrefix then let format = command.Replace(Commands.updateAudioFormatPrefix, String.Empty).ToLowerInvariant() if String.hasNoText format then Error "You must append one or more supported audio formats separated by commas (e.g., \"m4a,opus,best\")." @@ -184,7 +184,7 @@ module Orchestrator = Ok { NextAction = NextAction.Continue; UpdatedSettings = Some newSettings } // Update audio quality - elif String.startsWithIgnoreCase Commands.updateAudioQualityPrefix command then + elif command |> String.startsWithIgnoreCase Commands.updateAudioQualityPrefix then let inputQuality = command.Replace(Commands.updateAudioQualityPrefix, String.Empty) if String.hasNoText inputQuality then Error "You must enter a number representing an audio quality between 10 (lowest) and 0 (highest)." @@ -203,9 +203,10 @@ module Orchestrator = // Unknown command else - Error <| sprintf "\"%s\" is not a valid command. Enter \"%shelp\" to see a list of commands." - command - (string Commands.prefix) + Error <| + sprintf "\"%s\" is not a valid command. Enter \"%shelp\" to see a list of commands." + command + (string Commands.prefix) /// Processes a single user request, from input to downloading and file post-processing. From 0cff77bcedb26a284eee4a5cca2a481fb0072571 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:35:43 +0900 Subject: [PATCH 207/247] Add FSharpPlus package --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index 125038ed..b9ce6ac6 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -42,6 +42,7 @@ + From 58d613e2cf6722d2713bf0e4b5b9ae1bec786b7e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:44:42 +0900 Subject: [PATCH 208/247] Use int, not byte, for history display count --- src/CCVTAC.FSharp/History.fs | 11 +++++------ src/CCVTAC.FSharp/Settings/Settings.fs | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index b230b9a6..b0318c2f 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -5,17 +5,18 @@ open System.IO open System.Text.Json open Spectre.Console -type History(filePath: string, displayCount: byte) = +type History(filePath: string, displayCount: int) = let separator = ';' member private _.FilePath = filePath member private _.DisplayCount = displayCount - /// Add a URL and related data to the history file. + /// Write a URL and its related data to the history file. member this.Append(url: string, entryTime: DateTime, printer: Printer) : unit = try let serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") + // TODO: IO should be placed elsewhere. File.AppendAllText(this.FilePath, serializedEntryTime + string separator + url + String.newLine) printer.Debug $"Added \"%s{url}\" to the history log." with exn -> @@ -23,12 +24,10 @@ type History(filePath: string, displayCount: byte) = member this.ShowRecent(printer: Printer) : unit = try - // Read lines and take the last N lines in the original order - let max = int this.DisplayCount let lines = File.ReadAllLines this.FilePath |> Seq.rev - |> Seq.truncate max + |> Seq.truncate this.DisplayCount |> Seq.rev |> Seq.toList @@ -40,7 +39,7 @@ type History(filePath: string, displayCount: byte) = |> Seq.groupBy fst |> Seq.map (fun (dt, pairs) -> dt, pairs |> Seq.map snd |> Seq.toList) - // TODO: This shouldn't be here. + // TODO: These presentation matters shouldn't be here. let table = Table() table.Border <- TableBorder.None table.AddColumns("Time", "URL") |> ignore diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index f1bfc102..c654b0c2 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -32,7 +32,7 @@ module Settings = [] WorkingDirectory: string [] MoveToDirectory: string [] HistoryFile: string - [] HistoryDisplayCount: byte + [] HistoryDisplayCount: int [] AudioFormats: string list [] AudioQuality: byte [] SplitChapters: bool @@ -53,7 +53,7 @@ module Settings = WorkingDirectory = String.Empty MoveToDirectory = String.Empty HistoryFile = String.Empty - HistoryDisplayCount = 25uy // byte + HistoryDisplayCount = 25 SplitChapters = true SleepSecondsBetweenDownloads = 10us SleepSecondsBetweenURLs = 15us From 27e9341fbe87b00226c01d19ed838c3de2d80a57 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:32:13 +0900 Subject: [PATCH 209/247] Use choose over map and filter --- src/CCVTAC.FSharp/History.fs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index b0318c2f..679941ab 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -33,9 +33,10 @@ type History(filePath: string, displayCount: int) = let historyData = lines - |> Seq.map _.Split(separator) - |> Seq.filter (fun parts -> parts.Length = 2) - |> Seq.map (fun parts -> DateTime.Parse(parts[0]), parts[1]) + |> Seq.choose (fun line -> + match line.Split separator with + | [| date; url |] -> Some (DateTime.Parse date, url) + | _ -> None) |> Seq.groupBy fst |> Seq.map (fun (dt, pairs) -> dt, pairs |> Seq.map snd |> Seq.toList) From da6a4a3e67076a9921ed8610415de38e012c451f Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:03:41 +0900 Subject: [PATCH 210/247] Parse history dates --- src/CCVTAC.FSharp/History.fs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 679941ab..34d906c9 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -35,7 +35,10 @@ type History(filePath: string, displayCount: int) = lines |> Seq.choose (fun line -> match line.Split separator with - | [| date; url |] -> Some (DateTime.Parse date, url) + | [| dateText; url |] -> + match DateTime.TryParse dateText with + | true, date -> Some (date, url) + | _ -> None | _ -> None) |> Seq.groupBy fst |> Seq.map (fun (dt, pairs) -> dt, pairs |> Seq.map snd |> Seq.toList) From bb0fd88db4ce66e0cf1f1b8c413e8fb43dc36af3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 16 Dec 2025 21:37:11 +0900 Subject: [PATCH 211/247] Add new FileIo module; refactor history logging to use it --- src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj | 1 + src/CCVTAC.FSharp/Downloading/Downloader.fs | 2 +- src/CCVTAC.FSharp/History.fs | 9 ++++++--- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 5 ----- src/CCVTAC.FSharp/IoUtilities/Files.fs | 15 +++++++++++++++ src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- .../PostProcessing/Tagging/TaggingSet.fs | 2 +- 8 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 src/CCVTAC.FSharp/IoUtilities/Files.fs diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj index b9ce6ac6..8bd6e3ac 100644 --- a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj +++ b/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj @@ -11,6 +11,7 @@ + diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index a0c8e589..9694dcfd 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -78,7 +78,7 @@ module Downloader = let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory let downloadResult = runTool downloadSettings [1] printer - let filesDownloaded = audioFileCount userSettings.WorkingDirectory Files.audioFileExtensions > 0 + let filesDownloaded = audioFileCount userSettings.WorkingDirectory FileIo.audioFileExtensions > 0 match downloadResult, filesDownloaded with | Ok result, true -> diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 34d906c9..5f0d19e3 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -1,5 +1,6 @@ namespace CCVTAC.Console +open CCVTAC.Console.IoUtilities.FileIo open System open System.IO open System.Text.Json @@ -16,9 +17,11 @@ type History(filePath: string, displayCount: int) = member this.Append(url: string, entryTime: DateTime, printer: Printer) : unit = try let serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") - // TODO: IO should be placed elsewhere. - File.AppendAllText(this.FilePath, serializedEntryTime + string separator + url + String.newLine) - printer.Debug $"Added \"%s{url}\" to the history log." + let text = serializedEntryTime + string separator + url + String.newLine + + match appendToFile this.FilePath text with + | Ok _ -> printer.Debug $"Added \"%s{url}\" to the history log." + | Error err -> printer.Error $"Failed to write \"%s{url}\" to the history log at \"{this.FilePath}\": {err}" with exn -> printer.Error $"Could not append URL(s) to history log: {exn.Message}" diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index e17a127d..86c8dd9b 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -3,11 +3,6 @@ namespace CCVTAC.Console.IoUtilities open System.IO open CCVTAC.Console -module Files = - - let audioFileExtensions = - [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] - module Directories = type private ErrorList = string ResizeArray diff --git a/src/CCVTAC.FSharp/IoUtilities/Files.fs b/src/CCVTAC.FSharp/IoUtilities/Files.fs new file mode 100644 index 00000000..09ceaab8 --- /dev/null +++ b/src/CCVTAC.FSharp/IoUtilities/Files.fs @@ -0,0 +1,15 @@ +namespace CCVTAC.Console.IoUtilities + +open System.IO +open CCVTAC.Console + +module FileIo = + + let audioFileExtensions = + [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] + + let readAllText (filePath: string) : Result = + ofTry (fun _ -> File.ReadAllText filePath) + + let appendToFile (filePath: string) (text: string) : Result = + ofTry (fun _ -> File.AppendAllText(filePath, text)) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index ec5afcc1..e8b61635 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -135,7 +135,7 @@ module Mover = let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension FileIo.audioFileExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 8d254bfa..94fc232d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -68,7 +68,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension FileIo.audioFileExtensions) |> List.ofSeq if List.isEmpty audioFiles then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index f0b96b65..c8355fc7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -33,7 +33,7 @@ module TaggingSets = let fileHasSupportedExtension (file: string) = match Path.GetExtension file with | Null -> false - | NonNull (ext: string) -> Seq.caseInsensitiveContains ext Files.audioFileExtensions + | NonNull (ext: string) -> Seq.caseInsensitiveContains ext FileIo.audioFileExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match From 6a969343973183bb7039dd8d612c0b97146cf7c5 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 16 Dec 2025 21:45:11 +0900 Subject: [PATCH 212/247] Rename FileIO module to Files --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 2 +- src/CCVTAC.FSharp/History.fs | 2 +- src/CCVTAC.FSharp/IoUtilities/Files.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 9694dcfd..a0c8e589 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -78,7 +78,7 @@ module Downloader = let downloadSettings = ToolSettings.create commandWithArgs userSettings.WorkingDirectory let downloadResult = runTool downloadSettings [1] printer - let filesDownloaded = audioFileCount userSettings.WorkingDirectory FileIo.audioFileExtensions > 0 + let filesDownloaded = audioFileCount userSettings.WorkingDirectory Files.audioFileExtensions > 0 match downloadResult, filesDownloaded with | Ok result, true -> diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 5f0d19e3..73535ab0 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -1,6 +1,6 @@ namespace CCVTAC.Console -open CCVTAC.Console.IoUtilities.FileIo +open CCVTAC.Console.IoUtilities.Files open System open System.IO open System.Text.Json diff --git a/src/CCVTAC.FSharp/IoUtilities/Files.fs b/src/CCVTAC.FSharp/IoUtilities/Files.fs index 09ceaab8..098acd1e 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Files.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Files.fs @@ -3,7 +3,7 @@ namespace CCVTAC.Console.IoUtilities open System.IO open CCVTAC.Console -module FileIo = +module Files = let audioFileExtensions = [ ".aac"; ".alac"; ".flac"; ".m4a"; ".mp3"; ".ogg"; ".vorbis"; ".opus"; ".wav" ] diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index e8b61635..ec5afcc1 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -135,7 +135,7 @@ module Mover = let audioFileNames = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension FileIo.audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) |> List.ofSeq if audioFileNames.IsEmpty then diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 94fc232d..8d254bfa 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -68,7 +68,7 @@ module Renamer = let audioFiles = workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension FileIo.audioFileExtensions) + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) |> List.ofSeq if List.isEmpty audioFiles then diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index c8355fc7..f0b96b65 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -33,7 +33,7 @@ module TaggingSets = let fileHasSupportedExtension (file: string) = match Path.GetExtension file with | Null -> false - | NonNull (ext: string) -> Seq.caseInsensitiveContains ext FileIo.audioFileExtensions + | NonNull (ext: string) -> Seq.caseInsensitiveContains ext Files.audioFileExtensions filePaths |> Seq.map fileNamesWithVideoIdsRegex.Match From 000e0c16a9592e0e7962bfa9fb5b2cbb7cc3dd82 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:10:10 +0900 Subject: [PATCH 213/247] Rename binding --- src/CCVTAC.FSharp/History.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.FSharp/History.fs index 73535ab0..f8f8ba43 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.FSharp/History.fs @@ -16,8 +16,8 @@ type History(filePath: string, displayCount: int) = /// Write a URL and its related data to the history file. member this.Append(url: string, entryTime: DateTime, printer: Printer) : unit = try - let serializedEntryTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") - let text = serializedEntryTime + string separator + url + String.newLine + let serializedTime = JsonSerializer.Serialize(entryTime).Replace("\"", "") + let text = serializedTime + string separator + url + String.newLine match appendToFile this.FilePath text with | Ok _ -> printer.Debug $"Added \"%s{url}\" to the history log." From ed25d9290d40204dbcadbb63d3a1f35db355f3fd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:10:17 +0900 Subject: [PATCH 214/247] Add func comment --- src/CCVTAC.FSharp/Shared.fs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index 57e4ac31..7a94eb0c 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -6,6 +6,8 @@ open Spectre.Console [] module Shared = + /// Safely runs a function that might raise an exception. + /// If an exception is thrown, only returns its message. let ofTry (f: unit -> 'a) : Result<'a, string> = try Ok (f()) with exn -> Error exn.Message From ec560e6e4c1e0e136eda7caf9227e649d1d8c053 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:33:43 +0900 Subject: [PATCH 215/247] Return result from directory-reading func --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 104 ++++++++++--------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 86c8dd9b..9857d286 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -10,16 +10,17 @@ module Directories = [] let private allFilesSearchPattern = "*" - /// Counts the number of audio files in a directory + /// Counts the number of audio files in a directory. let audioFileCount (directory: string) (includedExtensions: string list) = DirectoryInfo(directory).EnumerateFiles() |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension includedExtensions) |> Seq.length - /// Returns the filenames in a given directory, optionally ignoring specific filenames + /// Returns the filenames in a given directory, optionally ignoring specific filenames. let private getDirectoryFileNames (directoryName: string) - (customIgnoreFiles: string seq option) = + (customIgnoreFiles: string seq option) + : Result = let ignoreFiles = customIgnoreFiles @@ -27,38 +28,40 @@ module Directories = |> Seq.distinct |> Seq.toArray - Directory.GetFiles(directoryName, allFilesSearchPattern, EnumerationOptions()) - |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith)) + ofTry (fun _ -> + Directory.GetFiles(directoryName, allFilesSearchPattern, EnumerationOptions()) + |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith))) /// Deletes all files in the working directory let deleteAllFiles showMaxErrors workingDirectory : Result = - let fileNames = getDirectoryFileNames workingDirectory None - - let successCount, errors = - Array.fold - (fun (s: uint, errs: ErrorList) fileName -> - try File.Delete fileName - (s + 1u, errs) - with exn -> - errs.Add exn.Message; s, errs) - (0u, ErrorList()) - fileNames - - if Seq.isEmpty errors then - Ok successCount - else - SB($"{String.fileLabel None successCount} deleted successfully, but some files could not be deleted:{String.newLine}") - .AppendLine - (fileNames - |> Array.truncate showMaxErrors - |> Array.map (sprintf "• %s") - |> String.concat String.newLine) - |> fun sb -> - if errors.Count > showMaxErrors - then sb.AppendLine $"... plus {errors.Count - showMaxErrors} more." - else sb - |> _.ToString() - |> Error + match getDirectoryFileNames workingDirectory None with + | Error errMsg -> Error errMsg + | Ok fileNames -> + let successCount, errors = + Array.fold + (fun (s: uint, errs: ErrorList) fileName -> + try File.Delete fileName + (s + 1u, errs) + with exn -> + errs.Add exn.Message; s, errs) + (0u, ErrorList()) + fileNames + + if Seq.isEmpty errors then + Ok successCount + else + SB($"{String.fileLabel None successCount} deleted successfully, but some files could not be deleted:{String.newLine}") + .AppendLine + (fileNames + |> Array.truncate showMaxErrors + |> Array.map (sprintf "• %s") + |> String.concat String.newLine) + |> fun sb -> + if errors.Count > showMaxErrors + then sb.AppendLine $"... plus {errors.Count - showMaxErrors} more." + else sb + |> _.ToString() + |> Error /// Ask the user to confirm the deletion of files in the specified directory. let askToDeleteAllFiles dirName (printer: Printer) = @@ -68,24 +71,25 @@ module Directories = /// Warn the user if there are any files in the specified directory. let warnIfAnyFiles showMax dirName = - let fileNames = getDirectoryFileNames dirName None - - if Array.isEmpty fileNames then - Ok () - else - SB($"Unexpectedly found {String.fileLabel None fileNames.Length} in working directory \"{dirName}\":{String.newLine}") - .AppendLine - (fileNames - |> Array.truncate showMax - |> Array.map (sprintf "• %s") - |> String.concat String.newLine) - |> fun sb -> - if fileNames.Length > showMax - then sb.AppendLine $"... plus {fileNames.Length - showMax} more." - else sb - |> _.AppendLine("This sometimes occurs due to the same video appearing twice in playlists.") - |> _.ToString() - |> Error + match getDirectoryFileNames dirName None with + | Error errMsg -> Error errMsg + | Ok fileNames -> + if Array.isEmpty fileNames then + Ok () + else + SB($"Unexpectedly found {String.fileLabel None fileNames.Length} in working directory \"{dirName}\":{String.newLine}") + .AppendLine + (fileNames + |> Array.truncate showMax + |> Array.map (sprintf "• %s") + |> String.concat String.newLine) + |> fun sb -> + if fileNames.Length > showMax + then sb.AppendLine $"... plus {fileNames.Length - showMax} more." + else sb + |> _.AppendLine("This sometimes occurs due to the same video appearing twice in playlists.") + |> _.ToString() + |> Error /// Ensures the specified directory exists, including creation of it if necessary. let ensureDirectoryExists dirName : Result = From 75bcefc68d4428a3fbc298e2f107a73a5408b303 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:14:30 +0900 Subject: [PATCH 216/247] =?UTF-8?q?Remove=20unnecessary=20Topic=20string?= =?UTF-8?q?=E2=80=93related=20conditional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index ec5afcc1..b804f555 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -101,9 +101,7 @@ module Mover = let safeName = workingName |> String.replaceInvalidPathChars None None |> _.Trim() let topicSuffix = " - Topic" - if safeName.EndsWith topicSuffix - then safeName.Replace(topicSuffix, String.Empty) - else safeName + safeName.Replace(topicSuffix, String.Empty) let run (taggingSets: TaggingSet seq) From 874a3a6297801c2b31f402081d8e0b4258bc9978 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:32:41 +0900 Subject: [PATCH 217/247] Use 'function' for clarity --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index b804f555..7600d458 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -151,7 +151,8 @@ module Mover = if moveFailureCount > 0 then printer.Warning $"However, %s{fileCountMsg moveFailureCount} could not be moved." - match moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir - audioFileNames.Length overwrite with + moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir + audioFileNames.Length overwrite + |> function | Ok msg -> printer.Info msg | Error err -> printer.Error $"Error moving the image file: %s{err}." From 891446b3866bc747624d63166ccfc7446d01bd3a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:30:53 +0900 Subject: [PATCH 218/247] Create local func for file deletion --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 29 ++++++++++---------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 9857d286..b67fa6b0 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -1,7 +1,7 @@ namespace CCVTAC.Console.IoUtilities -open System.IO open CCVTAC.Console +open System.IO module Directories = @@ -32,24 +32,25 @@ module Directories = Directory.GetFiles(directoryName, allFilesSearchPattern, EnumerationOptions()) |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith))) - /// Deletes all files in the working directory + /// Empties a specified directory and reports the count of deleted files. let deleteAllFiles showMaxErrors workingDirectory : Result = + let delete fileNames = + Array.fold + (fun (successCount: uint, errors: ErrorList) fileName -> + try File.Delete fileName + (successCount + 1u, errors) + with exn -> + errors.Add exn.Message; successCount, errors) + (0u, ErrorList()) + fileNames + match getDirectoryFileNames workingDirectory None with | Error errMsg -> Error errMsg | Ok fileNames -> - let successCount, errors = - Array.fold - (fun (s: uint, errs: ErrorList) fileName -> - try File.Delete fileName - (s + 1u, errs) - with exn -> - errs.Add exn.Message; s, errs) - (0u, ErrorList()) - fileNames - - if Seq.isEmpty errors then + match delete fileNames with + | successCount, errors when errors.Count = 0 -> Ok successCount - else + | successCount, errors -> SB($"{String.fileLabel None successCount} deleted successfully, but some files could not be deleted:{String.newLine}") .AppendLine (fileNames From 46e4b1542652cefc8f3d993cfd64aa6040f6dbbd Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:24:29 +0900 Subject: [PATCH 219/247] Clean up deletion func further --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 27 +++++++++----------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index b67fa6b0..18d8efa1 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -5,8 +5,6 @@ open System.IO module Directories = - type private ErrorList = string ResizeArray - [] let private allFilesSearchPattern = "*" @@ -36,30 +34,29 @@ module Directories = let deleteAllFiles showMaxErrors workingDirectory : Result = let delete fileNames = Array.fold - (fun (successCount: uint, errors: ErrorList) fileName -> - try File.Delete fileName + (fun (successCount: uint, errors: string list) fileName -> + try + File.Delete fileName (successCount + 1u, errors) - with exn -> - errors.Add exn.Message; successCount, errors) - (0u, ErrorList()) + with exn -> (successCount, errors @ [exn.Message])) + (0u, []) fileNames match getDirectoryFileNames workingDirectory None with | Error errMsg -> Error errMsg | Ok fileNames -> match delete fileNames with - | successCount, errors when errors.Count = 0 -> + | successCount, [] -> Ok successCount | successCount, errors -> SB($"{String.fileLabel None successCount} deleted successfully, but some files could not be deleted:{String.newLine}") - .AppendLine - (fileNames - |> Array.truncate showMaxErrors - |> Array.map (sprintf "• %s") - |> String.concat String.newLine) + .AppendLine(fileNames + |> Array.truncate showMaxErrors + |> Array.map (sprintf "• %s") + |> String.concat String.newLine) |> fun sb -> - if errors.Count > showMaxErrors - then sb.AppendLine $"... plus {errors.Count - showMaxErrors} more." + if errors.Length > showMaxErrors + then sb.AppendLine $"... plus {errors.Length - showMaxErrors} more." else sb |> _.ToString() |> Error From 8452faf9cd8ce6c64747fc83a4a485d6b2d3f0e7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:47:35 +0900 Subject: [PATCH 220/247] Use List.fold for file-moving messages --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 36 ++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 7600d458..cd5e976b 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -36,22 +36,17 @@ module Mover = (audioFiles: FileInfo list) (moveToDir: string) (overwrite: bool) - (printer: Printer) - : int * int = // TODO: Need a custom type for clarity. - - let mutable successCount = 0 - let mutable failureCount = 0 + : {| Successes: string list; Failures: string list |} = - for file in audioFiles do - try - File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) - successCount <- successCount + 1 - printer.Debug $"• Moved \"%s{file.Name}\"" - with exn -> - failureCount <- failureCount + 1 - printer.Error $"• Error moving file \"%s{file.Name}\": %s{exn.Message}" + ({| Successes = []; Failures = [] |}, audioFiles) + ||> List.fold + (fun results (file: FileInfo) -> + try + File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) + {| results with Successes = results.Successes @ [$"• Moved \"%s{file.Name}\""] |} + with exn -> + {| results with Failures = results.Failures @ [$"• Error moving \"%s{file.Name}\": %s{exn.Message}"] |}) - (successCount, failureCount) let private moveImageFile (maybeCollectionName: string) @@ -143,16 +138,17 @@ module Mover = printer.Debug $"Moving %s{fileCountMsg audioFileNames.Length} to \"%s{fullMoveToDir}\"..." - let moveSuccessCount, moveFailureCount = - moveAudioFiles audioFileNames fullMoveToDir overwrite printer + let results = moveAudioFiles audioFileNames fullMoveToDir overwrite - printer.Info $"Moved %s{fileCountMsg moveSuccessCount} in %s{watch.ElapsedFriendly}." + printer.Info $"Moved %s{fileCountMsg results.Successes.Length} in %s{watch.ElapsedFriendly}." + results.Successes |> List.iter printer.Debug - if moveFailureCount > 0 then - printer.Warning $"However, %s{fileCountMsg moveFailureCount} could not be moved." + if List.isNotEmpty results.Failures then + printer.Warning $"However, %s{fileCountMsg results.Failures.Length} could not be moved:" + results.Failures |> List.iter printer.Error moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir - audioFileNames.Length overwrite + audioFileNames.Length overwrite |> function | Ok msg -> printer.Info msg | Error err -> printer.Error $"Error moving the image file: %s{err}." From bca5127d95dd50a9c5a4f14e807e69234ee4974e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 17 Dec 2025 18:03:13 +0900 Subject: [PATCH 221/247] Better mutability for file-moving --- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index cd5e976b..1185b3fc 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -38,15 +38,17 @@ module Mover = (overwrite: bool) : {| Successes: string list; Failures: string list |} = - ({| Successes = []; Failures = [] |}, audioFiles) - ||> List.fold - (fun results (file: FileInfo) -> - try - File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) - {| results with Successes = results.Successes @ [$"• Moved \"%s{file.Name}\""] |} - with exn -> - {| results with Failures = results.Failures @ [$"• Error moving \"%s{file.Name}\": %s{exn.Message}"] |}) + let successes, failures = ResizeArray(), ResizeArray() + for file in audioFiles do + try + File.Move(file.FullName, Path.Combine(moveToDir, file.Name), overwrite) + successes.Add $"• Moved \"%s{file.Name}\"" + with exn -> + failures.Add $"• Error moving \"%s{file.Name}\": %s{exn.Message}" + + {| Successes = successes |> Seq.toList |> List.rev + Failures = failures |> Seq.toList |> List.rev |} let private moveImageFile (maybeCollectionName: string) From fbbbfd49aeb440b67f9afc8e9a9c488c612eb59a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:23:15 +0900 Subject: [PATCH 222/247] Create two funcs for file labels --- src/CCVTAC.FSharp/Extensions.fs | 10 +++++++++- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Deleter.fs | 4 ++-- src/CCVTAC.FSharp/PostProcessing/Mover.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Renamer.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs | 2 +- src/CCVTAC.FSharp/Shared.fs | 1 + 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 424b21bd..779a6f64 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -45,11 +45,19 @@ module String = let inline pluralize ifOne ifNotOne count = if Numerics.isOne count then ifOne else ifNotOne - let inline fileLabel descriptor count = + let inline private fileLabeller descriptor count = match descriptor with | None -> $"""%d{count} %s{pluralize "file" "files" count}""" | Some d -> $"""%d{count} %s{d} {pluralize "file" "files" count}""" + /// Returns a file-count string, such as "0 files" or 1 file" or "140 files". + let fileLabel count = + fileLabeller None count + + /// Returns a file-count string with a descriptor, such as "0 audio files" or "140 deleted files". + let fileLabelWithDescriptor descriptor count = + fileLabeller (Some descriptor) count + /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. /// Throws if the replacement character is an invalid path character. diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 18d8efa1..37e99639 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -75,7 +75,7 @@ module Directories = if Array.isEmpty fileNames then Ok () else - SB($"Unexpectedly found {String.fileLabel None fileNames.Length} in working directory \"{dirName}\":{String.newLine}") + SB($"Unexpectedly found {String.fileLabel fileNames.Length} in working directory \"{dirName}\":{String.newLine}") .AppendLine (fileNames |> Array.truncate showMax diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs index 7a5acea4..3a3f9743 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Deleter.fs @@ -39,7 +39,7 @@ module Deleter = let collectionFileNames = match getCollectionFiles collectionMetadata workingDirectory with | Ok files -> - printer.Debug $"""Found {String.fileLabel (Some "collection") files.Length}.""" + printer.Debug $"""Found {String.fileLabelWithDescriptor "collection" files.Length}.""" files | Error err -> printer.Warning err @@ -50,6 +50,6 @@ module Deleter = if Array.isEmpty allFileNames then printer.Warning "No files to delete were found." else - printer.Debug $"""Deleting {String.fileLabel (Some "temporary") allFileNames.Length}...""" + printer.Debug $"""Deleting {String.fileLabelWithDescriptor "temporary" allFileNames.Length}...""" deleteAll allFileNames printer printer.Info "Deleted temporary files." diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.FSharp/PostProcessing/Mover.fs index 1185b3fc..cb47e82c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Mover.fs @@ -136,7 +136,7 @@ module Mover = if audioFileNames.IsEmpty then printer.Error "No audio filenames to move were found." else - let fileCountMsg = String.fileLabel (Some "audio") + let fileCountMsg = String.fileLabelWithDescriptor "audio" printer.Debug $"Moving %s{fileCountMsg audioFileNames.Length} to \"%s{fullMoveToDir}\"..." diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs index 8d254bfa..f67db0a5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Renamer.fs @@ -74,7 +74,7 @@ module Renamer = if List.isEmpty audioFiles then printer.Warning "No audio files to rename were found." else - printer.Debug $"""Renaming %s{String.fileLabel (Some "audio") audioFiles.Length}...""" + printer.Debug $"""Renaming %s{String.fileLabelWithDescriptor "audio" audioFiles.Length}...""" for audioFile in audioFiles do let newFileName = diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs index a4909c5d..4f0ab531 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs @@ -181,7 +181,7 @@ module Tagger = (printer: Printer) : unit = - printer.Debug $"""Found %s{String.fileLabel (Some "audio") taggingSet.AudioFilePaths.Length} with resource ID %s{taggingSet.ResourceId}.""" + printer.Debug $"""Found %s{String.fileLabelWithDescriptor "audio" taggingSet.AudioFilePaths.Length} with resource ID %s{taggingSet.ResourceId}.""" match parseVideoJson taggingSet with | Ok videoData -> diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index 7a94eb0c..61135172 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -27,3 +27,4 @@ module Shared = |> loop seconds) doneMsgFn seconds + From 2ed39b3d9924100deacb269619a7d66ed6a87a5f Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:25:32 +0900 Subject: [PATCH 223/247] Improve file-deletion code --- src/CCVTAC.FSharp/IoUtilities/Directories.fs | 50 +++++++++---------- src/CCVTAC.FSharp/Orchestrator.fs | 15 +++--- .../PostProcessing/PostProcessing.fs | 4 +- src/CCVTAC.FSharp/Program.fs | 6 +-- src/CCVTAC.FSharp/Shared.fs | 2 + 5 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.FSharp/IoUtilities/Directories.fs index 37e99639..1c7e3aa4 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.FSharp/IoUtilities/Directories.fs @@ -31,42 +31,40 @@ module Directories = |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith))) /// Empties a specified directory and reports the count of deleted files. - let deleteAllFiles showMaxErrors workingDirectory : Result = + let deleteAllFiles workingDirectory + : Result = + let delete fileNames = - Array.fold - (fun (successCount: uint, errors: string list) fileName -> - try - File.Delete fileName - (successCount + 1u, errors) - with exn -> (successCount, errors @ [exn.Message])) - (0u, []) - fileNames + let successes, failures = ResizeArray(), ResizeArray() + + for fileName in fileNames do + try + File.Delete fileName + successes.Add $"• Deleted \"%s{fileName}\"" + with exn -> + failures.Add $"• Error deleting \"%s{fileName}\": %s{exn.Message}" + + { Successes = successes |> Seq.toList |> List.rev + Failures = failures |> Seq.toList |> List.rev } match getDirectoryFileNames workingDirectory None with | Error errMsg -> Error errMsg - | Ok fileNames -> - match delete fileNames with - | successCount, [] -> - Ok successCount - | successCount, errors -> - SB($"{String.fileLabel None successCount} deleted successfully, but some files could not be deleted:{String.newLine}") - .AppendLine(fileNames - |> Array.truncate showMaxErrors - |> Array.map (sprintf "• %s") - |> String.concat String.newLine) - |> fun sb -> - if errors.Length > showMaxErrors - then sb.AppendLine $"... plus {errors.Length - showMaxErrors} more." - else sb - |> _.ToString() - |> Error + | Ok fileNames -> Ok (delete fileNames) /// Ask the user to confirm the deletion of files in the specified directory. let askToDeleteAllFiles dirName (printer: Printer) = if printer.AskToBool("Delete all temporary files?", "Yes", "No") - then deleteAllFiles 10 dirName + then deleteAllFiles dirName else Error "Will not delete the files." + let printDeletionResults (printer: Printer) (results: ResultMessageCollection) : unit = + printer.Info $"Deleted %s{String.fileLabel results.Successes.Length}." + results.Successes |> List.iter printer.Debug + + if List.isNotEmpty results.Failures then + printer.Warning $"However, %s{String.fileLabel results.Failures.Length} could not be deleted:" + results.Failures |> List.iter printer.Error + /// Warn the user if there are any files in the specified directory. let warnIfAnyFiles showMax dirName = match getDirectoryFileNames dirName None with diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 86d09c4d..2ae643f8 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -266,15 +266,14 @@ module Orchestrator = let start (settings: UserSettings) (printer: Printer) : unit = // The working directory should start empty. Give the user a chance to empty it. match Directories.warnIfAnyFiles 10 settings.WorkingDirectory with - | Error err -> - printer.Error err - match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with - | Ok deletedCount -> - printer.Info $"%s{String.fileLabel None deletedCount} deleted." - | Error err' -> - printer.Error err' - printer.Info "Aborting..." | Ok () -> () + | Error filesFoundErr -> + printer.Error filesFoundErr + Directories.askToDeleteAllFiles settings.WorkingDirectory printer |> function + | Ok results -> Directories.printDeletionResults printer results + | Error deletionError -> + printer.Error deletionError + printer.Info "Aborting..." let results = ResultTracker printer let history = History(settings.HistoryFile, settings.HistoryDisplayCount) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index ca6fdcca..3e30cd38 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -84,8 +84,8 @@ module PostProcessor = | Error err -> printer.Error err printer.Info "Will delete the remaining files..." - match Directories.deleteAllFiles 20 workingDirectory with - | Ok deletedCount -> printer.Info $"%s{String.fileLabel None deletedCount} deleted." + match Directories.deleteAllFiles workingDirectory with + | Ok results -> Directories.printDeletionResults printer results | Error e -> printer.Error e | Error e -> printer.Error($"Tagging error(s) preventing further post-processing: {e}") diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.FSharp/Program.fs index f880e8dd..86fcd08c 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.FSharp/Program.fs @@ -61,10 +61,8 @@ module Program = | Error warnResult -> printer.Error warnResult match Directories.askToDeleteAllFiles settings.WorkingDirectory printer with - | Ok deletedCount -> printer.Info $"%s{String.fileLabel None deletedCount} deleted." - | Error delErr -> printer.Error delErr - ) - + | Error err -> printer.Error err + | Ok results -> Directories.printDeletionResults printer results) try Orchestrator.start settings printer int ExitCodes.Success diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.FSharp/Shared.fs index 61135172..b666fe82 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.FSharp/Shared.fs @@ -6,6 +6,8 @@ open Spectre.Console [] module Shared = + type ResultMessageCollection = { Successes: string list; Failures: string list } + /// Safely runs a function that might raise an exception. /// If an exception is thrown, only returns its message. let ofTry (f: unit -> 'a) : Result<'a, string> = From da192fca6071a12e794b7dfcc1a3805596badfe9 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:57:26 +0900 Subject: [PATCH 224/247] Add fileLabel funcs tests --- src/CCVTAC.FSharp.Tests/ExtensionsTests.fs | 16 ++++++++++++++++ src/CCVTAC.FSharp/Extensions.fs | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs index 8b701259..6eb3d670 100644 --- a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs +++ b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs @@ -58,6 +58,22 @@ module NumericsTests = Assert.False <| Numerics.isOne 0L Assert.False <| Numerics.isOne 0m +module StringTests = + + [] + let ``fileLabel formats correctly`` () = + Assert.True <| (String.fileLabel 0 = "0 files") + Assert.True <| (String.fileLabel 1 = "1 file") + Assert.True <| (String.fileLabel 2 = "2 files") + Assert.True <| (String.fileLabel 1_000_000 = "1000000 files") + + [] + let ``fileLabelWithDescriptor formats correctly`` () = + Assert.True <| (String.fileLabelWithDescriptor "audio" 0 = "0 audio files") + Assert.True <| (String.fileLabelWithDescriptor " temporary " 1 = "1 temporary file") + Assert.True <| (String.fileLabelWithDescriptor "deleted" 2 = "2 deleted files") + Assert.True <| (String.fileLabelWithDescriptor "images" 1_000_000 = "1000000 image files") + module SeqTests = [] diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 779a6f64..47bcaa62 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -55,8 +55,8 @@ module String = fileLabeller None count /// Returns a file-count string with a descriptor, such as "0 audio files" or "140 deleted files". - let fileLabelWithDescriptor descriptor count = - fileLabeller (Some descriptor) count + let fileLabelWithDescriptor (descriptor: string) count = + fileLabeller (Some (descriptor.Trim())) count /// Returns a new string in which all invalid path characters for the current OS /// have been replaced by the specified replacement character. From 810a264029051c88e5e10b6cc7cf1230bae8cfe6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:13:06 +0900 Subject: [PATCH 225/247] Add SRTP-powered inline func for number formatting; modify tests --- src/CCVTAC.FSharp.Tests/ExtensionsTests.fs | 98 +++++++++++----------- src/CCVTAC.FSharp/Extensions.fs | 15 +++- 2 files changed, 62 insertions(+), 51 deletions(-) diff --git a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs index 6eb3d670..8b2ceb31 100644 --- a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs +++ b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs @@ -5,74 +5,76 @@ open Xunit open System module NumericsTests = + open CCVTAC.Console.Numerics [] let ``isZero returns true for any zero value`` () = - Assert.True <| Numerics.isZero 0 - Assert.True <| Numerics.isZero 0u - Assert.True <| Numerics.isZero 0us - Assert.True <| Numerics.isZero 0. - Assert.True <| Numerics.isZero 0L - Assert.True <| Numerics.isZero 0m - Assert.True <| Numerics.isZero -0 - Assert.True <| Numerics.isZero -0. - Assert.True <| Numerics.isZero -0L - Assert.True <| Numerics.isZero -0m + Assert.True <| isZero 0 + Assert.True <| isZero 0u + Assert.True <| isZero 0us + Assert.True <| isZero 0. + Assert.True <| isZero 0L + Assert.True <| isZero 0m + Assert.True <| isZero -0 + Assert.True <| isZero -0. + Assert.True <| isZero -0L + Assert.True <| isZero -0m [] let ``isZero returns false for any non-zero value`` () = - Assert.False <| Numerics.isZero 1 - Assert.False <| Numerics.isOne -1 - Assert.False <| Numerics.isOne Int64.MinValue - Assert.False <| Numerics.isOne Int64.MaxValue - Assert.False <| Numerics.isOne 2 - Assert.False <| Numerics.isZero 1u - Assert.False <| Numerics.isZero 1us - Assert.False <| Numerics.isZero -0.0000000000001 - Assert.False <| Numerics.isZero 0.0000000000001 - Assert.False <| Numerics.isZero 1. - Assert.False <| Numerics.isZero 1L - Assert.False <| Numerics.isZero 1m + Assert.False <| isZero 1 + Assert.False <| isOne -1 + Assert.False <| isOne Int64.MinValue + Assert.False <| isOne Int64.MaxValue + Assert.False <| isOne 2 + Assert.False <| isZero 1u + Assert.False <| isZero 1us + Assert.False <| isZero -0.0000000000001 + Assert.False <| isZero 0.0000000000001 + Assert.False <| isZero 1. + Assert.False <| isZero 1L + Assert.False <| isZero 1m [] let ``isOne returns true for any one value`` () = - Assert.True <| Numerics.isOne 1 - Assert.True <| Numerics.isOne 1u - Assert.True <| Numerics.isOne 1us - Assert.True <| Numerics.isOne 1. - Assert.True <| Numerics.isOne 1L - Assert.True <| Numerics.isOne 1m + Assert.True <| isOne 1 + Assert.True <| isOne 1u + Assert.True <| isOne 1us + Assert.True <| isOne 1. + Assert.True <| isOne 1L + Assert.True <| isOne 1m [] let ``isOne returns false for any non-one value`` () = - Assert.False <| Numerics.isOne 0 - Assert.False <| Numerics.isOne -1 - Assert.False <| Numerics.isOne Int64.MinValue - Assert.False <| Numerics.isOne Int64.MaxValue - Assert.False <| Numerics.isOne 2 - Assert.False <| Numerics.isOne 0u - Assert.False <| Numerics.isOne 16u - Assert.False <| Numerics.isOne 0us - Assert.False <| Numerics.isOne -0. - Assert.False <| Numerics.isOne 0.001 - Assert.False <| Numerics.isOne 0L - Assert.False <| Numerics.isOne 0m + Assert.False <| isOne 0 + Assert.False <| isOne -1 + Assert.False <| isOne Int64.MinValue + Assert.False <| isOne Int64.MaxValue + Assert.False <| isOne 2 + Assert.False <| isOne 0u + Assert.False <| isOne 16u + Assert.False <| isOne 0us + Assert.False <| isOne -0. + Assert.False <| isOne 0.001 + Assert.False <| isOne 0L + Assert.False <| isOne 0m module StringTests = + open CCVTAC.Console.String [] let ``fileLabel formats correctly`` () = - Assert.True <| (String.fileLabel 0 = "0 files") - Assert.True <| (String.fileLabel 1 = "1 file") - Assert.True <| (String.fileLabel 2 = "2 files") - Assert.True <| (String.fileLabel 1_000_000 = "1000000 files") + Assert.True <| (fileLabel 0 = "0 files") + Assert.True <| (fileLabel 1 = "1 file") + Assert.True <| (fileLabel 2 = "2 files") + Assert.True <| (fileLabel 1_000_000 = "1,000,000 files") [] let ``fileLabelWithDescriptor formats correctly`` () = - Assert.True <| (String.fileLabelWithDescriptor "audio" 0 = "0 audio files") - Assert.True <| (String.fileLabelWithDescriptor " temporary " 1 = "1 temporary file") - Assert.True <| (String.fileLabelWithDescriptor "deleted" 2 = "2 deleted files") - Assert.True <| (String.fileLabelWithDescriptor "images" 1_000_000 = "1000000 image files") + Assert.True <| (fileLabelWithDescriptor "audio" 0 = "0 audio files") + Assert.True <| (fileLabelWithDescriptor " temporary " 1 = "1 temporary file") + Assert.True <| (fileLabelWithDescriptor "deleted" 2 = "2 deleted files") + Assert.True <| (fileLabelWithDescriptor "image" 1_000_000 = "1,000,000 image files") module SeqTests = diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 47bcaa62..353069f2 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -1,6 +1,7 @@ namespace CCVTAC.Console open System +open System.Globalization open System.IO open System.Text @@ -14,6 +15,11 @@ module Numerics = let inline isOne (n: ^a) = n = LanguagePrimitives.GenericOne<'a> + /// Formats a number of any type to a comma-formatted string. + let inline formatNumber (i: ^T) : string + when ^T : (member ToString : string * IFormatProvider -> string) = + (^T : (member ToString : string * IFormatProvider -> string) (i, "#,##0", CultureInfo.InvariantCulture)) + module String = let newLine = Environment.NewLine @@ -45,10 +51,10 @@ module String = let inline pluralize ifOne ifNotOne count = if Numerics.isOne count then ifOne else ifNotOne - let inline private fileLabeller descriptor count = + let inline private fileLabeller descriptor (count: int) = match descriptor with - | None -> $"""%d{count} %s{pluralize "file" "files" count}""" - | Some d -> $"""%d{count} %s{d} {pluralize "file" "files" count}""" + | None -> $"""%s{Numerics.formatNumber count} %s{pluralize "file" "files" count}""" + | Some d -> $"""%s{Numerics.formatNumber count} %s{d} {pluralize "file" "files" count}""" /// Returns a file-count string, such as "0 files" or 1 file" or "140 files". let fileLabel count = @@ -94,6 +100,7 @@ module String = let trimTerminalLineBreak (text: string) = text.TrimEnd(newLine.ToCharArray()) +[] module Seq = let isNotEmpty seq = not (Seq.isEmpty seq) @@ -105,6 +112,7 @@ module Seq = let caseInsensitiveContains text (xs: string seq) : bool = xs |> Seq.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) +[] module List = let isNotEmpty lst = not (List.isEmpty lst) @@ -116,6 +124,7 @@ module List = let caseInsensitiveContains text (lst: string list) : bool = lst |> List.exists (fun x -> String.Equals(x, text, StringComparison.OrdinalIgnoreCase)) +[] module Array = let isNotEmpty arr = not <| Array.isEmpty arr From 4c2dba3e111eff5f5431b2487e79665c19fd951a Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:22:59 +0900 Subject: [PATCH 226/247] Add FormatNumberTests module and tests --- src/CCVTAC.FSharp.Tests/ExtensionsTests.fs | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs index 8b2ceb31..c126feba 100644 --- a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs +++ b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs @@ -59,6 +59,54 @@ module NumericsTests = Assert.False <| isOne 0L Assert.False <| isOne 0m + module FormatNumberTests = + + // A tiny custom type that implements the required ToString signature. + type MyCustomNum(i: int) = + member _.ToString(fmt: string, provider: IFormatProvider) = + i.ToString(fmt, provider) + + [] + let ``format int`` () = + let actual = formatNumber 123456 + Assert.Equal("123,456", actual) + + [] + let ``format negative int`` () = + let actual = formatNumber -1234 + Assert.Equal("-1,234", actual) + + [] + let ``format zero`` () = + let actual = formatNumber 0 + Assert.Equal("0", actual) + + [] + let ``format int64`` () = + let actual = formatNumber 1234567890L + Assert.Equal("1,234,567,890", actual) + + [] + let ``format decimal rounds to integer display`` () = + let actual = formatNumber 123456.78M + Assert.Equal("123,457", actual) + + [] + let ``format float rounds to integer display`` () = + let actual = formatNumber 123456.78 + Assert.Equal("123,457", actual) + + [] + let ``format negative float rounds to integer display`` () = + let actual = formatNumber -1234.56 + Assert.Equal("-1,235", actual) + + [] + let ``format custom numeric type`` () = + let myNum = MyCustomNum 1234 + let actual = formatNumber myNum + Assert.Equal("1,234", actual) + module StringTests = open CCVTAC.Console.String From e3f19b2152d0917dc0b8e46c3ff28437c19e9913 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:40:36 +0900 Subject: [PATCH 227/247] Add and use String.pluralizeWithCount --- src/CCVTAC.FSharp/Extensions.fs | 5 +++++ src/CCVTAC.FSharp/Orchestrator.fs | 7 ++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 353069f2..f9dc50db 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -48,9 +48,14 @@ module String = let endsWithIgnoringCase endText (text: string) = text.EndsWith(endText, StringComparison.InvariantCultureIgnoreCase) + /// Pluralize text using a specified count. let inline pluralize ifOne ifNotOne count = if Numerics.isOne count then ifOne else ifNotOne + /// Pluralize text including its count, such as "1 file", "30 URLs". + let pluralizeWithCount ifOne ifNotOne count = + sprintf "%d %s" count (pluralize ifOne ifNotOne count) + let inline private fileLabeller descriptor (count: int) = match descriptor with | None -> $"""%s{Numerics.formatNumber count} %s{pluralize "file" "files" count}""" diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index 2ae643f8..ab9fcf66 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -28,11 +28,8 @@ module Orchestrator = : unit = if List.hasMultiple categorizedInputs then - let urlCount = counts[InputCategory.Url] - let cmdCount = counts[InputCategory.Command] - - let urlSummary = match urlCount with 1 -> "1 URL" | n -> $"%d{n} URLs" - let cmdSummary = match cmdCount with 1 -> "1 command" | n -> $"%d{n} commands" + let urlSummary = String.pluralizeWithCount "URL" "URLs" counts[InputCategory.Url] + let cmdSummary = String.pluralizeWithCount "command" "commands" counts[InputCategory.Command] printer.Info <| match counts[InputCategory.Url], counts[InputCategory.Command] with From ea722e0ed5ddd1dac28ffacbaacb05dedfa3d49e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:47:25 +0900 Subject: [PATCH 228/247] Add ReplaceInvalidPathCharsTests module and tests --- src/CCVTAC.FSharp.Tests/ExtensionsTests.fs | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs index c126feba..e8ec80cd 100644 --- a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs +++ b/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs @@ -124,6 +124,62 @@ module StringTests = Assert.True <| (fileLabelWithDescriptor "deleted" 2 = "2 deleted files") Assert.True <| (fileLabelWithDescriptor "image" 1_000_000 = "1,000,000 image files") + module ReplaceInvalidPathCharsTests = + open System.IO + + [] + let ``Default replacement '_' replaces invalid path chars`` () = + let invalids = Path.GetInvalidFileNameChars() |> Array.except ['\000'] + if Array.isEmpty invalids then + Assert.True(false, "Unexpected environment: not enough invalid filename chars") + let invalid = invalids[0] + let input = $"start%c{invalid}end" + let result = replaceInvalidPathChars None None input + + Assert.DoesNotContain(invalid.ToString(), result) + Assert.Contains("_", result) + Assert.StartsWith("start", result) + Assert.EndsWith("end", result) + + [] + let ``Custom invalid chars are replaced with provided replacement`` () = + let custom = ['#'; '%'] + let input = "abc#def%ghi" + let result = replaceInvalidPathChars (Some '-') (Some custom) input + + Assert.DoesNotContain("#", result) + Assert.DoesNotContain("%", result) + Assert.Equal("abc-def-ghi", result) + + [] + let ``Throws when replacement char itself is invalid`` () = + let invalidReplacement = Array.head <| Path.GetInvalidFileNameChars() + let input = "has-no-invalid-chars" + Assert.Throws(fun () -> + replaceInvalidPathChars (Some invalidReplacement) None input |> ignore) + + [] + let ``No invalid chars returns identical string`` () = + let input = "HelloWorld123" + let result = replaceInvalidPathChars None None input + Assert.Equal(input, result) + + [] + let ``All invalid path and filename chars are replaced`` () = + let fileInvalid = Path.GetInvalidFileNameChars() |> Array.truncate 3 + let pathInvalid = Path.GetInvalidPathChars() |> Array.truncate 3 + let extras = [| Path.PathSeparator + Path.DirectorySeparatorChar + Path.AltDirectorySeparatorChar + Path.VolumeSeparatorChar |] + let charsToTest = Array.concat [ fileInvalid; pathInvalid; extras ] + let input = String charsToTest + + let result = replaceInvalidPathChars None None input + + result.ToCharArray() |> Array.iter (fun ch -> Assert.Equal(ch, '_')) + Assert.Equal(input.Length, result.Length) + module SeqTests = [] From 761cb79439fe33f36014526091b600bc567bac52 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:41:03 +0900 Subject: [PATCH 229/247] Rename String.endsWithIgnoringCase to String.endsWithIgnoreCase --- src/CCVTAC.FSharp/Extensions.fs | 2 +- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index f9dc50db..12f88d23 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -45,7 +45,7 @@ module String = let startsWithIgnoreCase startText (text: string) = text.StartsWith(startText, StringComparison.InvariantCultureIgnoreCase) - let endsWithIgnoringCase endText (text: string) = + let endsWithIgnoreCase endText (text: string) = text.EndsWith(endText, StringComparison.InvariantCultureIgnoreCase) /// Pluralize text using a specified count. diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index f0b96b65..313d9a43 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -43,15 +43,15 @@ module TaggingSets = |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) |> Seq.filter (fun (_, files) -> let isSupportedExt = files |> Seq.exists fileHasSupportedExtension - let jsonCount = files |> Seq.filter (String.endsWithIgnoringCase jsonFileExt) |> Seq.length - let imageCount = files |> Seq.filter (String.endsWithIgnoringCase imageFileExt) |> Seq.length + let jsonCount = files |> Seq.filter (String.endsWithIgnoreCase jsonFileExt) |> Seq.length + let imageCount = files |> Seq.filter (String.endsWithIgnoreCase imageFileExt) |> Seq.length isSupportedExt && Numerics.isOne jsonCount && Numerics.isOne imageCount) |> Seq.map (fun (videoId, files) -> let audioFiles = files |> Seq.filter fileHasSupportedExtension - let jsonFile = files |> Seq.find (String.endsWithIgnoringCase jsonFileExt) - let imageFile = files |> Seq.find (String.endsWithIgnoringCase imageFileExt) + let jsonFile = files |> Seq.find (String.endsWithIgnoreCase jsonFileExt) + let imageFile = files |> Seq.find (String.endsWithIgnoreCase imageFileExt) { ResourceId = videoId AudioFilePaths = audioFiles |> Seq.toList JsonFilePath = jsonFile From be80be4330f4feebdeadd425cb0917e2381fcccb Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:41:13 +0900 Subject: [PATCH 230/247] Make pluralizeWithCount inline --- src/CCVTAC.FSharp/Extensions.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 12f88d23..8ec1831a 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -53,7 +53,7 @@ module String = if Numerics.isOne count then ifOne else ifNotOne /// Pluralize text including its count, such as "1 file", "30 URLs". - let pluralizeWithCount ifOne ifNotOne count = + let inline pluralizeWithCount ifOne ifNotOne count = sprintf "%d %s" count (pluralize ifOne ifNotOne count) let inline private fileLabeller descriptor (count: int) = From 2e4bc93ceabd5baa701d9045916f9052da3f465d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:45:40 +0900 Subject: [PATCH 231/247] Slightly improve message --- src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs index 3e30cd38..c1b0a637 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs @@ -39,14 +39,14 @@ module PostProcessor = with | ex -> Error ex.Message - let private generateTaggingSets directoryName : Result = + let private generateTaggingSets dir : Result = try - let taggingSets = createSets <| Directory.GetFiles directoryName + let taggingSets = createSets <| Directory.GetFiles dir if List.isEmpty taggingSets - then Error $"No tagging sets were created using working directory \"%s{directoryName}\"." + then Error $"No tagging sets were created using files in working directory \"%s{dir}\". Are all file extensions correct?" else Ok taggingSets with exn -> - Error $"Error reading working files in \"{directoryName}\": %s{exn.Message}" + Error $"Error reading working files in \"{dir}\": %s{exn.Message}" let run settings mediaType (printer: Printer) : unit = let watch = Watch() From 4d6305ab131f8a520cab6e2c22ca5e16e72c4df3 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:52:38 +0900 Subject: [PATCH 232/247] Add hasOne collection funcs --- src/CCVTAC.FSharp/Extensions.fs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.FSharp/Extensions.fs index 8ec1831a..00b80fdf 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.FSharp/Extensions.fs @@ -112,6 +112,8 @@ module Seq = let doesNotContain x seq = not <| Seq.contains x seq + let hasOne seq = seq |> Seq.length |> Numerics.isOne + let hasMultiple seq = seq |> Seq.length |> (<) 1 let caseInsensitiveContains text (xs: string seq) : bool = @@ -124,6 +126,8 @@ module List = let doesNotContain x lst = not <| List.contains x lst + let hasOne lst = lst |> List.length |> Numerics.isOne + let hasMultiple lst = lst |> List.length |> (<) 1 let caseInsensitiveContains text (lst: string list) : bool = @@ -136,6 +140,8 @@ module Array = let doesNotContain x arr = not <| Array.contains x arr + let hasOne arr = arr |> Array.length |> Numerics.isOne + let hasMultiple arr = arr |> Array.length |> (<) 1 let caseInsensitiveContains text (arr: string array) : bool = From ffdc0a3ccf2cf0516b13d1b99a89193dccd9a18e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:53:06 +0900 Subject: [PATCH 233/247] Use Seq.hasOne when creating tagging sets --- src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs index 313d9a43..aec500a4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs @@ -26,7 +26,7 @@ module TaggingSets = let jsonFileExt = ".json" let imageFileExt = ".jpg" - // Regex: group 1 holds the video id; group 0 is the full filename + // Regex group 0 is the full filename, and group 1 contains the video ID. let fileNamesWithVideoIdsRegex = Regex(@".+\[([\w_\-]{11})\](?:.*)?\.(\w+)", RegexOptions.Compiled) @@ -43,11 +43,9 @@ module TaggingSets = |> Seq.map (fun (videoId, matches) -> videoId, matches |> Seq.map _.Groups[0].Value) |> Seq.filter (fun (_, files) -> let isSupportedExt = files |> Seq.exists fileHasSupportedExtension - let jsonCount = files |> Seq.filter (String.endsWithIgnoreCase jsonFileExt) |> Seq.length - let imageCount = files |> Seq.filter (String.endsWithIgnoreCase imageFileExt) |> Seq.length - isSupportedExt - && Numerics.isOne jsonCount - && Numerics.isOne imageCount) + let hasOneJson = files |> Seq.filter (String.endsWithIgnoreCase jsonFileExt) |> Seq.hasOne + let hasOneImage = files |> Seq.filter (String.endsWithIgnoreCase imageFileExt) |> Seq.hasOne + isSupportedExt && hasOneJson && hasOneImage) |> Seq.map (fun (videoId, files) -> let audioFiles = files |> Seq.filter fileHasSupportedExtension let jsonFile = files |> Seq.find (String.endsWithIgnoreCase jsonFileExt) From 3b73a191680efcef228ebc35ebfd555f7dbfad8e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:26:44 +0900 Subject: [PATCH 234/247] Update ffmpeg prerequisite in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 02e6bcd2..ebefcf09 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ While I maintain it for my own use, feel free to use it yourself! However, pleas - [.NET 10 runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) - [yt-dlp](https://github.com/yt-dlp/yt-dlp) -- [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) +- [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction and conversion) - Optional: [mogrify](https://imagemagick.org/script/mogrify.php) (for auto-trimming album art) ## Screenshots From 8f41f871e9d57138904e04ac6780eb33f67efb99 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:51:18 +0900 Subject: [PATCH 235/247] Fix typo in return type --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index a0c8e589..1ce6c651 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -115,7 +115,7 @@ module Downloader = let metadataDownloadResult = runTool downloadSettings [1] printer match metadataDownloadResult with - | Ok _ -> Error ["Supplementary metadata download completed OK."] + | Ok _ -> Ok ["Supplementary metadata download completed OK."] | Error err -> Error [$"Supplementary metadata download failed: {err}"] let run (mediaType: MediaType) userSettings (printer: Printer) : Result = From 6ad79b137e8644b7ebfeece48716f9b989c902d7 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:53:41 +0900 Subject: [PATCH 236/247] Return string instead of string list for Result of Ok --- src/CCVTAC.FSharp/Downloading/Downloader.fs | 9 ++++----- src/CCVTAC.FSharp/Orchestrator.fs | 4 ++-- src/CCVTAC.FSharp/ResultTracker.fs | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index 1ce6c651..ad44e715 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -103,11 +103,10 @@ module Downloader = loop [] userSettings.AudioFormats - let downloadMetadata (printer: Printer) userSettings (SupplementaryUrl url) - : Result = + let downloadMetadata (printer: Printer) userSettings (SupplementaryUrl url) : Result = match url with - | None -> Ok ["No supplementary metadata link found."] + | None -> Ok "No supplementary metadata link found." | Some url' -> let args = generateDownloadArgs None userSettings None (Some [url']) let commandWithArgs = $"{programName} {args}" @@ -115,10 +114,10 @@ module Downloader = let metadataDownloadResult = runTool downloadSettings [1] printer match metadataDownloadResult with - | Ok _ -> Ok ["Supplementary metadata download completed OK."] + | Ok _ -> Ok "Supplementary metadata download completed OK." | Error err -> Error [$"Supplementary metadata download failed: {err}"] - let run (mediaType: MediaType) userSettings (printer: Printer) : Result = + let run (mediaType: MediaType) userSettings (printer: Printer) : Result = result { let rawUrls = generateDownloadUrl mediaType let urls = diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.FSharp/Orchestrator.fs index ab9fcf66..6ac422a8 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.FSharp/Orchestrator.fs @@ -92,9 +92,9 @@ module Orchestrator = |> List.map (sprintf "Media download error: %s") |> String.concat String.newLine |> Error - | Ok msgs -> + | Ok message -> printer.Debug "Media download(s) successful!" - msgs |> List.iter printer.Info + if String.hasText message then printer.Info message PostProcessor.run settings mediaType printer let groupClause = diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.FSharp/ResultTracker.fs index 0fe09869..54c95e58 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.FSharp/ResultTracker.fs @@ -28,7 +28,7 @@ type ResultTracker<'a>(printer: Printer) = failures[input] <- e /// Logs the result for a specific corresponding input. - member _.RegisterResult(input: string, result: Result<'a list, string list>) : unit = + member _.RegisterResult(input: string, result: Result<'a, string list>) : unit = match result with | Ok _ -> successCount <- successCount + 1UL From c2ad56c194672d652c3a902c9cb206db93b65eb6 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Sat, 27 Dec 2025 18:47:29 +0900 Subject: [PATCH 237/247] Add downloaderAdditionalOptions to settings --- README.md | 11 +++++++++-- src/CCVTAC.FSharp/Downloading/Downloader.fs | 4 ++++ src/CCVTAC.FSharp/Settings/Settings.fs | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ebefcf09..3b7faa1c 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,14 @@ You can copy and paste the sample settings file below to a JSON file named `sett // The full command you use to update your local yt-dlp installation. // This is a sample entry. - "downloaderUpdateCommand": "pip install --upgrade yt-dlp", + "downloaderUpdateCommand": "pip install --upgrade yt-dlp", + + // Arbitrary yt-dlp options to be included in all yt-dlp commands. + // Use with caution, as some options could disrupt operation of this program. + // Intended to be used only when necessary to resolve download issues. + // For example, see https://github.com/yt-dlp/yt-dlp/wiki/EJS, + // upon which this sample is based. + "downloaderAdditionalOptions": "--remote-components ejs:github", // Channel names for which the video thumbnail should // never be embedded in the audio file. @@ -128,7 +135,7 @@ You can copy and paste the sample settings file below to a JSON file named `sett // These require familiarity with regular expressions (regex). "tagDetectionPatterns": { - // Currently supports 5 tags: this one (Title) and its siblings listed below. + // Currently supports 5 tags: this one (title) and its siblings listed below. "title": [ { // A regex pattern for searching in the video metadata field specified below. diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.FSharp/Downloading/Downloader.fs index ad44e715..701875c1 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.FSharp/Downloading/Downloader.fs @@ -41,6 +41,10 @@ module Downloader = if userSettings.QuietMode then args <- args.Add "--quiet --no-warnings" + match userSettings.DownloaderAdditionalOptions with + | Some x -> args <- args.Add x + | None -> () + // No MediaType indicates that this is a supplemental metadata-only download. // TODO: Add a union type to more clearly indicate this difference. match mediaType with diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.FSharp/Settings/Settings.fs index c654b0c2..790a5062 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.FSharp/Settings/Settings.fs @@ -46,6 +46,7 @@ module Settings = [] RenamePatterns: RenamePattern list [] NormalizationForm : string [] DownloaderUpdateCommand : string + [] DownloaderAdditionalOptions : string option } let private defaultSettings = @@ -73,6 +74,7 @@ module Settings = RenamePatterns = [] NormalizationForm = "C" // Recommended for compatibility between Linux and macOS. DownloaderUpdateCommand = String.Empty + DownloaderAdditionalOptions = None } let summarize settings : (string * string) list = From 6ff6a4414822bb95f59f439106b1df385dbbc74e Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:56:52 +0900 Subject: [PATCH 238/247] Rename projects in solution --- .../CCVTAC.Main.fsproj} | 0 .../Commands.fs | 2 +- .../Downloading/Downloader.fs | 18 +- .../Downloading/Downloading.fs | 2 +- .../Downloading/Updater.fs | 8 +- .../Extensions.fs | 2 +- .../ExternalTools/ExternalTool.fs | 2 +- .../ExternalTools/Runner.fs | 4 +- .../ExternalTools/ToolSettings.fs | 2 +- src/{CCVTAC.FSharp => CCVTAC.Main}/Help.fs | 2 +- src/{CCVTAC.FSharp => CCVTAC.Main}/History.fs | 4 +- .../InputHelper.fs | 2 +- .../IoUtilities/Directories.fs | 4 +- .../IoUtilities/Files.fs | 4 +- .../Orchestrator.fs | 18 +- .../PostProcessing/CollectionMetadata.fs | 2 +- .../PostProcessing/Deleter.fs | 4 +- .../PostProcessing/ImageProcessor.fs | 4 +- .../PostProcessing/MetadataUtilities.fs | 4 +- .../PostProcessing/Mover.fs | 12 +- .../PostProcessing/PostProcessing.fs | 10 +- .../PostProcessing/Renamer.fs | 8 +- .../PostProcessing/Tagging/Detectors.fs | 8 +- .../PostProcessing/Tagging/TagDetector.fs | 6 +- .../PostProcessing/Tagging/Tagger.fs | 12 +- .../PostProcessing/Tagging/TaggingSet.fs | 6 +- .../PostProcessing/VideoMetadata.fs | 2 +- src/{CCVTAC.FSharp => CCVTAC.Main}/Printer.fs | 2 +- src/{CCVTAC.FSharp => CCVTAC.Main}/Program.fs | 10 +- .../ResultTracker.fs | 2 +- .../Settings/Id3Version.fs | 2 +- .../Settings/Settings.fs | 4 +- src/{CCVTAC.FSharp => CCVTAC.Main}/Shared.fs | 2 +- .../CCVTAC.Tests.fsproj} | 2 +- .../DownloadEntityTests.fs | 368 +++++++++--------- .../ExtensionsTests.fs | 6 +- .../Program.fs | 2 +- .../RenamerTests.fs | 6 +- .../TagDetectionTests.fs | 8 +- src/CCVTAC.sln | 4 +- 40 files changed, 285 insertions(+), 285 deletions(-) rename src/{CCVTAC.FSharp/CCVTAC.FSharp.fsproj => CCVTAC.Main/CCVTAC.Main.fsproj} (100%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Commands.fs (98%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Downloading/Downloader.fs (95%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Downloading/Downloading.fs (98%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Downloading/Updater.fs (86%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Extensions.fs (99%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/ExternalTools/ExternalTool.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/ExternalTools/Runner.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/ExternalTools/ToolSettings.fs (89%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Help.fs (99%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/History.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/InputHelper.fs (98%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/IoUtilities/Directories.fs (98%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/IoUtilities/Files.fs (87%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Orchestrator.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/CollectionMetadata.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Deleter.fs (96%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/ImageProcessor.fs (78%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/MetadataUtilities.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Mover.fs (96%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/PostProcessing.fs (95%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Renamer.fs (96%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Tagging/Detectors.fs (95%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Tagging/TagDetector.fs (92%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Tagging/Tagger.fs (97%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/Tagging/TaggingSet.fs (96%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/PostProcessing/VideoMetadata.fs (99%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Printer.fs (99%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Program.fs (95%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/ResultTracker.fs (98%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Settings/Id3Version.fs (93%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Settings/Settings.fs (99%) rename src/{CCVTAC.FSharp => CCVTAC.Main}/Shared.fs (97%) rename src/{CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj => CCVTAC.Tests/CCVTAC.Tests.fsproj} (94%) rename src/{CCVTAC.FSharp.Tests => CCVTAC.Tests}/DownloadEntityTests.fs (97%) rename src/{CCVTAC.FSharp.Tests => CCVTAC.Tests}/ExtensionsTests.fs (99%) rename src/{CCVTAC.FSharp.Tests => CCVTAC.Tests}/Program.fs (97%) rename src/{CCVTAC.FSharp.Tests => CCVTAC.Tests}/RenamerTests.fs (94%) rename src/{CCVTAC.FSharp.Tests => CCVTAC.Tests}/TagDetectionTests.fs (97%) diff --git a/src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj b/src/CCVTAC.Main/CCVTAC.Main.fsproj similarity index 100% rename from src/CCVTAC.FSharp/CCVTAC.FSharp.fsproj rename to src/CCVTAC.Main/CCVTAC.Main.fsproj diff --git a/src/CCVTAC.FSharp/Commands.fs b/src/CCVTAC.Main/Commands.fs similarity index 98% rename from src/CCVTAC.FSharp/Commands.fs rename to src/CCVTAC.Main/Commands.fs index bb5a8587..e55342a9 100644 --- a/src/CCVTAC.FSharp/Commands.fs +++ b/src/CCVTAC.Main/Commands.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main open System diff --git a/src/CCVTAC.FSharp/Downloading/Downloader.fs b/src/CCVTAC.Main/Downloading/Downloader.fs similarity index 95% rename from src/CCVTAC.FSharp/Downloading/Downloader.fs rename to src/CCVTAC.Main/Downloading/Downloader.fs index 701875c1..be4573dc 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloader.fs +++ b/src/CCVTAC.Main/Downloading/Downloader.fs @@ -1,12 +1,12 @@ -namespace CCVTAC.Console.Downloading - -open CCVTAC.Console -open CCVTAC.Console.ExternalTools.Runner -open CCVTAC.Console.IoUtilities -open CCVTAC.Console.IoUtilities.Directories -open CCVTAC.Console.Downloading.Downloading -open CCVTAC.Console.ExternalTools -open CCVTAC.Console.Settings.Settings +namespace CCVTAC.Main.Downloading + +open CCVTAC.Main +open CCVTAC.Main.ExternalTools.Runner +open CCVTAC.Main.IoUtilities +open CCVTAC.Main.IoUtilities.Directories +open CCVTAC.Main.Downloading.Downloading +open CCVTAC.Main.ExternalTools +open CCVTAC.Main.Settings.Settings open FsToolkit.ErrorHandling open System diff --git a/src/CCVTAC.FSharp/Downloading/Downloading.fs b/src/CCVTAC.Main/Downloading/Downloading.fs similarity index 98% rename from src/CCVTAC.FSharp/Downloading/Downloading.fs rename to src/CCVTAC.Main/Downloading/Downloading.fs index 780d81c7..1d8673b4 100644 --- a/src/CCVTAC.FSharp/Downloading/Downloading.fs +++ b/src/CCVTAC.Main/Downloading/Downloading.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console.Downloading +namespace CCVTAC.Main.Downloading open System.Text.RegularExpressions diff --git a/src/CCVTAC.FSharp/Downloading/Updater.fs b/src/CCVTAC.Main/Downloading/Updater.fs similarity index 86% rename from src/CCVTAC.FSharp/Downloading/Updater.fs rename to src/CCVTAC.Main/Downloading/Updater.fs index 1b6ed9c5..adeaae1e 100644 --- a/src/CCVTAC.FSharp/Downloading/Updater.fs +++ b/src/CCVTAC.Main/Downloading/Updater.fs @@ -1,8 +1,8 @@ -namespace CCVTAC.Console.Downloading +namespace CCVTAC.Main.Downloading -open CCVTAC.Console.ExternalTools -open CCVTAC.Console -open CCVTAC.Console.Settings.Settings +open CCVTAC.Main.ExternalTools +open CCVTAC.Main +open CCVTAC.Main.Settings.Settings module Updater = diff --git a/src/CCVTAC.FSharp/Extensions.fs b/src/CCVTAC.Main/Extensions.fs similarity index 99% rename from src/CCVTAC.FSharp/Extensions.fs rename to src/CCVTAC.Main/Extensions.fs index 00b80fdf..77128327 100644 --- a/src/CCVTAC.FSharp/Extensions.fs +++ b/src/CCVTAC.Main/Extensions.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main open System open System.Globalization diff --git a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs b/src/CCVTAC.Main/ExternalTools/ExternalTool.fs similarity index 97% rename from src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs rename to src/CCVTAC.Main/ExternalTools/ExternalTool.fs index b35c9e9f..5bb3aa24 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.Main/ExternalTools/ExternalTool.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console.ExternalTools +namespace CCVTAC.Main.ExternalTools open System.Diagnostics diff --git a/src/CCVTAC.FSharp/ExternalTools/Runner.fs b/src/CCVTAC.Main/ExternalTools/Runner.fs similarity index 97% rename from src/CCVTAC.FSharp/ExternalTools/Runner.fs rename to src/CCVTAC.Main/ExternalTools/Runner.fs index fb3f9187..c0bbeb4b 100644 --- a/src/CCVTAC.FSharp/ExternalTools/Runner.fs +++ b/src/CCVTAC.Main/ExternalTools/Runner.fs @@ -1,6 +1,6 @@ -namespace CCVTAC.Console.ExternalTools +namespace CCVTAC.Main.ExternalTools -open CCVTAC.Console +open CCVTAC.Main open Startwatch.Library open System open System.Diagnostics diff --git a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs b/src/CCVTAC.Main/ExternalTools/ToolSettings.fs similarity index 89% rename from src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs rename to src/CCVTAC.Main/ExternalTools/ToolSettings.fs index c1d5f4e3..208c5f3c 100644 --- a/src/CCVTAC.FSharp/ExternalTools/ToolSettings.fs +++ b/src/CCVTAC.Main/ExternalTools/ToolSettings.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console.ExternalTools +namespace CCVTAC.Main.ExternalTools /// Settings to govern the behavior of an external program. type ToolSettings = private { diff --git a/src/CCVTAC.FSharp/Help.fs b/src/CCVTAC.Main/Help.fs similarity index 99% rename from src/CCVTAC.FSharp/Help.fs rename to src/CCVTAC.Main/Help.fs index 7dfc27dc..a9ab8c08 100644 --- a/src/CCVTAC.FSharp/Help.fs +++ b/src/CCVTAC.Main/Help.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main module Help = diff --git a/src/CCVTAC.FSharp/History.fs b/src/CCVTAC.Main/History.fs similarity index 97% rename from src/CCVTAC.FSharp/History.fs rename to src/CCVTAC.Main/History.fs index f8f8ba43..80b084d1 100644 --- a/src/CCVTAC.FSharp/History.fs +++ b/src/CCVTAC.Main/History.fs @@ -1,6 +1,6 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main -open CCVTAC.Console.IoUtilities.Files +open CCVTAC.Main.IoUtilities.Files open System open System.IO open System.Text.Json diff --git a/src/CCVTAC.FSharp/InputHelper.fs b/src/CCVTAC.Main/InputHelper.fs similarity index 98% rename from src/CCVTAC.FSharp/InputHelper.fs rename to src/CCVTAC.Main/InputHelper.fs index 7492d5d2..85497375 100644 --- a/src/CCVTAC.FSharp/InputHelper.fs +++ b/src/CCVTAC.Main/InputHelper.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main open System.Text.RegularExpressions diff --git a/src/CCVTAC.FSharp/IoUtilities/Directories.fs b/src/CCVTAC.Main/IoUtilities/Directories.fs similarity index 98% rename from src/CCVTAC.FSharp/IoUtilities/Directories.fs rename to src/CCVTAC.Main/IoUtilities/Directories.fs index 1c7e3aa4..694d0381 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Directories.fs +++ b/src/CCVTAC.Main/IoUtilities/Directories.fs @@ -1,6 +1,6 @@ -namespace CCVTAC.Console.IoUtilities +namespace CCVTAC.Main.IoUtilities -open CCVTAC.Console +open CCVTAC.Main open System.IO module Directories = diff --git a/src/CCVTAC.FSharp/IoUtilities/Files.fs b/src/CCVTAC.Main/IoUtilities/Files.fs similarity index 87% rename from src/CCVTAC.FSharp/IoUtilities/Files.fs rename to src/CCVTAC.Main/IoUtilities/Files.fs index 098acd1e..d438d207 100644 --- a/src/CCVTAC.FSharp/IoUtilities/Files.fs +++ b/src/CCVTAC.Main/IoUtilities/Files.fs @@ -1,7 +1,7 @@ -namespace CCVTAC.Console.IoUtilities +namespace CCVTAC.Main.IoUtilities open System.IO -open CCVTAC.Console +open CCVTAC.Main module Files = diff --git a/src/CCVTAC.FSharp/Orchestrator.fs b/src/CCVTAC.Main/Orchestrator.fs similarity index 97% rename from src/CCVTAC.FSharp/Orchestrator.fs rename to src/CCVTAC.Main/Orchestrator.fs index 6ac422a8..23d10304 100644 --- a/src/CCVTAC.FSharp/Orchestrator.fs +++ b/src/CCVTAC.Main/Orchestrator.fs @@ -1,12 +1,12 @@ -namespace CCVTAC.Console - -open CCVTAC.Console.Downloading -open CCVTAC.Console.InputHelper -open CCVTAC.Console.IoUtilities -open CCVTAC.Console.PostProcessing -open CCVTAC.Console.Settings -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.Settings.Settings.LiveUpdating +namespace CCVTAC.Main + +open CCVTAC.Main.Downloading +open CCVTAC.Main.InputHelper +open CCVTAC.Main.IoUtilities +open CCVTAC.Main.PostProcessing +open CCVTAC.Main.Settings +open CCVTAC.Main.Settings.Settings +open CCVTAC.Main.Settings.Settings.LiveUpdating open Startwatch.Library open System diff --git a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs b/src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs similarity index 97% rename from src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs rename to src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs index b5ed5733..db3a4417 100644 --- a/src/CCVTAC.FSharp/PostProcessing/CollectionMetadata.fs +++ b/src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing open System.Text.Json.Serialization diff --git a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs b/src/CCVTAC.Main/PostProcessing/Deleter.fs similarity index 96% rename from src/CCVTAC.FSharp/PostProcessing/Deleter.fs rename to src/CCVTAC.Main/PostProcessing/Deleter.fs index 3a3f9743..06a03749 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Deleter.fs +++ b/src/CCVTAC.Main/PostProcessing/Deleter.fs @@ -1,6 +1,6 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing -open CCVTAC.Console +open CCVTAC.Main open System.IO module Deleter = diff --git a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs b/src/CCVTAC.Main/PostProcessing/ImageProcessor.fs similarity index 78% rename from src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs rename to src/CCVTAC.Main/PostProcessing/ImageProcessor.fs index b5d2cadb..ee62388c 100644 --- a/src/CCVTAC.FSharp/PostProcessing/ImageProcessor.fs +++ b/src/CCVTAC.Main/PostProcessing/ImageProcessor.fs @@ -1,6 +1,6 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing -open CCVTAC.Console.ExternalTools +open CCVTAC.Main.ExternalTools module ImageProcessor = diff --git a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs b/src/CCVTAC.Main/PostProcessing/MetadataUtilities.fs similarity index 97% rename from src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs rename to src/CCVTAC.Main/PostProcessing/MetadataUtilities.fs index 1b1351d8..e107389d 100644 --- a/src/CCVTAC.FSharp/PostProcessing/MetadataUtilities.fs +++ b/src/CCVTAC.Main/PostProcessing/MetadataUtilities.fs @@ -1,8 +1,8 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing open System open System.Text -open CCVTAC.Console +open CCVTAC.Main module MetadataUtilities = diff --git a/src/CCVTAC.FSharp/PostProcessing/Mover.fs b/src/CCVTAC.Main/PostProcessing/Mover.fs similarity index 96% rename from src/CCVTAC.FSharp/PostProcessing/Mover.fs rename to src/CCVTAC.Main/PostProcessing/Mover.fs index cb47e82c..769cb5c7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Mover.fs +++ b/src/CCVTAC.Main/PostProcessing/Mover.fs @@ -1,10 +1,10 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing -open CCVTAC.Console.IoUtilities -open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console -open CCVTAC.Console.PostProcessing +open CCVTAC.Main.IoUtilities +open CCVTAC.Main.PostProcessing.Tagging +open CCVTAC.Main.Settings.Settings +open CCVTAC.Main +open CCVTAC.Main.PostProcessing open System open System.IO open System.Text.Json diff --git a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs b/src/CCVTAC.Main/PostProcessing/PostProcessing.fs similarity index 95% rename from src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs rename to src/CCVTAC.Main/PostProcessing/PostProcessing.fs index c1b0a637..78998c9e 100644 --- a/src/CCVTAC.FSharp/PostProcessing/PostProcessing.fs +++ b/src/CCVTAC.Main/PostProcessing/PostProcessing.fs @@ -1,9 +1,9 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing -open CCVTAC.Console -open CCVTAC.Console.IoUtilities -open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.Console.Settings.Settings +open CCVTAC.Main +open CCVTAC.Main.IoUtilities +open CCVTAC.Main.PostProcessing.Tagging +open CCVTAC.Main.Settings.Settings open System.IO open System.Linq open System.Text.Json diff --git a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs b/src/CCVTAC.Main/PostProcessing/Renamer.fs similarity index 96% rename from src/CCVTAC.FSharp/PostProcessing/Renamer.fs rename to src/CCVTAC.Main/PostProcessing/Renamer.fs index f67db0a5..48dab0b4 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Renamer.fs +++ b/src/CCVTAC.Main/PostProcessing/Renamer.fs @@ -1,8 +1,8 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing -open CCVTAC.Console -open CCVTAC.Console.IoUtilities -open CCVTAC.Console.Settings.Settings +open CCVTAC.Main +open CCVTAC.Main.IoUtilities +open CCVTAC.Main.Settings.Settings open System open System.IO open System.Text diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs similarity index 95% rename from src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs rename to src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs index 49974852..b90d93d0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs @@ -1,8 +1,8 @@ -namespace CCVTAC.Console.PostProcessing.Tagging +namespace CCVTAC.Main.PostProcessing.Tagging -open CCVTAC.Console -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.PostProcessing +open CCVTAC.Main +open CCVTAC.Main.Settings.Settings +open CCVTAC.Main.PostProcessing open System open System.Text.RegularExpressions diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs b/src/CCVTAC.Main/PostProcessing/Tagging/TagDetector.fs similarity index 92% rename from src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs rename to src/CCVTAC.Main/PostProcessing/Tagging/TagDetector.fs index 23eb884e..c23ee4e7 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TagDetector.fs +++ b/src/CCVTAC.Main/PostProcessing/Tagging/TagDetector.fs @@ -1,7 +1,7 @@ -namespace CCVTAC.Console.PostProcessing.Tagging +namespace CCVTAC.Main.PostProcessing.Tagging -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.PostProcessing.Tagging +open CCVTAC.Main.Settings.Settings +open CCVTAC.Main.PostProcessing.Tagging module TagDetection = diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs b/src/CCVTAC.Main/PostProcessing/Tagging/Tagger.fs similarity index 97% rename from src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs rename to src/CCVTAC.Main/PostProcessing/Tagging/Tagger.fs index 4f0ab531..15c396e5 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/Tagger.fs +++ b/src/CCVTAC.Main/PostProcessing/Tagging/Tagger.fs @@ -1,10 +1,10 @@ -namespace CCVTAC.Console.PostProcessing.Tagging +namespace CCVTAC.Main.PostProcessing.Tagging -open CCVTAC.Console -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.PostProcessing -open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.Console.Downloading.Downloading +open CCVTAC.Main +open CCVTAC.Main.Settings.Settings +open CCVTAC.Main.PostProcessing +open CCVTAC.Main.PostProcessing.Tagging +open CCVTAC.Main.Downloading.Downloading open Startwatch.Library open TaggingSets open MetadataUtilities diff --git a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs b/src/CCVTAC.Main/PostProcessing/Tagging/TaggingSet.fs similarity index 96% rename from src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs rename to src/CCVTAC.Main/PostProcessing/Tagging/TaggingSet.fs index aec500a4..ddee46d0 100644 --- a/src/CCVTAC.FSharp/PostProcessing/Tagging/TaggingSet.fs +++ b/src/CCVTAC.Main/PostProcessing/Tagging/TaggingSet.fs @@ -1,7 +1,7 @@ -namespace CCVTAC.Console.PostProcessing.Tagging +namespace CCVTAC.Main.PostProcessing.Tagging -open CCVTAC.Console -open CCVTAC.Console.IoUtilities +open CCVTAC.Main +open CCVTAC.Main.IoUtilities open System.IO open System.Text.RegularExpressions diff --git a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs b/src/CCVTAC.Main/PostProcessing/VideoMetadata.fs similarity index 99% rename from src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs rename to src/CCVTAC.Main/PostProcessing/VideoMetadata.fs index 639ff3d7..0ca0deae 100644 --- a/src/CCVTAC.FSharp/PostProcessing/VideoMetadata.fs +++ b/src/CCVTAC.Main/PostProcessing/VideoMetadata.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console.PostProcessing +namespace CCVTAC.Main.PostProcessing open System.Text.Json.Serialization diff --git a/src/CCVTAC.FSharp/Printer.fs b/src/CCVTAC.Main/Printer.fs similarity index 99% rename from src/CCVTAC.FSharp/Printer.fs rename to src/CCVTAC.Main/Printer.fs index 4607a870..fe11aca3 100644 --- a/src/CCVTAC.FSharp/Printer.fs +++ b/src/CCVTAC.Main/Printer.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main open System open System.Collections.Generic diff --git a/src/CCVTAC.FSharp/Program.fs b/src/CCVTAC.Main/Program.fs similarity index 95% rename from src/CCVTAC.FSharp/Program.fs rename to src/CCVTAC.Main/Program.fs index 86fcd08c..dd1634b3 100644 --- a/src/CCVTAC.FSharp/Program.fs +++ b/src/CCVTAC.Main/Program.fs @@ -1,9 +1,9 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main -open CCVTAC.Console -open CCVTAC.Console.IoUtilities -open CCVTAC.Console.Settings -open CCVTAC.Console.Settings.Settings +open CCVTAC.Main +open CCVTAC.Main.IoUtilities +open CCVTAC.Main.Settings +open CCVTAC.Main.Settings.Settings open Settings.IO open System open System.IO diff --git a/src/CCVTAC.FSharp/ResultTracker.fs b/src/CCVTAC.Main/ResultTracker.fs similarity index 98% rename from src/CCVTAC.FSharp/ResultTracker.fs rename to src/CCVTAC.Main/ResultTracker.fs index 54c95e58..1a60397b 100644 --- a/src/CCVTAC.FSharp/ResultTracker.fs +++ b/src/CCVTAC.Main/ResultTracker.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main open System open System.Collections.Generic diff --git a/src/CCVTAC.FSharp/Settings/Id3Version.fs b/src/CCVTAC.Main/Settings/Id3Version.fs similarity index 93% rename from src/CCVTAC.FSharp/Settings/Id3Version.fs rename to src/CCVTAC.Main/Settings/Id3Version.fs index 2234912d..0b1a6e3c 100644 --- a/src/CCVTAC.FSharp/Settings/Id3Version.fs +++ b/src/CCVTAC.Main/Settings/Id3Version.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console.Settings +namespace CCVTAC.Main.Settings module TagFormat = diff --git a/src/CCVTAC.FSharp/Settings/Settings.fs b/src/CCVTAC.Main/Settings/Settings.fs similarity index 99% rename from src/CCVTAC.FSharp/Settings/Settings.fs rename to src/CCVTAC.Main/Settings/Settings.fs index 790a5062..23c8f4cd 100644 --- a/src/CCVTAC.FSharp/Settings/Settings.fs +++ b/src/CCVTAC.Main/Settings/Settings.fs @@ -1,8 +1,8 @@ -namespace CCVTAC.Console.Settings +namespace CCVTAC.Main.Settings open System open System.Text.Json.Serialization -open CCVTAC.Console +open CCVTAC.Main open Spectre.Console module Settings = diff --git a/src/CCVTAC.FSharp/Shared.fs b/src/CCVTAC.Main/Shared.fs similarity index 97% rename from src/CCVTAC.FSharp/Shared.fs rename to src/CCVTAC.Main/Shared.fs index b666fe82..d607d8a0 100644 --- a/src/CCVTAC.FSharp/Shared.fs +++ b/src/CCVTAC.Main/Shared.fs @@ -1,4 +1,4 @@ -namespace CCVTAC.Console +namespace CCVTAC.Main open System.Threading open Spectre.Console diff --git a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj b/src/CCVTAC.Tests/CCVTAC.Tests.fsproj similarity index 94% rename from src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj rename to src/CCVTAC.Tests/CCVTAC.Tests.fsproj index 45042fdf..3ad3e9f7 100644 --- a/src/CCVTAC.FSharp.Tests/CCVTAC.FSharp.Tests.fsproj +++ b/src/CCVTAC.Tests/CCVTAC.Tests.fsproj @@ -26,6 +26,6 @@ - + diff --git a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs b/src/CCVTAC.Tests/DownloadEntityTests.fs similarity index 97% rename from src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs rename to src/CCVTAC.Tests/DownloadEntityTests.fs index 44a4480e..ffd6663f 100644 --- a/src/CCVTAC.FSharp.Tests/DownloadEntityTests.fs +++ b/src/CCVTAC.Tests/DownloadEntityTests.fs @@ -1,184 +1,184 @@ -module DownloadEntityTests - -open Xunit -open CCVTAC.Console.Downloading.Downloading - -module MediaTypeWithIdsTests = - let incorrectMediaType = "Incorrect media type" - let unexpectedError e = $"Unexpected error: {e}" - let unexpectedOk = "Unexpectedly parsed a MediaType" - - [] - let ``Detects video URL with its ID`` () = - let url = "https://www.youtube.com/watch?v=12312312312" - let expectedId = "12312312312" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | Video actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects playlist video URL with its ID`` () = - let url = "https://www.youtube.com/watch?v=12312312312&list=OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg&index=1" - let expectedVideoId = "12312312312" - let expectedPlaylistId = "OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | PlaylistVideo (actualVideoId, actualPlaylistId) -> - Assert.Equal(expectedVideoId, actualVideoId) - Assert.Equal(expectedPlaylistId, actualPlaylistId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects standard playlist URL with its ID`` () = - let url = "https://www.youtube.com/playlist?list=PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L&index=1" - let expectedId = "PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | StandardPlaylist actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects release playlist URL with its ID`` () = - let url = "https://www.youtube.com/playlist?list=OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L&index=1" - let expectedId = "OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | ReleasePlaylist actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects channel URL type 1 with its ID`` () = - let url = "https://www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" - let expectedId = "www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | Channel actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects channel URL type 2 with its ID`` () = - let url = "https://www.youtube.com/@NicknameBasedYouTubeChannelName" - let expectedId = "www.youtube.com/@NicknameBasedYouTubeChannelName" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | Channel actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects channel URL type 2 with encoded Japanese characters`` () = - let url = "https://www.youtube.com/@%E3%81%8A%E3%81%91%E3%83%91%E3%83%A9H" - let expectedId = "www.youtube.com/@%E3%81%8A%E3%81%91%E3%83%91%E3%83%A9H" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | Channel actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects channel videos URL with encoded Japanese characters`` () = - let url = "https://www.youtube.com/@%E3%81%8A%91%E3%83%A9H/videos" - let expectedId = "www.youtube.com/@%E3%81%8A%91%E3%83%A9H/videos" - let result = mediaTypeWithIds url - - match result with - | Ok mediaType -> match mediaType with - | Channel actualId -> Assert.Equal(expectedId, actualId) - | _ -> failwith incorrectMediaType - | Error e -> failwith (unexpectedError e) - - [] - let ``Detects unsupported channel URL type 2 with unencoded Japanese characters`` () = - let url = "https://www.youtube.com/@日本語" - let result = mediaTypeWithIds url - - match result with - | Error _ -> Assert.True true - | Ok _ -> Assert.True (false, unexpectedOk) - - [] - let ``Detects unsupported channel videos URL with unencoded Japanese characters`` () = - let url = "https://www.youtube.com/@日本語/videos" - let result = mediaTypeWithIds url - - match result with - | Error _ -> Assert.True true - | Ok _ -> Assert.True (false, unexpectedOk) - - [] - let ``Error result when an invalid URL is passed`` () = - let url = "INVALID URL" - let result = mediaTypeWithIds url - - match result with - | Error _ -> Assert.True true - | Ok _ -> Assert.True (false, unexpectedOk) - -module DownloadUrlsTests = - [] - let ``Generates expected URL for video`` () = - let video = Video "12312312312" - let expectedUrl = ["https://www.youtube.com/watch?v=12312312312"] - let result = generateDownloadUrl video - Assert.Equal(result.Length, 1) - Assert.Equal(expectedUrl.Length, result.Length) - Assert.Equal(expectedUrl.Head, result.Head) - - [] - let ``Generates expected URL pair for playlist video`` () = - let playlistVideo = PlaylistVideo ("12312312312", "OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg") - let videoUrl = "https://www.youtube.com/watch?v=12312312312" - let playlistUrl = "https://www.youtube.com/playlist?list=OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg" - let expectedUrls = [videoUrl; playlistUrl] - let result = generateDownloadUrl playlistVideo - Assert.Equal(result.Length, 2) - Assert.Equal(expectedUrls.Length, result.Length) - Assert.Equal(expectedUrls.Head, result.Head) - Assert.Equal(expectedUrls[1], result[1]) - - [] - let ``Generates expected URL for standard playlist`` () = - let sPlaylist = StandardPlaylist "PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" - let expectedUrls = ["https://www.youtube.com/playlist?list=PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L"] - let result = generateDownloadUrl sPlaylist - Assert.Equal(result.Length, 1) - Assert.Equal(expectedUrls.Length, result.Length) - Assert.Equal(expectedUrls.Head, result.Head) - - [] - let ``Generates expected URL for release playlist`` () = - let rPlaylist = ReleasePlaylist "OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" - let expectedUrls = ["https://www.youtube.com/playlist?list=OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L"] - let result = generateDownloadUrl rPlaylist - Assert.Equal(result.Length, 1) - Assert.Equal(expectedUrls.Length, result.Length) - Assert.Equal(expectedUrls.Head, result.Head) - - [] - let ``Generates expected URL for channel`` () = - let channel = Channel "www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" - let expectedUrls = ["https://www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg"] - let result = generateDownloadUrl channel - Assert.Equal(result.Length, 1) - Assert.Equal(expectedUrls.Length, result.Length) - Assert.Equal(expectedUrls.Head, result.Head) +module DownloadEntityTests + +open Xunit +open CCVTAC.Main.Downloading.Downloading + +module MediaTypeWithIdsTests = + let incorrectMediaType = "Incorrect media type" + let unexpectedError e = $"Unexpected error: {e}" + let unexpectedOk = "Unexpectedly parsed a MediaType" + + [] + let ``Detects video URL with its ID`` () = + let url = "https://www.youtube.com/watch?v=12312312312" + let expectedId = "12312312312" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | Video actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects playlist video URL with its ID`` () = + let url = "https://www.youtube.com/watch?v=12312312312&list=OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg&index=1" + let expectedVideoId = "12312312312" + let expectedPlaylistId = "OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | PlaylistVideo (actualVideoId, actualPlaylistId) -> + Assert.Equal(expectedVideoId, actualVideoId) + Assert.Equal(expectedPlaylistId, actualPlaylistId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects standard playlist URL with its ID`` () = + let url = "https://www.youtube.com/playlist?list=PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L&index=1" + let expectedId = "PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | StandardPlaylist actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects release playlist URL with its ID`` () = + let url = "https://www.youtube.com/playlist?list=OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L&index=1" + let expectedId = "OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | ReleasePlaylist actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects channel URL type 1 with its ID`` () = + let url = "https://www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" + let expectedId = "www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | Channel actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects channel URL type 2 with its ID`` () = + let url = "https://www.youtube.com/@NicknameBasedYouTubeChannelName" + let expectedId = "www.youtube.com/@NicknameBasedYouTubeChannelName" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | Channel actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects channel URL type 2 with encoded Japanese characters`` () = + let url = "https://www.youtube.com/@%E3%81%8A%E3%81%91%E3%83%91%E3%83%A9H" + let expectedId = "www.youtube.com/@%E3%81%8A%E3%81%91%E3%83%91%E3%83%A9H" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | Channel actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects channel videos URL with encoded Japanese characters`` () = + let url = "https://www.youtube.com/@%E3%81%8A%91%E3%83%A9H/videos" + let expectedId = "www.youtube.com/@%E3%81%8A%91%E3%83%A9H/videos" + let result = mediaTypeWithIds url + + match result with + | Ok mediaType -> match mediaType with + | Channel actualId -> Assert.Equal(expectedId, actualId) + | _ -> failwith incorrectMediaType + | Error e -> failwith (unexpectedError e) + + [] + let ``Detects unsupported channel URL type 2 with unencoded Japanese characters`` () = + let url = "https://www.youtube.com/@日本語" + let result = mediaTypeWithIds url + + match result with + | Error _ -> Assert.True true + | Ok _ -> Assert.True (false, unexpectedOk) + + [] + let ``Detects unsupported channel videos URL with unencoded Japanese characters`` () = + let url = "https://www.youtube.com/@日本語/videos" + let result = mediaTypeWithIds url + + match result with + | Error _ -> Assert.True true + | Ok _ -> Assert.True (false, unexpectedOk) + + [] + let ``Error result when an invalid URL is passed`` () = + let url = "INVALID URL" + let result = mediaTypeWithIds url + + match result with + | Error _ -> Assert.True true + | Ok _ -> Assert.True (false, unexpectedOk) + +module DownloadUrlsTests = + [] + let ``Generates expected URL for video`` () = + let video = Video "12312312312" + let expectedUrl = ["https://www.youtube.com/watch?v=12312312312"] + let result = generateDownloadUrl video + Assert.Equal(result.Length, 1) + Assert.Equal(expectedUrl.Length, result.Length) + Assert.Equal(expectedUrl.Head, result.Head) + + [] + let ``Generates expected URL pair for playlist video`` () = + let playlistVideo = PlaylistVideo ("12312312312", "OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg") + let videoUrl = "https://www.youtube.com/watch?v=12312312312" + let playlistUrl = "https://www.youtube.com/playlist?list=OLZK5uy_kgsbf_bzaknqCjNbb2BtnfylIvHdNlKzg" + let expectedUrls = [videoUrl; playlistUrl] + let result = generateDownloadUrl playlistVideo + Assert.Equal(result.Length, 2) + Assert.Equal(expectedUrls.Length, result.Length) + Assert.Equal(expectedUrls.Head, result.Head) + Assert.Equal(expectedUrls[1], result[1]) + + [] + let ``Generates expected URL for standard playlist`` () = + let sPlaylist = StandardPlaylist "PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" + let expectedUrls = ["https://www.youtube.com/playlist?list=PLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L"] + let result = generateDownloadUrl sPlaylist + Assert.Equal(result.Length, 1) + Assert.Equal(expectedUrls.Length, result.Length) + Assert.Equal(expectedUrls.Head, result.Head) + + [] + let ``Generates expected URL for release playlist`` () = + let rPlaylist = ReleasePlaylist "OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L" + let expectedUrls = ["https://www.youtube.com/playlist?list=OLaB53ktYgG5CBaIe-otRu41Wop8Ji8C2L"] + let result = generateDownloadUrl rPlaylist + Assert.Equal(result.Length, 1) + Assert.Equal(expectedUrls.Length, result.Length) + Assert.Equal(expectedUrls.Head, result.Head) + + [] + let ``Generates expected URL for channel`` () = + let channel = Channel "www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg" + let expectedUrls = ["https://www.youtube.com/channel/UBMmt12UKW571UWtJAgWkWrg"] + let result = generateDownloadUrl channel + Assert.Equal(result.Length, 1) + Assert.Equal(expectedUrls.Length, result.Length) + Assert.Equal(expectedUrls.Head, result.Head) diff --git a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs b/src/CCVTAC.Tests/ExtensionsTests.fs similarity index 99% rename from src/CCVTAC.FSharp.Tests/ExtensionsTests.fs rename to src/CCVTAC.Tests/ExtensionsTests.fs index e8ec80cd..506958c5 100644 --- a/src/CCVTAC.FSharp.Tests/ExtensionsTests.fs +++ b/src/CCVTAC.Tests/ExtensionsTests.fs @@ -1,11 +1,11 @@ module ExtensionsTests -open CCVTAC.Console +open CCVTAC.Main open Xunit open System module NumericsTests = - open CCVTAC.Console.Numerics + open CCVTAC.Main.Numerics [] let ``isZero returns true for any zero value`` () = @@ -108,7 +108,7 @@ module NumericsTests = Assert.Equal("1,234", actual) module StringTests = - open CCVTAC.Console.String + open CCVTAC.Main.String [] let ``fileLabel formats correctly`` () = diff --git a/src/CCVTAC.FSharp.Tests/Program.fs b/src/CCVTAC.Tests/Program.fs similarity index 97% rename from src/CCVTAC.FSharp.Tests/Program.fs rename to src/CCVTAC.Tests/Program.fs index fdc31cde..0695f84c 100644 --- a/src/CCVTAC.FSharp.Tests/Program.fs +++ b/src/CCVTAC.Tests/Program.fs @@ -1 +1 @@ -module Program = let [] main _ = 0 +module Program = let [] main _ = 0 diff --git a/src/CCVTAC.FSharp.Tests/RenamerTests.fs b/src/CCVTAC.Tests/RenamerTests.fs similarity index 94% rename from src/CCVTAC.FSharp.Tests/RenamerTests.fs rename to src/CCVTAC.Tests/RenamerTests.fs index e6288412..84535978 100644 --- a/src/CCVTAC.FSharp.Tests/RenamerTests.fs +++ b/src/CCVTAC.Tests/RenamerTests.fs @@ -1,8 +1,8 @@ module RenamerTests -open CCVTAC.Console -open CCVTAC.Console.Settings.Settings -open CCVTAC.Console.PostProcessing +open CCVTAC.Main +open CCVTAC.Main.Settings.Settings +open CCVTAC.Main.PostProcessing open System open System.Text open Xunit diff --git a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs b/src/CCVTAC.Tests/TagDetectionTests.fs similarity index 97% rename from src/CCVTAC.FSharp.Tests/TagDetectionTests.fs rename to src/CCVTAC.Tests/TagDetectionTests.fs index c8c804d0..ed060049 100644 --- a/src/CCVTAC.FSharp.Tests/TagDetectionTests.fs +++ b/src/CCVTAC.Tests/TagDetectionTests.fs @@ -1,9 +1,9 @@ module TagDetectionTests -open CCVTAC.Console -open CCVTAC.Console.PostProcessing.Tagging -open CCVTAC.Console.PostProcessing -open CCVTAC.Console.Settings.Settings +open CCVTAC.Main +open CCVTAC.Main.PostProcessing.Tagging +open CCVTAC.Main.PostProcessing +open CCVTAC.Main.Settings.Settings open System open Xunit diff --git a/src/CCVTAC.sln b/src/CCVTAC.sln index 506e1a60..ab72356d 100644 --- a/src/CCVTAC.sln +++ b/src/CCVTAC.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 25.0.1706.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CCVTAC.FSharp", "CCVTAC.FSharp\CCVTAC.FSharp.fsproj", "{44860E27-2F04-428C-8444-C774627A019F}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CCVTAC.Main", "CCVTAC.Main\CCVTAC.Main.fsproj", "{44860E27-2F04-428C-8444-C774627A019F}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CCVTAC.FSharp.Tests", "CCVTAC.FSharp.Tests\CCVTAC.FSharp.Tests.fsproj", "{D9BD0637-C4C1-435B-B202-1447FD85DCDE}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CCVTAC.Tests", "CCVTAC.Tests\CCVTAC.Tests.fsproj", "{D9BD0637-C4C1-435B-B202-1447FD85DCDE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution From e1800ff38f5f155a6e3c5367a4e5c7a0b13495c8 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:10:22 +0900 Subject: [PATCH 239/247] Func comment clean-up --- src/CCVTAC.Main/Downloading/Updater.fs | 2 +- src/CCVTAC.Main/ExternalTools/ExternalTool.fs | 7 +++---- src/CCVTAC.Main/ExternalTools/Runner.fs | 6 ++---- src/CCVTAC.Main/IoUtilities/Directories.fs | 2 -- src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs | 7 +++---- src/CCVTAC.Main/Printer.fs | 2 +- 6 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/CCVTAC.Main/Downloading/Updater.fs b/src/CCVTAC.Main/Downloading/Updater.fs index adeaae1e..596b1792 100644 --- a/src/CCVTAC.Main/Downloading/Updater.fs +++ b/src/CCVTAC.Main/Downloading/Updater.fs @@ -6,7 +6,7 @@ open CCVTAC.Main.Settings.Settings module Updater = - let run userSettings (printer: Printer) : Result = + let run userSettings (printer: Printer) : Result = if String.hasNoText userSettings.DownloaderUpdateCommand then printer.Info("No downloader update command provided, so will skip.") Ok() diff --git a/src/CCVTAC.Main/ExternalTools/ExternalTool.fs b/src/CCVTAC.Main/ExternalTools/ExternalTool.fs index 5bb3aa24..805e362b 100644 --- a/src/CCVTAC.Main/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.Main/ExternalTools/ExternalTool.fs @@ -13,16 +13,15 @@ module ExternalTool = /// The name of the program. This should be the exact text used to call it /// on the command line, excluding any arguments. /// The URL of the program, from which users should install it if needed. - /// A brief summary of the purpose of the program within the context of this program. + /// A brief summary of the purpose of the program within the context of this application. /// Should be phrased as a noun (e.g., "image processing" or "audio normalization"). - let create (name: string) (url: string) (purpose: string) = + let create (name: string) (url: string) (purpose: string) : ExternalTool = { Name = name.Trim() Url = url.Trim() Purpose = purpose.Trim() } /// Attempts a dry run of the program to determine if it is installed and available on this system. - /// A Result indicating whether the program is available or not. - let programExists name = + let programExists name : Result = let processStartInfo = ProcessStartInfo( FileName = name, UseShellExecute = false, diff --git a/src/CCVTAC.Main/ExternalTools/Runner.fs b/src/CCVTAC.Main/ExternalTools/Runner.fs index c0bbeb4b..9d1eca61 100644 --- a/src/CCVTAC.Main/ExternalTools/Runner.fs +++ b/src/CCVTAC.Main/ExternalTools/Runner.fs @@ -18,10 +18,8 @@ module Runner = /// Calls an external application. /// Tool settings for execution /// Additional exit codes, other than 0, that can be treated as non-failures - /// Printer for logging - /// A Result instance containing the exit code and any warnings or else an error message. - let runTool toolSettings otherSuccessExitCodes (printer: Printer) - : Result = + /// + let runTool toolSettings otherSuccessExitCodes (printer: Printer) : Result = let watch = Watch() printer.Info $"Running {toolSettings.CommandWithArgs}..." diff --git a/src/CCVTAC.Main/IoUtilities/Directories.fs b/src/CCVTAC.Main/IoUtilities/Directories.fs index 694d0381..4b342438 100644 --- a/src/CCVTAC.Main/IoUtilities/Directories.fs +++ b/src/CCVTAC.Main/IoUtilities/Directories.fs @@ -30,7 +30,6 @@ module Directories = Directory.GetFiles(directoryName, allFilesSearchPattern, EnumerationOptions()) |> Array.filter (fun filePath -> not (ignoreFiles |> Array.exists filePath.EndsWith))) - /// Empties a specified directory and reports the count of deleted files. let deleteAllFiles workingDirectory : Result = @@ -65,7 +64,6 @@ module Directories = printer.Warning $"However, %s{String.fileLabel results.Failures.Length} could not be deleted:" results.Failures |> List.iter printer.Error - /// Warn the user if there are any files in the specified directory. let warnIfAnyFiles showMax dirName = match getDirectoryFileNames dirName None with | Error errMsg -> Error errMsg diff --git a/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs index b90d93d0..1a5804fd 100644 --- a/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs @@ -1,6 +1,5 @@ namespace CCVTAC.Main.PostProcessing.Tagging -open CCVTAC.Main open CCVTAC.Main.Settings.Settings open CCVTAC.Main.PostProcessing open System @@ -32,8 +31,8 @@ module Detectors = raise (ArgumentException($"\"{fieldName}\" is an invalid video metadata field name.")) /// Finds and returns the first instance of text matching a given detection scheme pattern, - /// parsing into T if necessary. - /// A match of type 'a if there was a match; otherwise, the default value provided. + /// parsing to type 'a if necessary. + /// A match of type 'a if there was a match; otherwise, the default value provided, if any. let detectSingle<'a> (videoMetadata: VideoMetadata) (patterns: TagDetectionPattern seq) @@ -55,7 +54,7 @@ module Detectors = /// Finds and returns all instances of text matching a given detection scheme pattern, /// concatenating them into a single string (using a custom separator), then casting /// to type 'a if necessary. - /// A match of type 'a if there were any matches; otherwise, the default value provided. + /// A match of type 'a if there were any matches; otherwise, the default value provided, if any. let detectMultiple<'a> (data: VideoMetadata) (patterns: TagDetectionPattern seq) diff --git a/src/CCVTAC.Main/Printer.fs b/src/CCVTAC.Main/Printer.fs index fe11aca3..1bed9efc 100644 --- a/src/CCVTAC.Main/Printer.fs +++ b/src/CCVTAC.Main/Printer.fs @@ -37,7 +37,7 @@ type Printer(showDebug: bool) = | Ok _ -> [] | Error errors -> errors - /// Show or hide debug messages. + /// Toggle showing or hiding debug messages. member this.ShowDebug(show: bool) = minimumLogLevel <- (if show then Level.Debug else Level.Info) From b5b4fcef3636486847ac0092ee182b05935c65ec Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:13:33 +0900 Subject: [PATCH 240/247] Comment clean-up --- src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs | 2 +- src/CCVTAC.Main/Printer.fs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs b/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs index 1a5804fd..ab81e01e 100644 --- a/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs +++ b/src/CCVTAC.Main/PostProcessing/Tagging/Detectors.fs @@ -10,7 +10,7 @@ module Detectors = /// If casting fails, the default value is returned instead. let tryCast<'a> (text: string) : 'a option = try - // If T is string, return the text directly + // If 'a is string, return the text directly. if typeof<'a> = typeof then Some (box text :?> 'a) else diff --git a/src/CCVTAC.Main/Printer.fs b/src/CCVTAC.Main/Printer.fs index 1bed9efc..d73f12b2 100644 --- a/src/CCVTAC.Main/Printer.fs +++ b/src/CCVTAC.Main/Printer.fs @@ -112,7 +112,7 @@ type Printer(showDebug: bool) = Printer.EmptyLines(defaultArg appendLines 0uy) member private this.Errors(headerMessage: string, errors: string seq) = - // Create an array with headerMessage followed by the items in errors + // Create an array with headerMessage followed by the items in errors. let items = seq { yield headerMessage; yield! errors } |> Seq.toArray this.Errors(items, 0uy) @@ -139,7 +139,6 @@ type Printer(showDebug: bool) = if Numerics.isZero count then () else - // Write count blank lines. The original wrote (count - 1) extra NewLines inside WriteLine call. let repeats = int count - 1 if repeats <= 0 then AnsiConsole.WriteLine() From af2d433bece205b21061ff7f43f14d2d43372ada Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:21:45 +0900 Subject: [PATCH 241/247] Handle TODO by avoiding 'failwith' --- src/CCVTAC.Main/PostProcessing/Mover.fs | 80 ++++++++++++------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/src/CCVTAC.Main/PostProcessing/Mover.fs b/src/CCVTAC.Main/PostProcessing/Mover.fs index 769cb5c7..d44ef0c1 100644 --- a/src/CCVTAC.Main/PostProcessing/Mover.fs +++ b/src/CCVTAC.Main/PostProcessing/Mover.fs @@ -113,44 +113,42 @@ module Mover = let workingDirInfo = DirectoryInfo settings.WorkingDirectory - let firstTaggingSet = - taggingSets - |> Seq.tryHead - |> Option.defaultWith (fun () -> failwith "No tagging sets provided") // TODO: Improve. - - let subFolderName = getSafeSubDirectoryName maybeCollectionData firstTaggingSet - let collectionName = maybeCollectionData |> Option.map _.Title |> Option.defaultValue String.Empty - let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) - - match Directories.ensureDirectoryExists fullMoveToDir with - | Error err -> - printer.Error err - | Ok dirInfo -> - printer.Debug $"Move-to directory \"%s{dirInfo.Name}\" exists." - - let audioFileNames = - workingDirInfo.EnumerateFiles() - |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) - |> List.ofSeq - - if audioFileNames.IsEmpty then - printer.Error "No audio filenames to move were found." - else - let fileCountMsg = String.fileLabelWithDescriptor "audio" - - printer.Debug $"Moving %s{fileCountMsg audioFileNames.Length} to \"%s{fullMoveToDir}\"..." - - let results = moveAudioFiles audioFileNames fullMoveToDir overwrite - - printer.Info $"Moved %s{fileCountMsg results.Successes.Length} in %s{watch.ElapsedFriendly}." - results.Successes |> List.iter printer.Debug - - if List.isNotEmpty results.Failures then - printer.Warning $"However, %s{fileCountMsg results.Failures.Length} could not be moved:" - results.Failures |> List.iter printer.Error - - moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir - audioFileNames.Length overwrite - |> function - | Ok msg -> printer.Info msg - | Error err -> printer.Error $"Error moving the image file: %s{err}." + match taggingSets |> Seq.tryHead with + | None -> printer.Error "No tagging sets provided" + | Some firstTaggingSet -> + let subFolderName = getSafeSubDirectoryName maybeCollectionData firstTaggingSet + let collectionName = maybeCollectionData |> Option.map _.Title |> Option.defaultValue String.Empty + let fullMoveToDir = Path.Combine(settings.MoveToDirectory, subFolderName, collectionName) + + match Directories.ensureDirectoryExists fullMoveToDir with + | Error err -> + printer.Error err + | Ok dirInfo -> + printer.Debug $"Move-to directory \"%s{dirInfo.Name}\" exists." + + let audioFileNames = + workingDirInfo.EnumerateFiles() + |> Seq.filter (fun f -> List.caseInsensitiveContains f.Extension Files.audioFileExtensions) + |> List.ofSeq + + if audioFileNames.IsEmpty then + printer.Error "No audio filenames to move were found." + else + let fileCountMsg = String.fileLabelWithDescriptor "audio" + + printer.Debug $"Moving %s{fileCountMsg audioFileNames.Length} to \"%s{fullMoveToDir}\"..." + + let results = moveAudioFiles audioFileNames fullMoveToDir overwrite + + printer.Info $"Moved %s{fileCountMsg results.Successes.Length} in %s{watch.ElapsedFriendly}." + results.Successes |> List.iter printer.Debug + + if List.isNotEmpty results.Failures then + printer.Warning $"However, %s{fileCountMsg results.Failures.Length} could not be moved:" + results.Failures |> List.iter printer.Error + + moveImageFile collectionName subFolderName workingDirInfo fullMoveToDir + audioFileNames.Length overwrite + |> function + | Ok msg -> printer.Info msg + | Error err -> printer.Error $"Error moving the image file: %s{err}." From b5ea7ccb99c19238d0266ea9f30d0b7378766b93 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:24:11 +0900 Subject: [PATCH 242/247] Tweak comment --- src/CCVTAC.Main/Downloading/Downloader.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CCVTAC.Main/Downloading/Downloader.fs b/src/CCVTAC.Main/Downloading/Downloader.fs index be4573dc..275341a8 100644 --- a/src/CCVTAC.Main/Downloading/Downloader.fs +++ b/src/CCVTAC.Main/Downloading/Downloader.fs @@ -46,7 +46,7 @@ module Downloader = | None -> () // No MediaType indicates that this is a supplemental metadata-only download. - // TODO: Add a union type to more clearly indicate this difference. + // TODO: Perhaps add a union type to more clearly indicate this difference. match mediaType with | Some mt -> if userSettings.SplitChapters then From 3b6eed206d85f6c6d389c9b173c6efb273c30f2d Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:24:37 +0900 Subject: [PATCH 243/247] Add line break --- src/CCVTAC.Main/PostProcessing/Mover.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CCVTAC.Main/PostProcessing/Mover.fs b/src/CCVTAC.Main/PostProcessing/Mover.fs index d44ef0c1..e263829c 100644 --- a/src/CCVTAC.Main/PostProcessing/Mover.fs +++ b/src/CCVTAC.Main/PostProcessing/Mover.fs @@ -15,6 +15,7 @@ open TaggingSets module Mover = let private playlistImageRegex = Regex(@"\[[OP]L[\w\d_-]{12,}\]", RegexOptions.Compiled) + let private imageFileWildcard = "*.jp*" let private isPlaylistImage (fileName: string) = From 92f0fc891e15713dfb6d73252949ce1f5e899a02 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:27:21 +0900 Subject: [PATCH 244/247] Minor code tweaks --- src/CCVTAC.Main/PostProcessing/Mover.fs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/CCVTAC.Main/PostProcessing/Mover.fs b/src/CCVTAC.Main/PostProcessing/Mover.fs index e263829c..c82e32c3 100644 --- a/src/CCVTAC.Main/PostProcessing/Mover.fs +++ b/src/CCVTAC.Main/PostProcessing/Mover.fs @@ -1,16 +1,16 @@ namespace CCVTAC.Main.PostProcessing +open CCVTAC.Main open CCVTAC.Main.IoUtilities +open CCVTAC.Main.PostProcessing open CCVTAC.Main.PostProcessing.Tagging open CCVTAC.Main.Settings.Settings -open CCVTAC.Main -open CCVTAC.Main.PostProcessing +open TaggingSets open System open System.IO open System.Text.Json open System.Text.RegularExpressions open Startwatch.Library -open TaggingSets module Mover = @@ -37,7 +37,8 @@ module Mover = (audioFiles: FileInfo list) (moveToDir: string) (overwrite: bool) - : {| Successes: string list; Failures: string list |} = + : {| Successes: string list + Failures: string list |} = let successes, failures = ResizeArray(), ResizeArray() @@ -75,7 +76,7 @@ module Mover = with exn -> Error $"Error copying the image file: %s{exn.Message}" - let private getParsedVideoJson (taggingSet: TaggingSet) : Result = + let private getParsedVideoJson taggingSet : Result = try let json = File.ReadAllText taggingSet.JsonFilePath match JsonSerializer.Deserialize json with From ab6f7c8f0135965438586c3ef96adc6460425fc0 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:35:03 +0900 Subject: [PATCH 245/247] Remove unused settings file option --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 3b7faa1c..3a2fca93 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,6 @@ You can copy and paste the sample settings file below to a JSON file named `sett // Count of entries to show for `history` command. "historyDisplayCount": 20, - // The directory to which the log file should be saved. - "logDirectory": "/Users/me/Downloads", - // The audio formats (codec) audio should be extracted to. // Options: best, aac, alac, flac, m4a, mp3, opus, vorbis, wav. // Not all options are available for all videos. From ae4644db9c56d4952928beac92ced70d24a8d259 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:46:01 +0900 Subject: [PATCH 246/247] Update readme --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3a2fca93..ea9621fc 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,18 @@ CCVTAC (CodeConscious Video-to-Audio Converter) is a small .NET-powered CLI tool written in F# that acts as a wrapper around [yt-dlp](https://github.com/yt-dlp/yt-dlp) to enable easier download and extractions of audio from YouTube videos, playlists, and channels, plus do some automatic post-processing (tagging, renaming, and moving). -While I maintain it for my own use, feel free to use it yourself! However, please note that it's geared to my own personal use cases and that no warranties or guarantees are provided. +Feel free to use it yourself, but please note that it's geared to my personal use case and that no warranties or guarantees are provided. [![Build and test](https://github.com/codeconscious/ccvtac/actions/workflows/build-test.yml/badge.svg)](https://github.com/codeconscious/ccvtac/actions/workflows/build-test.yml) ## Features - Converts YouTube videos, playlists, and channels to local audio files (via [yt-dlp](https://github.com/yt-dlp/yt-dlp)) -- Writes ID3 tags to files where possible using available or regex-detected metadata -- Adds video metadata (channel name and URL, video URL, etc.) to files' Comment tags -- Auto-renames files via custom regex patterns (to remove media IDs, etc.) +- Writes ID3 tags to files where possible using available metadata via regex-based detection +- Logs video metadata (channel name and URL, video URL, etc.) to files' Comment tags +- Auto-renames files via custom regex patterns (to remove video IDs, etc.) - Optionally writes video thumbnails to files as artwork (if [mogrify](https://imagemagick.org/script/mogrify.php) is installed) -- Customized behavior via a user settings file — e.g., chapter splitting, image embedding, directories +- Customizable behavior via a settings file - Saves entered URLs to a local history file ## Prerequisites @@ -37,21 +37,21 @@ While I maintain it for my own use, feel free to use it yourself! However, pleas ### Settings -A valid settings file is mandatory to use this application. +A valid JSON settings file is mandatory to use this application. By default, the application will look for a file named `settings.json` in its directory. However, you can manually specify an existing file path using the `-s` option, such as `dotnet run -- -s `. -If your `settings.json` file does not exist, one will be created in the application directory with default settings. At minimum, you will need to enter (1) an existing directory for temporary working files, (2) an existing directory to which the final audio files should be moved, and (3) a path to your history file. The other settings have sensible defaults. Some settings require familiarity with regular expressions (regex). +> [!TIP] +> The `--` is necessary to indicate that the command and arguments are for this program and not for `dotnet`. -#### Starter file with comments - -You can copy and paste the sample settings file below to a JSON file named `settings.json` to get started. You will, in particular, need to update the three directories at the top. You can leave the commented lines as-is, as they will be ignored. +If your `settings.json` file does not exist, a default one will be created. At minimum, you will need to enter (1) an existing directory for temporary working files, (2) an existing directory to which the final audio files should be moved, and (3) a path to your history file. The other settings have sensible defaults. Some settings require familiarity with regular expressions (regex).
- Click here to expand! + Click to see a sample settings file + +The sample below contains explanations and some example values as well. -> [!IMPORTANT] -> When entering regular expressions in the JSON, you must enter two backslashes for each one you want to include. For example, to match a whitespace character, use `\\s` instead of `\s`. +**Important:** When entering regular expressions, you must double-up backslashes. For example, to match a whitespace character, use `\\s` instead of `\s`. ```js { @@ -199,22 +199,22 @@ List of commands: - `\help` to see this list of commands - `\quit` or `\q` to quit - `\history` to see the URLs you most recently entered -- `\update-downloader` or `\update-dl` to update yt-dlp using the command in your settings (If you start experiencing constant download errors, try this command) +- `\update-downloader` or `\update-dl` to update yt-dlp using the command in your settings (If you start experiencing constant download errors, try this command to ensure you have the latest version) - Modify the current session only (without updating the settings file): - `\split` toggles chapter splitting - `\images` toggles image embedding - `\quiet` toggles quiet mode - - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the format - - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality + - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the audio format for the current session only + - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality for the current session only ## Reporting issues -If you run into any issues, feel free to create an issue on GitHub. Please provide as much information as possible (i.e., entered URLs, system information, yt-dlp version, etc.). +If you run into any issues, feel free to create an issue on GitHub. Please provide as much information as possible (i.e., entered URLs or comments, system information, yt-dlp version, etc.) and I'll try to take a look. ## History The first incarnation of this application was written in C#. However, after picking up [F#](https://fsharp.org/) out of curiosity about it and functional programming (FP) in 2024 and subsequently using it successfully to create other tools (mainly [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in an FP style, I become curious about F#'s OOP capabilities as well. -As an experiment to both test OOP-style F# and LLMs more, I rewrote this application in OOP F#, using LLMs only for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C# (and how much less code there was too), so I decided to keep this tool in F# permanently. +As an experiment, I rewrote this application in OOP F#, using LLMs solely for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C# (including how much less code there was too), so I decided to keep this tool in F# permanently. -Due to this background, the code is not particularly idiomatic F#, but it is perfectly viable in its current blended-style form. That said, I'll probably tweak it over time to gradually to introduce more FP. +Due to this background, the code is not particularly idiomatic F#, but it is perfectly viable in its current blended-style form. That said, I'll probably tweak it over time to gradually to introduce more FP, mainly for practice. From 51e62c0394ce7f3e5150f2d36323fb4e6c444058 Mon Sep 17 00:00:00 2001 From: CodeConscious <50596087+codeconscious@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:49:13 +0900 Subject: [PATCH 247/247] Final tweaks --- README.md | 18 +++++--- src/CCVTAC.Main/CCVTAC.Main.fsproj | 1 - src/CCVTAC.Main/Downloading/Downloader.fs | 5 +-- src/CCVTAC.Main/ExternalTools/ExternalTool.fs | 2 +- src/CCVTAC.Main/ExternalTools/Runner.fs | 1 - src/CCVTAC.Main/Help.fs | 45 ++++++------------- .../PostProcessing/CollectionMetadata.fs | 2 +- src/CCVTAC.Main/PostProcessing/Deleter.fs | 6 +-- src/CCVTAC.Tests/DownloadEntityTests.fs | 12 ++--- 9 files changed, 35 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index ea9621fc..7fda5743 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Feel free to use it yourself, but please note that it's geared to my personal us - Converts YouTube videos, playlists, and channels to local audio files (via [yt-dlp](https://github.com/yt-dlp/yt-dlp)) - Writes ID3 tags to files where possible using available metadata via regex-based detection -- Logs video metadata (channel name and URL, video URL, etc.) to files' Comment tags +- Logs video metadata (channel name and URL, video URL, etc.) to files' Comments tags - Auto-renames files via custom regex patterns (to remove video IDs, etc.) - Optionally writes video thumbnails to files as artwork (if [mogrify](https://imagemagick.org/script/mogrify.php) is installed) - Customizable behavior via a settings file @@ -191,7 +191,7 @@ The sample below contains explanations and some example values as well. ### Using the application -Once your settings file is ready, run the application with `dotnet run` within the `CCVTAC.Console` directory. Alternatively, pass `-h` or `--help` for instructions (e.g., `dotnet run -- --help`). +Once your settings file is ready, run the application with `dotnet run` within the `CCVTAC.Console` directory, optionally passing the path to your settings file using `-s`. Alternatively, pass `-h` or `--help` for instructions (e.g., `dotnet run -- --help`). When the application is running, enter at least one YouTube media URL (video, playlist, or channel) or command at the prompt and press Enter. No spaces between items are necessary. @@ -199,22 +199,26 @@ List of commands: - `\help` to see this list of commands - `\quit` or `\q` to quit - `\history` to see the URLs you most recently entered -- `\update-downloader` or `\update-dl` to update yt-dlp using the command in your settings (If you start experiencing constant download errors, try this command to ensure you have the latest version) +- `\update-downloader` or `\update-dl` to update yt-dlp using the command in your settings (Note: If you start experiencing constant download errors, try this command to ensure you have the latest version) - Modify the current session only (without updating the settings file): - `\split` toggles chapter splitting - `\images` toggles image embedding - `\quiet` toggles quiet mode - - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the audio format for the current session only - - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality for the current session only + - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the audio format + - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality + +Enter `\commands` in the application to see this summary. ## Reporting issues If you run into any issues, feel free to create an issue on GitHub. Please provide as much information as possible (i.e., entered URLs or comments, system information, yt-dlp version, etc.) and I'll try to take a look. +However, do keep in mind that this is ultimately a hobby project for myself, so I cannot guarantee every issue will be fixed. + ## History -The first incarnation of this application was written in C#. However, after picking up [F#](https://fsharp.org/) out of curiosity about it and functional programming (FP) in 2024 and subsequently using it successfully to create other tools (mainly [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in an FP style, I become curious about F#'s OOP capabilities as well. +The first incarnation of this application was written in C#. However, after picking up [F#](https://fsharp.org/) out of curiosity about it and functional programming (FP) in 2024 and successfully using it to create other tools (mainly [Audio Tag Tools](https://github.com/codeconscious/audio-tag-tools/)) in an FP style, I become curious about F#'s OOP capabilities as well. -As an experiment, I rewrote this application in OOP F#, using LLMs solely for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C# (including how much less code there was too), so I decided to keep this tool in F# permanently. +As an experiment, I rewrote this application in OOP-style F#, using LLMs solely for the rough initial conversion (which greatly reduced the overall time and labor necessary at the cost of requiring a *lot* of manual cleanup). Ultimately, I was surprised how much I preferred the F# code over the C#, so I decided to keep this tool in F#. Due to this background, the code is not particularly idiomatic F#, but it is perfectly viable in its current blended-style form. That said, I'll probably tweak it over time to gradually to introduce more FP, mainly for practice. diff --git a/src/CCVTAC.Main/CCVTAC.Main.fsproj b/src/CCVTAC.Main/CCVTAC.Main.fsproj index 8bd6e3ac..5f7431df 100644 --- a/src/CCVTAC.Main/CCVTAC.Main.fsproj +++ b/src/CCVTAC.Main/CCVTAC.Main.fsproj @@ -39,7 +39,6 @@ - diff --git a/src/CCVTAC.Main/Downloading/Downloader.fs b/src/CCVTAC.Main/Downloading/Downloader.fs index 275341a8..d1c44d31 100644 --- a/src/CCVTAC.Main/Downloading/Downloader.fs +++ b/src/CCVTAC.Main/Downloading/Downloader.fs @@ -22,8 +22,8 @@ module Downloader = let formatArg = match audioFormat with | None -> String.Empty - | Some f when f = "best" -> String.Empty - | Some f -> $"-f {f}" + | Some format when format = "best" -> String.Empty + | Some format -> $"-f {format}" let mutable args = match mediaType with @@ -108,7 +108,6 @@ module Downloader = loop [] userSettings.AudioFormats let downloadMetadata (printer: Printer) userSettings (SupplementaryUrl url) : Result = - match url with | None -> Ok "No supplementary metadata link found." | Some url' -> diff --git a/src/CCVTAC.Main/ExternalTools/ExternalTool.fs b/src/CCVTAC.Main/ExternalTools/ExternalTool.fs index 805e362b..aa4a2353 100644 --- a/src/CCVTAC.Main/ExternalTools/ExternalTool.fs +++ b/src/CCVTAC.Main/ExternalTools/ExternalTool.fs @@ -32,7 +32,7 @@ module ExternalTool = try match Process.Start processStartInfo with | Null -> - Error $"The program \"{name}\" was not found. (The process was null.)" + Error $"The program \"{name}\" was not found (i.e., the process was null)." | NonNull process' -> process'.WaitForExit() Ok() diff --git a/src/CCVTAC.Main/ExternalTools/Runner.fs b/src/CCVTAC.Main/ExternalTools/Runner.fs index 9d1eca61..5fd2a1a4 100644 --- a/src/CCVTAC.Main/ExternalTools/Runner.fs +++ b/src/CCVTAC.Main/ExternalTools/Runner.fs @@ -20,7 +20,6 @@ module Runner = /// Additional exit codes, other than 0, that can be treated as non-failures /// let runTool toolSettings otherSuccessExitCodes (printer: Printer) : Result = - let watch = Watch() printer.Info $"Running {toolSettings.CommandWithArgs}..." diff --git a/src/CCVTAC.Main/Help.fs b/src/CCVTAC.Main/Help.fs index a9ab8c08..6e99b617 100644 --- a/src/CCVTAC.Main/Help.fs +++ b/src/CCVTAC.Main/Help.fs @@ -9,26 +9,16 @@ module Help = channels, plus do some automatic post-processing (tagging, renaming, and moving) too. - While I maintain it primarily for my own use, feel free to use it yourself. - No warranties or guarantees are provided. - - FEATURES - - - Converts YouTube videos, playlists, and channels to local audio files (via yt-dlp) - - Writes ID3 tags to files where possible using available or regex-detected metadata - - Adds video metadata (channel name and URL, video URL, etc.) to files' Comment tags - - Auto-renames files via custom regex patterns (to remove media IDs, etc.) - - Optionally writes video thumbnails to files as artwork (if mogrify is installed) - - Customized behavior via a user settings file -- e.g., chapter splitting, image embedding, directories - - Saves entered URLs to a local history file + Feel free to use it yourself, but please note that it's geared to my personal + use case and that no warranties or guarantees are provided. PREREQUISITES • .NET 10 runtime (https://dotnet.microsoft.com/en-us/download/dotnet/10.0) • yt-dlp (https://github.com/yt-dlp/yt-dlp) - • [ffmpeg](https://ffmpeg.org/) (for yt-dlp artwork extraction) - • Optional: mogrify https://imagemagick.org/script/mogrify.php - (for auto-trimming album art) + • ffmpeg (https://ffmpeg.org/) for yt-dlp artwork extraction + • Optional: mogrify (https://imagemagick.org/script/mogrify.php) + for auto-trimming album art RUNNING IT @@ -40,17 +30,13 @@ module Help = However, you can manually specify an existing file path using the `-s` option, such as `dotnet run -- -s `. - If your `settings.json` file does not exist, a default file will be created in the - application directory with default settings. At minimum, you will need to - enter (1) an existing directory for temporary working files, (2) an existing - directory to which the final audio files should be moved, and (3) a path to - your history file. The other settings have sensible defaults. + Note: The `--` is necessary to indicate that the command and arguments are + for this program and not for `dotnet`. - I added the `sleepSecondsBetweenDownloads` and `sleepSecondsBetweenURLs` - settings to help reduce concentrated loads on YouTube servers. Please avoid - lowering these values too much and slamming their servers with enormous, - long-running downloads (even if you feel their servers can take it). Such behavior - might get you rate-limited by YouTube. + If your `settings.json` file does not exist, a default file will be created. + At minimum, you must enter (1) an existing directory for temporary working files, + (2) an existing directory to which the final audio files should be moved, and + (3) a path to your history file. The other settings have sensible defaults. See the README file on the GitHub repo for more about settings. @@ -69,7 +55,7 @@ module Help = - "\quit" or "\q" to quit - "\history" to see the last few URLs you entered - "\update-downloader" or "\update-dl" to update yt-dlp using the command in your settings - (If you start experiencing constant download errors, try this command) + (If you start experiencing constant download errors, try this command!) - Modify the current session only (without updating the settings file): - `\split` toggles chapter splitting - `\images` toggles image embedding @@ -77,10 +63,5 @@ module Help = - `\format-` followed by a supported audio format (e.g., `\format-m4a`) changes the format - `\quality-` followed by a supported audio quality (e.g., `\quality-0`) changes the audio quality - Enter `\commands` to see this summary in the application. - - Reporting issues: - - If you run into any issues, feel free to create an issue on GitHub. Please provide as much - information as possible (e.g., entered URLs, system information, yt-dlp version). + Enter `\commands` in the application to see this summary. """ diff --git a/src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs b/src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs index db3a4417..5303e4f1 100644 --- a/src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs +++ b/src/CCVTAC.Main/PostProcessing/CollectionMetadata.fs @@ -7,7 +7,7 @@ type CollectionMetadata = [] Title: string [] Availability: string [] Description: string - [] Tags: obj list + [] Tags: string list [] ModifiedDate: string [] ViewCount: int option [] PlaylistCount: int option diff --git a/src/CCVTAC.Main/PostProcessing/Deleter.fs b/src/CCVTAC.Main/PostProcessing/Deleter.fs index 06a03749..b850d6c7 100644 --- a/src/CCVTAC.Main/PostProcessing/Deleter.fs +++ b/src/CCVTAC.Main/PostProcessing/Deleter.fs @@ -15,11 +15,7 @@ module Deleter = try Ok (Directory.GetFiles(workingDirectory, $"*{metadata.Id}*")) with exn -> Error $"Error collecting filenames: {exn.Message}" - let private deleteAll - (fileNames: string array) - (printer: Printer) - : unit = - + let private deleteAll (fileNames: string array) (printer: Printer) : unit = fileNames |> Array.iter (fun fileName -> try diff --git a/src/CCVTAC.Tests/DownloadEntityTests.fs b/src/CCVTAC.Tests/DownloadEntityTests.fs index ffd6663f..8b5eb598 100644 --- a/src/CCVTAC.Tests/DownloadEntityTests.fs +++ b/src/CCVTAC.Tests/DownloadEntityTests.fs @@ -85,8 +85,8 @@ module MediaTypeWithIdsTests = [] let ``Detects channel URL type 2 with encoded Japanese characters`` () = - let url = "https://www.youtube.com/@%E3%81%8A%E3%81%91%E3%83%91%E3%83%A9H" - let expectedId = "www.youtube.com/@%E3%81%8A%E3%81%91%E3%83%91%E3%83%A9H" + let url = "https://www.youtube.com/@%E3%81%8A%E3%81%E3%81%8A%E3%81%E3%81%8A%E3%81%E3%81%8A%E3%81" + let expectedId = "www.youtube.com/@%E3%81%8A%E3%81%E3%81%8A%E3%81%E3%81%8A%E3%81%E3%81%8A%E3%81" let result = mediaTypeWithIds url match result with @@ -97,8 +97,8 @@ module MediaTypeWithIdsTests = [] let ``Detects channel videos URL with encoded Japanese characters`` () = - let url = "https://www.youtube.com/@%E3%81%8A%91%E3%83%A9H/videos" - let expectedId = "www.youtube.com/@%E3%81%8A%91%E3%83%A9H/videos" + let url = "https://www.youtube.com/@%E3%81%8A%91%E3%83%A9HJ/videos" + let expectedId = "www.youtube.com/@%E3%81%8A%91%E3%83%A9HJ/videos" let result = mediaTypeWithIds url match result with @@ -109,7 +109,7 @@ module MediaTypeWithIdsTests = [] let ``Detects unsupported channel URL type 2 with unencoded Japanese characters`` () = - let url = "https://www.youtube.com/@日本語" + let url = "https://www.youtube.com/@日本の文字" let result = mediaTypeWithIds url match result with @@ -118,7 +118,7 @@ module MediaTypeWithIdsTests = [] let ``Detects unsupported channel videos URL with unencoded Japanese characters`` () = - let url = "https://www.youtube.com/@日本語/videos" + let url = "https://www.youtube.com/@日本語の文字/videos" let result = mediaTypeWithIds url match result with