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