diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..5191df6
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,39 @@
+name: publish
+on:
+ release:
+ types:
+ - created
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ dotnet-runtime: [ 'linux-x64', 'osx-arm64', 'osx-x64', 'win-x64' ]
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore dependencies
+ # need to set /p:PublishTrimmed here as well because --no-restore on the publish step causes trimming to silently fail
+ # https://github.com/dotnet/sdk/issues/37049
+ run: dotnet restore -p:PublishTrimmed=true
+
+ - name: Publish
+ env:
+ TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
+ run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --no-restore
+
+ - name: Upload
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ outputName="autotag-${{ github.ref_name }}_${{ matrix.dotnet-runtime }}.zip"
+ finalOutputName="${outputName/osx/macos}"
+ zip -j $finalOutputName AutoTag.CLI/bin/Release/net*/${{ matrix.dotnet-runtime }}/publish/autotag*
+ gh release upload ${{ github.ref_name }} $finalOutputName
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..b891112
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,51 @@
+name: test
+
+on: [ push, pull_request ]
+
+jobs:
+ unit-tests:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore
+
+ - name: Test
+ run: dotnet test AutoTag.Core.Test --no-build --verbosity normal
+
+ integration-tests:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ "ubuntu-latest", "macos-latest", "windows-latest" ]
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore
+
+ - name: Test
+ env:
+ TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
+ run: dotnet test AutoTag.CLI.Test --no-build --verbosity normal
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 648ec25..363d3a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -363,3 +363,5 @@ FodyWeavers.xsd
# Ignore JetBrains config files
.idea/
+
+.env
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj
new file mode 100644
index 0000000..4cfb705
--- /dev/null
+++ b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/ConfigTests.cs b/AutoTag.CLI.Test/ConfigTests.cs
new file mode 100644
index 0000000..1458889
--- /dev/null
+++ b/AutoTag.CLI.Test/ConfigTests.cs
@@ -0,0 +1,97 @@
+using System.Text.Json;
+using AutoTag.CLI.Test.Helpers;
+using AutoTag.Core.Config;
+using AutoTag.Core.Files;
+using AwesomeAssertions.Execution;
+
+namespace AutoTag.CLI.Test;
+
+public class ConfigTests(CLIFixture cli, ITestContextAccessor context) : CLITestBase
+{
+ [Fact]
+ public async Task Should_CreateConfigFile_WhenDoesntExist()
+ {
+ await cli.ExecuteAsync("--print-config");
+
+ File.Exists(ConfigPath).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Should_SaveArgumentsToConfigFile_WhenSetDefaultArgumentSet()
+ {
+ await cli.ExecuteAsync(
+ "--set-default",
+ "--print-config",
+ "-p", "parse pattern",
+ "--no-rename",
+ "--tv-pattern", "{Series} {Season:S00}{Episode:E00}",
+ "--movie-pattern", "{Title} {Year}",
+ "--windows-safe",
+ "--rename-subs",
+ "--replace", "a=b",
+ "--replace", "cde=fgh",
+ "-t",
+ "--no-tag",
+ "--no-cover",
+ "--manual",
+ "--extended-tagging",
+ "--apple-tagging",
+ "-l", "pt-BR",
+ "--search-language", "en-GB",
+ "--search-language", "en-US",
+ "-g",
+ "--include-adult",
+ "--remove-empty-folders"
+ );
+
+ var config = JsonSerializer.Deserialize(
+ await File.ReadAllTextAsync(ConfigPath, context.Current.CancellationToken),
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
+ )!;
+
+ using (new AssertionScope())
+ {
+ config.ParsePattern.Should().Be("parse pattern");
+ config.RenameFiles.Should().BeFalse();
+ config.TVRenamePattern.Should().Be("{Series} {Season:S00}{Episode:E00}");
+ config.MovieRenamePattern.Should().Be("{Title} {Year}");
+ config.WindowsSafe.Should().BeTrue();
+ config.RenameSubtitles.Should().BeTrue();
+ config.FileNameReplaces.Should()
+ .BeEquivalentTo([new FileNameReplace("a", "b"), new FileNameReplace("cde", "fgh")]);
+ config.Mode.Should().Be(Mode.TV);
+ config.TagFiles.Should().BeFalse();
+ config.AddCoverArt.Should().BeFalse();
+ config.ManualMode.Should().BeTrue();
+ config.ExtendedTagging.Should().BeTrue();
+ config.AppleTagging.Should().BeTrue();
+ config.Language.Should().Be("pt-BR");
+ config.SearchLanguages.Should().BeEquivalentTo("en-GB", "en-US");
+ config.EpisodeGroup.Should().BeTrue();
+ config.IncludeAdult.Should().BeTrue();
+ config.RemoveEmptyFolders.Should().BeTrue();
+ }
+ }
+
+ [Fact]
+ public async Task Should_LoadConfigFromFile()
+ {
+ await cli.ExecuteAsync(
+ "--set-default",
+ "--print-config",
+ "--no-rename",
+ "--tv-pattern", "{Series}/{Season:S00}{Episode:E00}",
+ "--replace", "a=b",
+ "--replace", "cde=fgh"
+ );
+
+ var (output, _) = await cli.ExecuteAsync("--print-config");
+
+ var config = JsonSerializer.Deserialize(output)!;
+
+ config.RenameFiles.Should().BeFalse();
+ config.TVRenamePattern.Should().Be("{Series}/{Season:S00}{Episode:E00}");
+ config.FileNameReplaces.Should()
+ .BeEquivalentTo([new FileNameReplace("a", "b"), new FileNameReplace("cde", "fgh")]);
+ }
+}
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/GlobalUsing.cs b/AutoTag.CLI.Test/GlobalUsing.cs
new file mode 100644
index 0000000..02572fb
--- /dev/null
+++ b/AutoTag.CLI.Test/GlobalUsing.cs
@@ -0,0 +1,2 @@
+global using AwesomeAssertions;
+global using Xunit;
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/Helpers/CLIFixture.cs b/AutoTag.CLI.Test/Helpers/CLIFixture.cs
new file mode 100644
index 0000000..a1022fe
--- /dev/null
+++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs
@@ -0,0 +1,110 @@
+using System.Diagnostics;
+using System.Text;
+using System.Text.RegularExpressions;
+using AutoTag.CLI.Test.Helpers;
+using CliWrap;
+using CliWrap.Buffered;
+using Spectre.Console.Cli.Testing;
+
+[assembly: AssemblyFixture(typeof(CLIFixture))]
+[assembly: CaptureConsole(CaptureOut = true, CaptureError = true)]
+
+namespace AutoTag.CLI.Test.Helpers;
+
+public partial class CLIFixture(ITestContextAccessor context) : IAsyncLifetime
+{
+ private string _cliPublishOutput = null!;
+
+ [GeneratedRegex(@"\x1b\[[0-9;]*m")]
+ private static partial Regex AnsiCodeRegex { get; }
+
+ public async ValueTask InitializeAsync()
+ {
+ if (Debugger.IsAttached)
+ {
+ return;
+ }
+
+ var stdout = new StringBuilder();
+
+ string? outputPath = null;
+ var build = await Cli.Wrap("dotnet")
+ .WithWorkingDirectory(Path.Combine("..", "..", "..", ".."))
+ .WithArguments(["publish", "AutoTag.CLI", "-c", "Release"])
+ .WithStandardOutputPipe(PipeTarget.ToDelegate(l =>
+ {
+ stdout.AppendLine(l);
+
+ if (l.Contains("AutoTag.CLI ->"))
+ {
+ outputPath = Path.Combine(
+ l.Split("->", 2, StringSplitOptions.TrimEntries)[1],
+ $"autotag{(OperatingSystem.IsWindows() ? ".exe" : "")}"
+ );
+ }
+ }))
+ .WithStandardErrorPipe(PipeTarget.ToDelegate(context.Current.SendDiagnosticMessage))
+ .WithEnvironmentVariables(b => b.Set("TMDB_API_KEY", Environment.GetEnvironmentVariable("TMDB_API_KEY")))
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteAsync();
+
+ if (!build.IsSuccess || string.IsNullOrEmpty(outputPath))
+ {
+ context.Current.SendDiagnosticMessage("CLI production build failed:\n{0}", stdout.ToString());
+
+ throw new Exception("CLI build failed");
+ }
+
+ _cliPublishOutput = outputPath;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _cliPublishOutput = "";
+
+ return ValueTask.CompletedTask;
+ }
+
+ public async Task<(string, int)> ExecuteAsync(params string[] arguments)
+ {
+ if (context.Current.TestClassInstance is not CLITestBase cliTest)
+ {
+ throw new InvalidOperationException($"Test class must derive from {nameof(CLITestBase)}");
+ }
+
+ string[] args = [..arguments, "-c", cliTest.ConfigPath];
+
+ if (Debugger.IsAttached)
+ {
+ context.Current.SendDiagnosticMessage("Debugger detected, running CLI in-process");
+
+ var app = new CommandAppTester();
+ app.SetDefaultCommand();
+
+ var appResult = await app.RunAsync(args);
+
+ return (RemoveAnsiColourCodes(appResult.Output), appResult.ExitCode);
+ }
+
+ if (string.IsNullOrEmpty(_cliPublishOutput))
+ {
+ throw new InvalidOperationException("CLI publish output not set");
+ }
+
+ var cmd = Cli.Wrap(_cliPublishOutput)
+ .WithArguments(args)
+ .WithValidation(CommandResultValidation.None);
+
+ var result = await cmd.ExecuteBufferedAsync();
+
+ if (!result.IsSuccess)
+ {
+ context.Current.SendDiagnosticMessage($"CLI returned non-zero exit code ({result.ExitCode})");
+ context.Current.SendDiagnosticMessage(result.StandardOutput);
+ }
+
+ return (RemoveAnsiColourCodes(result.StandardOutput), result.ExitCode);
+ }
+
+ private static string RemoveAnsiColourCodes(string input) => AnsiCodeRegex.Replace(input, "");
+}
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/Helpers/CLITestBase.cs b/AutoTag.CLI.Test/Helpers/CLITestBase.cs
new file mode 100644
index 0000000..dda7ad5
--- /dev/null
+++ b/AutoTag.CLI.Test/Helpers/CLITestBase.cs
@@ -0,0 +1,21 @@
+namespace AutoTag.CLI.Test.Helpers;
+
+public abstract class CLITestBase : IDisposable
+{
+ private readonly string _testDirectory;
+
+ protected CLITestBase()
+ {
+ _testDirectory = Path.Combine(".", $"test-files-{Random.Shared.Next()}");
+ Directory.CreateDirectory(_testDirectory);
+
+ ConfigPath = Path.Combine(_testDirectory, "conf.json");
+ FileSystem = new FileSystemBuilder(Path.Combine(_testDirectory, "filesystem"));
+ }
+
+ public string ConfigPath { get; }
+
+ protected FileSystemBuilder FileSystem { get; }
+
+ public void Dispose() => Directory.Delete(_testDirectory, true);
+}
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs b/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs
new file mode 100644
index 0000000..a10a6dc
--- /dev/null
+++ b/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs
@@ -0,0 +1,33 @@
+namespace AutoTag.CLI.Test.Helpers;
+
+public class FileSystemBuilder(string basePath)
+{
+ public FileSystemBuilder CreateFile(string name)
+ {
+ var filePath = Path.Combine(basePath, name);
+ var extension = Path.GetExtension(name);
+ if (extension is ".mkv" or ".mp4")
+ {
+ File.Copy(Path.Combine("..", "..", "..", "TestFiles", $"test{extension}"), filePath);
+ }
+ else
+ {
+ using var _ = File.Create(filePath);
+ }
+
+ return this;
+ }
+
+ public FileSystemBuilder CreateDirectory(string name, Action build)
+ {
+ var path = Path.Combine(basePath, name);
+
+ Directory.CreateDirectory(path);
+ var directory = new FileSystemBuilder(path);
+ build(directory);
+
+ return this;
+ }
+
+ public string GetPath(params string[] segments) => Path.Combine([basePath, ..segments]);
+}
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/ProcessTests.cs b/AutoTag.CLI.Test/ProcessTests.cs
new file mode 100644
index 0000000..55f1673
--- /dev/null
+++ b/AutoTag.CLI.Test/ProcessTests.cs
@@ -0,0 +1,334 @@
+using AutoTag.CLI.Test.Helpers;
+using AwesomeAssertions.Execution;
+using TagLib;
+using File = System.IO.File;
+using TagLibFile = TagLib.File;
+using Tag = TagLib.Matroska.Tag;
+
+namespace AutoTag.CLI.Test;
+
+public class ProcessTests(CLIFixture cli) : CLITestBase
+{
+ [Fact]
+ public async Task Should_ProcessFilesWithRenamePattern()
+ {
+ FileSystem
+ .CreateDirectory("Downloads",
+ d => d
+ .CreateDirectory("house.of.the.dragon.s02",
+ d2 => d2.CreateFile("house.of.the.dragon.s02e01.mkv")
+ .CreateFile("house.of.the.dragon.s02e02.mkv")
+ )
+ .CreateDirectory("The Testaments S01E10",
+ d2 => d2.CreateFile("The Testaments S01E10.mp4")
+ .CreateFile("The Testaments S01E10.srt")
+ )
+ )
+ .CreateDirectory("Movies", d => d
+ .CreateFile("Interstellar.2014.avi")
+ .CreateFile("Star Wars (1977).mkv")
+ );
+
+ var (_, exitCode) = await cli.ExecuteAsync(
+ FileSystem.GetPath("Downloads"),
+ FileSystem.GetPath("Movies", "Interstellar.2014.avi"),
+ FileSystem.GetPath("Movies", "Star Wars (1977).mkv"),
+ "--tv-pattern", "{Series} {Season:S00}{Episode:E00}",
+ "--movie-pattern", "{Title} ({Year})",
+ "--rename-subs"
+ );
+
+ exitCode.Should().Be(0);
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "house.of.the.dragon.s02", "house.of.the.dragon.s02e01.mkv"),
+ FileSystem.GetPath("Downloads", "house.of.the.dragon.s02", "House of the Dragon S02E01.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("A Son for a Son");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("House of the Dragon");
+ f.Disc.Should().Be(2);
+ f.Track.Should().Be(1);
+ f.TrackCount.Should().Be(8);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "house.of.the.dragon.s02", "house.of.the.dragon.s02e02.mkv"),
+ FileSystem.GetPath("Downloads", "house.of.the.dragon.s02", "House of the Dragon S02E02.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("Rhaenyra the Cruel");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("House of the Dragon");
+ f.Disc.Should().Be(2);
+ f.Track.Should().Be(2);
+ f.TrackCount.Should().Be(8);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "The Testaments S01E10", "The Testaments S01E10.mp4"),
+ FileSystem.GetPath("Downloads", "The Testaments S01E10", "The Testaments S01E10.mp4"),
+ f =>
+ {
+ f.Title.Should().Be("Secateurs");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("The Testaments");
+ f.Disc.Should().Be(1);
+ f.Track.Should().Be(10);
+ f.TrackCount.Should().Be(10);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "The Testaments S01E10", "The Testaments S01E10.srt"),
+ FileSystem.GetPath("Downloads", "The Testaments S01E10", "The Testaments S01E10.srt")
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Movies", "Interstellar.2014.avi"),
+ FileSystem.GetPath("Movies", "Interstellar (2014).avi")
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Movies", "Star Wars (1977).mkv"),
+ FileSystem.GetPath("Movies", "Star Wars (1977).mkv"),
+ f =>
+ {
+ f.Title.Should().Be("Star Wars");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Year.Should().Be(1977);
+ }
+ );
+ }
+
+ [Fact]
+ public async Task Should_ProcessFilesWithAbsoluteRenamePattern()
+ {
+ FileSystem
+ .CreateDirectory("Downloads",
+ d => d
+ .CreateDirectory("house.of.the.dragon.s02",
+ d2 => d2.CreateFile("house.of.the.dragon.s02e01.mkv")
+ .CreateFile("house.of.the.dragon.s02e02.mkv")
+ )
+ .CreateDirectory("The Testaments S01E10",
+ d2 => d2.CreateFile("The Testaments S01E10.mp4")
+ .CreateFile("The Testaments S01E10.srt")
+ )
+ .CreateDirectory("Doctor Who",
+ d2 => d2.CreateFile("Doctor.Who.2005.S00E02.mkv")
+ .CreateFile("Doctor.Who.2005.S01E01.pt1.mkv")
+ .CreateFile("Doctor.Who.2005.S01E02-03.mkv")
+ .CreateFile("Doctor.Who.2005.S01.scene.nfo")
+ )
+ .CreateDirectory("lotr",
+ d2 => d2.CreateFile("The Lord of the Rings The Fellowship of the Ring.mp4"))
+ .CreateDirectory("Empty", _ => { })
+ )
+ .CreateDirectory("Movies", d => d
+ .CreateFile("Interstellar.2014.avi")
+ .CreateFile("Star Wars (1977).mkv")
+ );
+
+ var (_, exitCode) = await cli.ExecuteAsync(
+ FileSystem.GetPath("Downloads"),
+ FileSystem.GetPath("Movies", "Interstellar.2014.avi"),
+ FileSystem.GetPath("Movies", "Star Wars (1977).mkv"),
+ "--tv-pattern",
+ FileSystem.GetPath(
+ "TV",
+ "{Series}{Year: (0)|}",
+ "{Season:Season 0|Specials}",
+ "{Series}{Year: (0)|} {Season:S00}{Episode:E00}{EndEpisode:-00|}{Part: pt0|}"
+ ),
+ "--movie-pattern", FileSystem.GetPath("Movies", "{Title} ({Year})"),
+ "--rename-subs",
+ "--remove-empty-folders",
+ "--windows-safe"
+ );
+
+ exitCode.Should().Be(0);
+
+ // isn't empty so should remain
+ Directory.Exists(FileSystem.GetPath("Downloads", "Doctor Who")).Should().BeTrue();
+
+ Directory.Exists(FileSystem.GetPath("Downloads", "house.of.the.dragon.s02")).Should().BeFalse();
+ Directory.Exists(FileSystem.GetPath("Downloads", "The Testaments S01E10")).Should().BeFalse();
+ Directory.Exists(FileSystem.GetPath("Downloads", "lotr")).Should().BeFalse();
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "house.of.the.dragon.s02", "house.of.the.dragon.s02e01.mkv"),
+ FileSystem.GetPath("TV", "House of the Dragon", "Season 2", "House of the Dragon S02E01.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("A Son for a Son");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("House of the Dragon");
+ f.Disc.Should().Be(2);
+ f.Track.Should().Be(1);
+ f.TrackCount.Should().Be(8);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "house.of.the.dragon.s02", "house.of.the.dragon.s02e02.mkv"),
+ FileSystem.GetPath("TV", "House of the Dragon", "Season 2", "House of the Dragon S02E02.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("Rhaenyra the Cruel");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("House of the Dragon");
+ f.Disc.Should().Be(2);
+ f.Track.Should().Be(2);
+ f.TrackCount.Should().Be(8);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "The Testaments S01E10", "The Testaments S01E10.mp4"),
+ FileSystem.GetPath("TV", "The Testaments", "Season 1", "The Testaments S01E10.mp4"),
+ f =>
+ {
+ f.Title.Should().Be("Secateurs");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("The Testaments");
+ f.Disc.Should().Be(1);
+ f.Track.Should().Be(10);
+ f.TrackCount.Should().Be(10);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "The Testaments S01E10", "The Testaments S01E10.srt"),
+ FileSystem.GetPath("TV", "The Testaments", "Season 1", "The Testaments S01E10.srt")
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "Doctor Who", "Doctor.Who.2005.S00E02.mkv"),
+ FileSystem.GetPath("TV", "Doctor Who (2005)", "Specials", "Doctor Who (2005) S00E02.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("The Christmas Invasion");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("Doctor Who");
+ f.Disc.Should().Be(0);
+ f.Track.Should().Be(2);
+ f.TrackCount.Should().Be(199);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "Doctor Who", "Doctor.Who.2005.S01E01.pt1.mkv"),
+ FileSystem.GetPath("TV", "Doctor Who (2005)", "Season 1", "Doctor Who (2005) S01E01 pt1.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("Rose");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("Doctor Who");
+ f.Disc.Should().Be(1);
+ f.Track.Should().Be(1);
+ f.TrackCount.Should().Be(13);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "Doctor Who", "Doctor.Who.2005.S01E02-03.mkv"),
+ FileSystem.GetPath("TV", "Doctor Who (2005)", "Season 1", "Doctor Who (2005) S01E02-03.mkv"),
+ f =>
+ {
+ f.Title.Should().Be("The End of the World");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Album.Should().Be("Doctor Who");
+ f.Disc.Should().Be(1);
+ f.Track.Should().Be(2);
+ f.TrackCount.Should().Be(13);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Downloads", "lotr", "The Lord of the Rings The Fellowship of the Ring.mp4"),
+ FileSystem.GetPath("Movies", "The Lord of the Rings The Fellowship of the Ring (2001).mp4"),
+ f =>
+ {
+ f.Title.Should().Be("The Lord of the Rings: The Fellowship of the Ring");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Year.Should().Be(2001);
+ }
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Movies", "Interstellar.2014.avi"),
+ FileSystem.GetPath("Movies", "Interstellar (2014).avi")
+ );
+
+ AssertFile(
+ FileSystem.GetPath("Movies", "Star Wars (1977).mkv"),
+ FileSystem.GetPath("Movies", "Star Wars (1977).mkv"),
+ f =>
+ {
+ f.Title.Should().Be("Star Wars");
+ f.Description.Should().NotBeEmpty();
+ f.Genres.Should().NotBeEmpty();
+ f.Year.Should().Be(1977);
+ }
+ );
+ }
+
+ private static void AssertFile(string originalPath, string newPath, Action? assertTags = null)
+ {
+ if (originalPath != newPath)
+ {
+ File.Exists(originalPath).Should().BeFalse();
+ }
+
+ File.Exists(newPath).Should().BeTrue();
+
+ if (assertTags != null && Path.GetExtension(newPath) is ".mkv" or ".mp4")
+ {
+ using var file = TagLibFile.Create(newPath);
+
+ var tags = new FileTags(
+ file.Tag.Title,
+ file.Tag.Description,
+ file.Tag.Genres,
+ file.TagTypes.HasFlag(TagTypes.Matroska)
+ ? ((Tag)file.GetTag(TagTypes.Matroska)).Get("ALBUM")?.FirstOrDefault() ?? ""
+ : file.Tag.Album,
+ file.Tag.Disc,
+ file.Tag.Track,
+ file.Tag.TrackCount,
+ file.Tag.Year
+ );
+
+ using (new AssertionScope())
+ {
+ assertTags(tags);
+ }
+ }
+ }
+
+ private record FileTags(
+ string Title,
+ string Description,
+ string[] Genres,
+ string Album,
+ uint Disc,
+ uint Track,
+ uint TrackCount,
+ uint Year
+ );
+}
\ No newline at end of file
diff --git a/AutoTag.CLI.Test/TestFiles/test.mkv b/AutoTag.CLI.Test/TestFiles/test.mkv
new file mode 100644
index 0000000..bb9fb09
Binary files /dev/null and b/AutoTag.CLI.Test/TestFiles/test.mkv differ
diff --git a/AutoTag.CLI.Test/TestFiles/test.mp4 b/AutoTag.CLI.Test/TestFiles/test.mp4
new file mode 100644
index 0000000..440b6ff
Binary files /dev/null and b/AutoTag.CLI.Test/TestFiles/test.mp4 differ
diff --git a/AutoTag.CLI.Test/xunit.runner.json b/AutoTag.CLI.Test/xunit.runner.json
new file mode 100644
index 0000000..68790c3
--- /dev/null
+++ b/AutoTag.CLI.Test/xunit.runner.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+ "diagnosticMessages": true
+}
\ No newline at end of file
diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj
index 1f98609..f048ae4 100644
--- a/AutoTag.CLI/AutoTag.CLI.csproj
+++ b/AutoTag.CLI/AutoTag.CLI.csproj
@@ -1,35 +1,46 @@
-
- autotag
- Exe
- net10.0
- 4.0.3
- enable
- enable
-
+
+ autotag
+ Exe
+ net10.0
+ 4.1.0
+ enable
+ enable
+ linux-x64;osx-arm64;osx-x64;win-x64
+
-
- true
- true
- true
- true
-
- true
- partial
-
+
+ true
+ true
+ true
+ true
+
+ true
+ partial
+ False
+ None
+
-
-
-
-
-
-
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
diff --git a/AutoTag.CLI/CLIInterface.cs b/AutoTag.CLI/CLIInterface.cs
index 1ba4f6a..a653244 100644
--- a/AutoTag.CLI/CLIInterface.cs
+++ b/AutoTag.CLI/CLIInterface.cs
@@ -5,96 +5,15 @@
namespace AutoTag.CLI;
-public class CLIInterface(IServiceProvider serviceProvider) : IUserInterface
+public class CLIInterface(IServiceProvider serviceProvider, IAnsiConsole console) : IUserInterface
{
- private List Files = null!;
+ private AutoTagConfig Config = null!;
private TaggingFile CurrentFile = null!;
+ private List Files = null!;
private bool Success = true;
private int Warnings;
- private AutoTagConfig Config = null!;
-
- public async Task RunAsync(IEnumerable entries)
- {
- Config = serviceProvider.GetRequiredService();
- var processor = serviceProvider.GetRequiredKeyedService(Config.Mode);
- var fileFinder = serviceProvider.GetRequiredService();
-
- AnsiConsole.WriteLine($"AutoTag v{GetVersion()}");
- AnsiConsole.MarkupLine("[link]https://jtattersall.net[/]");
-
- Files = fileFinder.FindFilesToProcess(entries);
-
- if (Files.Count == 0)
- {
- DisplayMessage("No files found", MessageType.Error);
- return 1;
- }
-
- foreach (var file in Files)
- {
- CurrentFile = file;
- AnsiConsole.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]");
-
- Success &= await processor.ProcessAsync(file);
- }
-
- return ReportResults(Files.Count);
- }
-
- private int ReportResults(int fileCount)
- {
- if (Success)
- {
- if (Warnings == 0)
- {
- AnsiConsole.MarkupLineInterpolated(
- $"\n\n[green]{(fileCount > 1 ? $"All {fileCount} files" : "File")} successfully processed.[/]");
- }
- else
- {
- AnsiConsole.MarkupLineInterpolated(
- $"[yellow]\n\n{(fileCount > 1 ? $"All {fileCount} files" : "File")} successfully processed with {Warnings} warning{(Warnings > 1 ? "s" : "")}.[/]");
- }
-
- return 0;
- }
- else
- {
- int failedFiles = Files.Count(f => !f.Success);
-
- if (failedFiles < fileCount)
- {
- if (Warnings == 0)
- {
- AnsiConsole.MarkupLineInterpolated(
- $"[green]\n\n{fileCount - failedFiles} file{(fileCount - failedFiles > 1 ? "s" : "")} successfully processed.[/]");
- }
- else
- {
- AnsiConsole.MarkupLineInterpolated(
- $"[yellow]\n\n{fileCount - failedFiles} file{(fileCount - failedFiles > 1 ? "s" : "")} successfully processed with {Warnings} warning{(Warnings > 1 ? "s" : "")}.[/]");
- }
-
- AnsiConsole.MarkupLineInterpolated(
- $"[maroon]Errors encountered for {failedFiles} file{(failedFiles > 1 ? "s" : "")}:[/]");
- }
- else
- {
- AnsiConsole.MarkupLine("[maroon]\n\nErrors encountered for all files:[/]");
- }
-
- foreach (var file in Files.Where(f => !f.Success))
- {
- AnsiConsole.MarkupLineInterpolated($"[magenta]{file.Path}:[/]");
- AnsiConsole.MarkupLineInterpolated($"[red] {file.Status}\n[/]");
- }
-
- return 1;
- }
- }
-
public void DisplayMessage(string message, MessageType type)
{
if (type.IsLog() && !Config.Verbose)
@@ -112,7 +31,7 @@ public void DisplayMessage(string message, MessageType type)
colour = Color.Yellow;
}
- AnsiConsole.Write(new Text($"{message}\n", new Style(foreground: colour)));
+ console.Write(new Text($"{message}\n", new Style(colour)));
}
public void SetStatus(string status, MessageType type)
@@ -150,13 +69,13 @@ public void SetStatus(string status, MessageType type, Exception ex)
if (Config.Verbose)
{
- AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
+ console.WriteException(ex, ExceptionFormats.ShortenEverything);
}
}
public int? SelectOption(string message, List options)
{
- var choice = AnsiConsole.Prompt(
+ var choice = console.Prompt(
new SelectionPrompt<(int?, string)>()
.Title($" [yellow]{Markup.Escape(message)}[/]")
.PageSize(10)
@@ -166,15 +85,121 @@ public void SetStatus(string status, MessageType type, Exception ex)
])
.UseConverter(o => $" {o.Item2}")
.WrapAround()
- .HighlightStyle(new Style(foreground: Color.Aqua))
+ .HighlightStyle(new Style(Color.Aqua))
);
return choice.Item1;
}
+ public void SetFilePath(string path)
+ {
+ }
+
+ public async Task RunAsync(IEnumerable entries)
+ {
+ Config = serviceProvider.GetRequiredService();
+ var movieProcessor = serviceProvider.GetRequiredKeyedService(Mode.Movie);
+ var tvProcessor = serviceProvider.GetRequiredKeyedService(Mode.TV);
+ var fileFinder = serviceProvider.GetRequiredService();
+
+ console.WriteLine($"AutoTag v{GetVersion()}");
+ console.MarkupLine("[link]https://jtattersall.net[/]");
+
+ Files = fileFinder.FindFilesToProcess(entries);
+
+ if (Files.Count == 0)
+ {
+ DisplayMessage("No files found", MessageType.Error);
+ return 1;
+ }
+
+ foreach (var file in Files)
+ {
+ CurrentFile = file;
+ console.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]");
+
+ Success &= (await ProcessWithFallbackAsync(file, movieProcessor, tvProcessor)).IsSuccess();
+ }
+
+ return ReportResults(Files.Count);
+ }
+
+ private int ReportResults(int fileCount)
+ {
+ if (Success)
+ {
+ if (Warnings == 0)
+ {
+ console.MarkupLineInterpolated(
+ $"\n\n[green]{(fileCount > 1 ? $"All {fileCount} files" : "File")} successfully processed.[/]");
+ }
+ else
+ {
+ console.MarkupLineInterpolated(
+ $"[yellow]\n\n{(fileCount > 1 ? $"All {fileCount} files" : "File")} successfully processed with {Warnings} warning{(Warnings > 1 ? "s" : "")}.[/]");
+ }
+
+ return 0;
+ }
+
+ var failedFiles = Files.Count(f => !f.Success);
+
+ if (failedFiles < fileCount)
+ {
+ if (Warnings == 0)
+ {
+ console.MarkupLineInterpolated(
+ $"[green]\n\n{fileCount - failedFiles} file{(fileCount - failedFiles > 1 ? "s" : "")} successfully processed.[/]");
+ }
+ else
+ {
+ console.MarkupLineInterpolated(
+ $"[yellow]\n\n{fileCount - failedFiles} file{(fileCount - failedFiles > 1 ? "s" : "")} successfully processed with {Warnings} warning{(Warnings > 1 ? "s" : "")}.[/]");
+ }
+
+ console.MarkupLineInterpolated(
+ $"[maroon]Errors encountered for {failedFiles} file{(failedFiles > 1 ? "s" : "")}:[/]");
+ }
+ else
+ {
+ console.MarkupLine("[maroon]\n\nErrors encountered for all files:[/]");
+ }
+
+ foreach (var file in Files.Where(f => !f.Success))
+ {
+ console.MarkupLineInterpolated($"[magenta]{file.Path}:[/]");
+ console.MarkupLineInterpolated($"[red] {file.Status}\n[/]");
+ }
+
+ return 1;
+ }
+
public static string GetVersion() => Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(3)!;
- public void SetFilePath(string path)
+ private async Task ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor,
+ IProcessor tvProcessor)
{
+ if (file is { TVDetails: null, MovieDetails: null })
+ {
+ DisplayMessage("Error: Unable to parse required information from filename", MessageType.Error);
+ return ProcessResult.ParseFailure;
+ }
+
+ if (file.TVDetails is not null)
+ {
+ var result = await tvProcessor.ProcessAsync(file);
+
+ if (result != ProcessResult.NotFound)
+ {
+ return result;
+ }
+ }
+
+ if (file.MovieDetails is not null)
+ {
+ return await movieProcessor.ProcessAsync(file);
+ }
+
+ return ProcessResult.Fail;
}
}
\ No newline at end of file
diff --git a/AutoTag.CLI/Keys.cs.template b/AutoTag.CLI/Keys.cs.template
deleted file mode 100644
index e9ef2d7..0000000
--- a/AutoTag.CLI/Keys.cs.template
+++ /dev/null
@@ -1,9 +0,0 @@
-/*
-Copy this file to "Keys.cs" and add your API key
-*/
-
-namespace AutoTag.CLI {
- public static class Keys {
- public const string TMDBKey = "TMDB_API_KEY";
- }
-}
diff --git a/AutoTag.CLI/RootCommand.cs b/AutoTag.CLI/RootCommand.cs
index 062a410..62b2dd8 100644
--- a/AutoTag.CLI/RootCommand.cs
+++ b/AutoTag.CLI/RootCommand.cs
@@ -7,44 +7,46 @@
namespace AutoTag.CLI;
-public class RootCommand : AsyncCommand
+public class RootCommand(IAnsiConsole console) : AsyncCommand
{
- public override async Task ExecuteAsync(CommandContext context, RootCommandSettings cmdSettings, CancellationToken cancellationToken)
+ private static readonly JsonSerializerOptions PrintJsonOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ protected override async Task ExecuteAsync(CommandContext context, RootCommandSettings cmdSettings,
+ CancellationToken cancellationToken)
{
if (cmdSettings.PrintVersion)
{
- AnsiConsole.WriteLine(CLIInterface.GetVersion());
+ console.WriteLine(CLIInterface.GetVersion());
return 0;
}
-
+
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { DisableDefaults = true });
- builder.Services.AddCoreServices(Keys.TMDBKey);
+ builder.Services.AddCoreServices(ThisAssembly.Constants.TMDBApiKey);
+ builder.Services.AddSingleton(console);
builder.Services.AddScoped();
using var host = builder.Build();
var configService = host.Services.GetRequiredService();
var config = await configService.LoadOrGenerateConfigAsync(cmdSettings.ConfigPath);
-
+
cmdSettings.UpdateConfig(config);
- if (cmdSettings.PrintConfig)
+ if (cmdSettings.SetDefault)
{
- AnsiConsole.Write(new JsonText(JsonSerializer.Serialize(config, PrintJsonOptions)));
- return 0;
+ await configService.SaveToDiskAsync();
}
- if (cmdSettings.SetDefault)
+ if (cmdSettings.PrintConfig)
{
- await configService.SaveToDiskAsync();
+ console.Write(new JsonText(JsonSerializer.Serialize(config, PrintJsonOptions)));
+ return 0;
}
var ui = (CLIInterface)host.Services.GetRequiredService();
return await ui.RunAsync(cmdSettings.Paths);
}
-
- private static readonly JsonSerializerOptions PrintJsonOptions = new()
- {
- WriteIndented = true
- };
}
\ No newline at end of file
diff --git a/AutoTag.CLI/Settings/RootCommandSettings.Rename.cs b/AutoTag.CLI/Settings/RootCommandSettings.Rename.cs
index 17236f1..eb16024 100644
--- a/AutoTag.CLI/Settings/RootCommandSettings.Rename.cs
+++ b/AutoTag.CLI/Settings/RootCommandSettings.Rename.cs
@@ -1,4 +1,5 @@
using AutoTag.Core.Config;
+using AutoTag.Core.Files;
namespace AutoTag.CLI.Settings;
diff --git a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs
index 49a47bc..e1bb927 100644
--- a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs
+++ b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs
@@ -4,6 +4,10 @@ namespace AutoTag.CLI.Settings;
public partial class RootCommandSettings
{
+ [CommandOption("-a|--auto")]
+ [Description("Auto tagging mode (auto select mode based on file name)")]
+ public bool AutoMode { get; init; }
+
[CommandOption("-t|--tv")]
[Description("TV tagging mode")]
public bool TVMode { get; init; }
@@ -36,12 +40,29 @@ public partial class RootCommandSettings
[Description("Metadata language (default: en)")]
public string? Language { get; init; }
+ [CommandOption("--search-language ")]
+ [Description("Additional languages to use when searching TMDB")]
+ public string[]? SearchLanguages { get; init; }
+
[CommandOption("-g|--episode-group")]
[Description("Manually choose alternate episode orderings for a TV show")]
public bool? EpisodeGroup { get; init; }
+ [CommandOption("--include-adult")]
+ [Description("Include adult titles in TMDB searches")]
+ public bool? IncludeAdult { get; init; }
+
+ [CommandOption("--remove-empty-folders")]
+ [Description("Remove source folders after moving files if they are empty")]
+ public bool? RemoveEmptyFolders { get; init; }
+
private void SetTaggingOptions(AutoTagConfig config)
{
+ if (AutoMode)
+ {
+ config.Mode = Mode.Auto;
+ }
+
if (TVMode)
{
config.Mode = Mode.TV;
@@ -82,9 +103,24 @@ private void SetTaggingOptions(AutoTagConfig config)
config.Language = Language;
}
+ if (SearchLanguages?.Length > 0)
+ {
+ config.SearchLanguages = SearchLanguages.ToList();
+ }
+
if (EpisodeGroup.HasValue)
{
config.EpisodeGroup = EpisodeGroup.Value;
}
+
+ if (IncludeAdult.HasValue)
+ {
+ config.IncludeAdult = IncludeAdult.Value;
+ }
+
+ if (RemoveEmptyFolders.HasValue)
+ {
+ config.RemoveEmptyFolders = RemoveEmptyFolders.Value;
+ }
}
}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/AutoTag.Core.Test.csproj b/AutoTag.Core.Test/AutoTag.Core.Test.csproj
index 5183796..0cf303a 100644
--- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj
+++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj
@@ -1,5 +1,4 @@
-
net10.0
enable
@@ -10,26 +9,30 @@
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
+
+
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ False
+ None
+
+
-
+
-
diff --git a/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs
new file mode 100644
index 0000000..7c50985
--- /dev/null
+++ b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs
@@ -0,0 +1,77 @@
+using AutoTag.Core.Config;
+using AutoTag.Core.Files.Parsing;
+using AutoTag.Core.Test.Helpers;
+
+namespace AutoTag.Core.Test.Files.FileFinder;
+
+public class FindFilesToProcess
+{
+ [Fact]
+ public void Should_FindCommonVideoContainers_AndOnlyTagKnownSafeFormats()
+ {
+ var (fs, root) = new MockFileSystemBuilder()
+ .WithFile("episode.AVI")
+ .WithFile("movie.mkv")
+ .WithFile("clip.mov")
+ .WithFile("notes.txt")
+ .WithDirectory("Nested", d => d.WithFile("nested.mp4"))
+ .Build();
+
+ var finder = new Core.Files.FileFinder(
+ new AutoTagConfig { RenameSubtitles = false },
+ fs,
+ new Mock().Object,
+ new Mock().Object
+ );
+
+ var result = finder.FindFilesToProcess([root]);
+
+ result.Should().ContainSingle(file => file.Path.EndsWith("episode.AVI") && !file.Taggable);
+ result.Should().ContainSingle(file => file.Path.EndsWith("movie.mkv") && file.Taggable);
+ result.Should().ContainSingle(file => file.Path.EndsWith("clip.mov") && !file.Taggable);
+ result.Should().ContainSingle(file => file.Path.EndsWith("nested.mp4") && file.Taggable);
+ result.Should().NotContain(file => file.Path.EndsWith("notes.txt"));
+ }
+
+ [Fact]
+ public void Should_GroupVideoAndSubtitlesWithSameParsedDetails()
+ {
+ var (fs, root) = new MockFileSystemBuilder()
+ .WithFile("Title S01E02.mkv")
+ .WithFile("Title S01E02 ENG.ass")
+ .WithFile("Title S01E02.ass")
+ .WithDirectory("Movie", d => d.WithFile("Title.mp4").WithFile("Title.srt"))
+ .WithFile("Unknown1.mp4")
+ .WithFile("Unknown2.srt")
+ .Build();
+
+ var mockParser = new Mock();
+ mockParser.Setup(m => m.ParseFileName(It.Is(s => s.Contains("Title S01E02"))))
+ .Returns((new ParsedTVFileName("Title", null, 1, 2, null, null), new ParsedMovieFileName("Title", null)));
+
+ mockParser.Setup(m => m.ParseFileName(It.Is(s => s.Contains("Title."))))
+ .Returns((null, new ParsedMovieFileName("Title", null)));
+
+ mockParser.Setup(m => m.ParseFileName(It.Is(s => s.Contains("Unknown"))))
+ .Returns((null, null));
+
+ var finder = new Core.Files.FileFinder(
+ new AutoTagConfig { RenameSubtitles = true },
+ fs,
+ new Mock().Object,
+ mockParser.Object
+ );
+
+ var result = finder.FindFilesToProcess([root]);
+
+ result.Should().ContainSingle(f => f.Path.EndsWith("Title S01E02.mkv")
+ && f.SubtitlePaths.Any(s => s.EndsWith("Title S01E02 ENG.ass"))
+ && f.SubtitlePaths.Any(s => s.EndsWith("Title S01E02.ass")));
+
+ result.Should().ContainSingle(f => f.Path.EndsWith("Title.mp4")
+ && f.SubtitlePaths.Any(s => s.EndsWith("Title.srt")));
+
+ result.Should().ContainSingle(f => f.Path.EndsWith("Unknown1.mp4") && f.SubtitlePaths.Count == 0);
+ result.Should().ContainSingle(f => f.Path.EndsWith("Unknown2.srt") && f.SubtitlePaths.Count == 0);
+ }
+}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/Files/FileNamer/GetNewFileName.cs b/AutoTag.Core.Test/Files/FileNamer/GetNewFileName.cs
new file mode 100644
index 0000000..b77eb5f
--- /dev/null
+++ b/AutoTag.Core.Test/Files/FileNamer/GetNewFileName.cs
@@ -0,0 +1,250 @@
+using AutoTag.Core.Config;
+using AutoTag.Core.Files;
+using AutoTag.Core.Movie;
+using AutoTag.Core.TV;
+
+namespace AutoTag.Core.Test.Files.FileNamer;
+
+public class GetNewFileName
+{
+ [Fact]
+ public void Should_ReplaceLegacySpecifiersInTVPattern_WithoutFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "%1 S%2E%3 %4"
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Show Name",
+ Season = 3,
+ Episode = 15,
+ Title = "Episode Title"
+ },
+ "Show Name S3E15 Episode Title",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceLegacySpecifiersInTVPattern_WithFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "%1 S%2:00E%3:000 %4"
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Show Name",
+ Season = 3,
+ Episode = 15,
+ Title = "Episode Title"
+ },
+ "Show Name S03E015 Episode Title",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceSpecifiersInTVPattern_WithoutFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "{Series} S{Season}E{Episode} {Title}"
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Show Name",
+ Season = 12,
+ Episode = 1,
+ Title = "Episode Title"
+ },
+ "Show Name S12E1 Episode Title",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceSpecifiersInTVPattern_WithFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "{Series} {Season:S00|Specials}{Episode:E000} {Title}"
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Show Name",
+ Season = 5,
+ Episode = 4,
+ Title = "Episode Title"
+ },
+ "Show Name S05E004 Episode Title",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceLegacySpecifiersInMoviePattern_WithoutFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ MovieRenamePattern = "%1 (%2)"
+ },
+ new MovieFileMetadata
+ {
+ Title = "Movie Name",
+ Date = new DateTime(1999, 06, 07)
+ },
+ "Movie Name (1999)",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceLegacySpecifiersInMoviePattern_WithFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ MovieRenamePattern = "%1 (%2:00000)"
+ },
+ new MovieFileMetadata
+ {
+ Title = "Movie Name",
+ Date = new DateTime(1999, 06, 07)
+ },
+ "Movie Name (01999)",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceSpecifiersInMoviePattern_WithoutFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ MovieRenamePattern = "{Title} ({Year})"
+ },
+ new MovieFileMetadata
+ {
+ Title = "Movie Name",
+ Date = new DateTime(1999, 06, 07)
+ },
+ "Movie Name (1999)",
+ false
+ );
+
+ [Fact]
+ public void Should_ReplaceSpecifiersInMoviePattern_WithFormats()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ MovieRenamePattern = "{Title} {Year:(0000)}"
+ },
+ new MovieFileMetadata
+ {
+ Title = "Movie Name",
+ Date = new DateTime(1999, 06, 07)
+ },
+ "Movie Name (1999)",
+ false
+ );
+
+ [Fact]
+ public void Should_UseAlternativeValue_WhenValueIs0()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "{Series} {Season:S00|Specials }{Episode:E000} {Title}"
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Show Name",
+ Season = 0,
+ Episode = 4,
+ Title = "Episode Title"
+ },
+ "Show Name Specials E004 Episode Title",
+ false
+ );
+
+ [Fact]
+ public void Should_UseAlternative_WhenValueIsNull()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ MovieRenamePattern = "{Title}{Year: 0000|}" // empty alternative value
+ },
+ new MovieFileMetadata
+ {
+ Title = "Movie Name",
+ Date = null
+ },
+ "Movie Name",
+ false
+ );
+
+ [Fact]
+ public void Should_ApplyFileNameReplacesToFieldValues()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "Test {Series} {Season}x{Episode:00} {Title}",
+ FileNameReplaces =
+ [
+ new FileNameReplace("replace1", "replacement1"),
+ new FileNameReplace("Test ", "")
+ ]
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Name of replace1 show",
+ Season = 0,
+ Episode = 4,
+ Title = "Test Episode Title"
+ },
+ "Test Name of replacement1 show 0x04 Episode Title",
+ false
+ );
+
+ [Fact]
+ public void Should_RemoveInvalidFileNameCharactersFromFieldValues()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "/TV/{Series}/{Series} {Season:S00}{Episode:E00} {Title}"
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "Show/Name",
+ Season = 8,
+ Episode = 2,
+ Title = "Episode Title/"
+ },
+ "/TV/ShowName/ShowName S08E02 Episode Title",
+ true
+ );
+
+ [Fact]
+ public void Should_RemoveInvalidNTFSFileNameCharactersFromFieldValues()
+ => TestGetNewFileName(
+ new AutoTagConfig
+ {
+ TVRenamePattern = "/TV/{Series}/{Series} {Season:S00}{Episode:E00} {Title}",
+ WindowsSafe = true
+ },
+ new TVFileMetadata
+ {
+ SeriesName = "ShowName",
+ Season = 8,
+ Episode = 2,
+ Title = "Episode Title/: Test"
+ },
+ "/TV/ShowName/ShowName S08E02 Episode Title Test",
+ true
+ );
+
+ private static void TestGetNewFileName(AutoTagConfig config, FileMetadata metadata, string expectedResult,
+ bool expectedReplacedInvalid)
+ {
+ var namer = new Core.Files.FileNamer(config);
+
+ var (result, replacedInvalid) = namer.GetNewFileName(metadata);
+
+ result.Should().Be(expectedResult);
+ replacedInvalid.Should().Be(expectedReplacedInvalid);
+ }
+}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs
new file mode 100644
index 0000000..8b3b5da
--- /dev/null
+++ b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs
@@ -0,0 +1,253 @@
+using AutoTag.Core.Config;
+using AutoTag.Core.Files;
+using AutoTag.Core.Movie;
+using AutoTag.Core.Test.Helpers;
+using AutoTag.Core.TV;
+
+namespace AutoTag.Core.Test.Files.FileWriter;
+
+public class WriteAsync
+{
+ private static Core.Files.FileWriter GetInstance(ICoverArtFetcher? fetcher = null, AutoTagConfig? config = null,
+ IFileSystem? fs = null, IUserInterface? ui = null, IFileNamer? namer = null)
+ => new(
+ fetcher.OrDefaultMock(),
+ config.OrDefaultMock(),
+ fs.OrDefaultMock(),
+ ui.OrDefaultMock(),
+ namer ?? new Core.Files.FileNamer(config.OrDefaultMock())
+ );
+
+ private static string GetPath(params string[] segments) =>
+ Path.Combine([OperatingSystem.IsWindows() ? @"C:\" : "/", ..segments]);
+
+ [Fact]
+ public async Task Should_SkipRename_WhenVideoAndSubtitleAreAlreadyCorrectlyNamed()
+ {
+ var config = new AutoTagConfig
+ {
+ RenameFiles = true,
+ TagFiles = false
+ };
+
+ var mockFs = new Mock();
+ mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny()))
+ .Returns(GetPath());
+
+ var mockUi = new Mock();
+
+ var writer = GetInstance(config: config, fs: mockFs.Object, ui: mockUi.Object);
+ var taggingFile = new TaggingFile
+ {
+ Path = GetPath("Movie (2020).mkv"),
+ SubtitlePaths = [GetPath("Movie (2020).srt")]
+ };
+
+ var metadata = new MovieFileMetadata
+ {
+ Title = "Movie",
+ Date = new DateTime(2020, 1, 1)
+ };
+
+ var result = await writer.WriteAsync(taggingFile, metadata);
+
+ result.Should().BeTrue();
+ mockFs.Verify(fs => fs.Move(It.IsAny(), It.IsAny()), Times.Never);
+ mockUi.Verify(ui => ui.SetStatus("Rename skipped - already named correctly", MessageType.Information),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Should_NotSkip_WhenSubtitleNameIsWrong()
+ {
+ var config = new AutoTagConfig
+ {
+ RenameFiles = true,
+ TagFiles = false
+ };
+
+ var mockFs = new Mock();
+
+ var inPath = GetPath("subtitle.srt");
+ var outPath = GetPath("Movie (2020).srt");
+
+ mockFs.Setup(fs => fs.Exists(outPath))
+ .Returns(false);
+ mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny()))
+ .Returns(GetPath());
+
+ var mockUi = new Mock();
+
+ var writer = GetInstance(config: config, fs: mockFs.Object, ui: mockUi.Object);
+ var taggingFile = new TaggingFile
+ {
+ Path = GetPath("Movie (2020).mkv"),
+ SubtitlePaths = [GetPath(inPath)]
+ };
+
+ var metadata = new MovieFileMetadata
+ {
+ Title = "Movie",
+ Date = new DateTime(2020, 1, 1)
+ };
+
+ var result = await writer.WriteAsync(taggingFile, metadata);
+
+ result.Should().BeTrue();
+ mockFs.Verify(fs => fs.Move(inPath, outPath), Times.Once);
+ mockUi.Verify(ui => ui.SetStatus("File skipped - already named correctly", MessageType.Information),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task Should_TagFile_WhenRenameIsSkipped()
+ {
+ var config = new AutoTagConfig
+ {
+ RenameFiles = false,
+ TagFiles = true
+ };
+ var mockUi = new Mock();
+ var writer = GetInstance(config: config, ui: mockUi.Object);
+ var metadata = new MovieFileMetadata
+ {
+ Title = "Movie",
+ Date = new DateTime(2020, 1, 1)
+ };
+
+ var result = await writer.WriteAsync(new TaggingFile { Path = GetPath("Movie (2020).mkv") }, metadata);
+
+ result.Should().BeFalse();
+ mockUi.Verify(
+ ui => ui.SetStatus("Error: Failed to write tags to file", MessageType.Error, It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Should_RemoveSourceFolder_WhenSourceFolderIsEmptyAndAbsolutePatternUsed()
+ {
+ var config = new AutoTagConfig
+ {
+ MovieRenamePattern = GetPath("Movies", "{Title} ({Year})"),
+ RemoveEmptyFolders = true,
+ RenameFiles = true,
+ TagFiles = false
+ };
+
+ var mockFs = new Mock();
+ mockFs.Setup(fs => fs.Exists(GetPath("Movies", "Movie (2020).mkv")))
+ .Returns(false);
+ mockFs.Setup(fs => fs.DirectoryExists(GetPath("Downloads")))
+ .Returns(true);
+ mockFs.Setup(fs => fs.DirectoryIsEmpty(GetPath("Downloads")))
+ .Returns(true);
+ mockFs.Setup(fs => fs.GetDirectoryPath(It.Is((string s) => s.Contains("Downloads"))))
+ .Returns(GetPath("Downloads"));
+ mockFs.Setup(fs => fs.GetDirectoryPath(It.Is((string s) => s.Contains("Movies"))))
+ .Returns(GetPath("Movies"));
+ mockFs.Setup(fs => fs.PathContainsDirectory(It.IsAny())).Returns(true);
+
+ var downloads = GetPath("Downloads");
+
+ var writer = GetInstance(config: config, fs: mockFs.Object);
+ var taggingFile = new TaggingFile
+ {
+ Path = GetPath("Downloads", "raw.mkv")
+ };
+ var metadata = new MovieFileMetadata
+ {
+ Title = "Movie",
+ Date = new DateTime(2020, 1, 1)
+ };
+
+ var result = await writer.WriteAsync(taggingFile, metadata);
+
+ result.Should().BeTrue();
+ mockFs.Verify(fs => fs.DeleteDirectory(downloads), Times.Once);
+ }
+
+ [Fact]
+ public async Task Should_NotRemoveSourceFolder_WhenNotEmpty()
+ {
+ var config = new AutoTagConfig
+ {
+ MovieRenamePattern = GetPath("Movies", "{Title} ({Year})"),
+ RemoveEmptyFolders = true,
+ RenameFiles = true,
+ TagFiles = false
+ };
+
+ var mockFs = new Mock();
+ mockFs.Setup(fs => fs.Exists(GetPath("Movies", "Movie (2020).mkv")))
+ .Returns(false);
+ mockFs.Setup(fs => fs.DirectoryExists("Downloads"))
+ .Returns(true);
+ mockFs.Setup(fs => fs.DirectoryIsEmpty("Downloads"))
+ .Returns(false);
+ mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny()))
+ .Returns(GetPath());
+ mockFs.Setup(fs => fs.PathContainsDirectory(It.IsAny())).Returns(true);
+
+ var writer = GetInstance(config: config, fs: mockFs.Object);
+ var taggingFile = new TaggingFile
+ {
+ Path = GetPath("Downloads", "raw.mkv")
+ };
+ var metadata = new MovieFileMetadata
+ {
+ Title = "Movie",
+ Date = new DateTime(2020, 1, 1)
+ };
+
+ var result = await writer.WriteAsync(taggingFile, metadata);
+
+ result.Should().BeTrue();
+ mockFs.Verify(fs => fs.DeleteDirectory(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Should_RenameMultipleSubtitlesWithNumberedSuffixes()
+ {
+ var config = new AutoTagConfig
+ {
+ RenameFiles = true,
+ TagFiles = false
+ };
+
+ var mockFs = new Mock();
+ mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny()))
+ .Returns(GetPath());
+
+ var writer = GetInstance(config: config, fs: mockFs.Object);
+
+ var sub1Path = GetPath("sub-one.ass");
+ var sub2Path = GetPath("sub-two.ass");
+ var taggingFile = new TaggingFile
+ {
+ Path = GetPath("raw.mkv"),
+ SubtitlePaths = [sub1Path, sub2Path]
+ };
+ var metadata = new TVFileMetadata
+ {
+ SeriesName = "Series",
+ Season = 1,
+ Episode = 1,
+ Title = "Pilot"
+ };
+
+ var result = await writer.WriteAsync(taggingFile, metadata);
+
+ var sub1OutPath = GetPath("Series - 1x01 - Pilot.1.ass");
+ var sub2OutPath = GetPath("Series - 1x01 - Pilot.2.ass");
+
+ result.Should().BeTrue();
+ mockFs.Verify(fs => fs.Move(
+ sub1Path,
+ sub1OutPath
+ ), Times.Once);
+ mockFs.Verify(fs => fs.Move(
+ sub2Path,
+ sub2OutPath
+ ), Times.Once);
+ }
+}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs b/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs
new file mode 100644
index 0000000..ef5af73
--- /dev/null
+++ b/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs
@@ -0,0 +1,106 @@
+using AutoTag.Core.Config;
+using AutoTag.Core.Files.Parsing;
+using AutoTag.Core.Test.Helpers;
+
+namespace AutoTag.Core.Test.Files.TVFileNameParser;
+
+public class TryParse
+{
+ private static Core.Files.Parsing.TVFileNameParser GetInstance(AutoTagConfig? config = null)
+ => new(config.OrDefaultMock());
+
+ [Theory]
+ [InlineData("Fringe 3x03.mkv", "Fringe", null, 3, 3, null, null)]
+ [InlineData("Silo S01E10.mkv", "Silo", null, 1, 10, null, null)]
+ [InlineData("Inside.No.9.S09E09.mkv", "Inside No 9", null, 9, 9, null,
+ null)] // dot separated and number in series name
+ [InlineData("the_last_of_us_s01e07.mp4", "The Last of Us", null, 1, 7, null,
+ null)] // underscore separated + lowercase
+ [InlineData("Alias.S02E01.The.Enemy.Walks.In.1080p.AMZN.WEB-DL.DDP5.1.H.264-LycanHD.mkv", "Alias", null, 2,
+ 1, null, null)] // scene tags
+ [InlineData("Warehouse 13 0x01 Episode Title.mp4", "Warehouse 13", null, 0, 1, null, null)] // season 0
+ [InlineData("Doctor Who (2005) - 2x13 - Doomsday (2).mkv", "Doctor Who", 2005, 2, 13, null,
+ null)] // symbols in series name
+ [InlineData("Serial Experiments Lain E12 Landscape 1080p BluRay FLAC 2.0 x264-Chotab.mkv",
+ "Serial Experiments Lain", null, null, 12, null, null)] // absolute episode
+ [InlineData("Fallout.2024.S02E06.1080p.WEB.h264-ETHEL[EZTVx.to].mkv", "Fallout", 2024, 2, 6, null, null)] // year
+ [InlineData("Absolute.Episode.Show.001.mkv", "Absolute Episode Show", null, null, 1, null,
+ null)] // absolute numbering with no delimiter
+ [InlineData("Beavis and Butt-Head - 2x10-11 - Way Down Mexico Way.mkv", "Beavis and Butt Head", null, 2, 10, 11,
+ null)] // multi-episode file
+ [InlineData("Test.S02E08-E09.mkv", "Test", null, 2, 8, 9, null)] // multi-episode file
+ [InlineData("Lost - 1x24 - Exodus (2) - pt1.mkv", "Lost", null, 1, 24, null, 1)] // part file
+ [InlineData("Absolute.Episode.Show.002-003.pt4.mkv", "Absolute Episode Show", null, null, 2, 3,
+ 4)] // absolute numbering with end episode and part
+ [InlineData("[Group] Series Dublado (35).AVI", "Series", null, null, 35, null, null)]
+ public void Should_ParseCommonNamingFormats(string fileName, string seriesName, int? year, int? season, int episode,
+ int? endEpisode, int? part)
+ {
+ var parser = GetInstance();
+
+ var success = parser.TryParse(fileName, out var result);
+
+ success.Should().BeTrue();
+ result.Should().BeEquivalentTo(
+ new ParsedTVFileName(seriesName, year, season, episode, endEpisode, part),
+ o => o.Using(StringComparer.OrdinalIgnoreCase)
+ );
+ }
+
+ [Fact]
+ public void Should_RemoveSceneTagsAndLanguageTermsFromTitle()
+ {
+ var parser = GetInstance();
+
+ var success = parser.TryParse("[Scene] [Tag] Subbed Legendado ENG Show Name S06E27 [Tag].mp4", out var result);
+
+ success.Should().BeTrue();
+ result.Should().BeEquivalentTo(
+ new ParsedTVFileName("Show Name", null, 6, 27, null, null),
+ o => o.Using(StringComparer.OrdinalIgnoreCase)
+ );
+ }
+
+ [Fact]
+ public void Should_ParseFromFullPath_When_ParsePatternProvided()
+ {
+ var config = new AutoTagConfig
+ {
+ ParsePattern = @".*/(?.+)/Season (?\d+)/S\d+E(?\d+)"
+ };
+
+ var parser = GetInstance(config);
+
+ var success = parser.TryParse("/test/a/b/Series Name/Season 2/S02E05 Title.mkv", out var result);
+
+ success.Should().BeTrue();
+ result.Should().BeEquivalentTo(new ParsedTVFileName("Series Name", null, 2, 5, null, null));
+ }
+
+ [Theory]
+ [InlineData("S01E05.mkv")]
+ [InlineData("28 Years Later 2025 1080p MA WEB-DL.mkv")]
+ [InlineData("25.mp4")]
+ public void Should_ReturnFalse_When_PartMissingFromFileName(string fileName)
+ {
+ var parser = GetInstance();
+
+ var success = parser.TryParse(fileName, out _);
+
+ success.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Should_ReturnFalse_When_ParsePatternDoesNotMatch()
+ {
+ var config = new AutoTagConfig
+ {
+ ParsePattern = @".*/(?.+)/Season (?\d+)/S\d+E(?\d+)"
+ };
+
+ var parser = GetInstance(config);
+
+ var success = parser.TryParse("/test/a/b/Series Name/S02/Series Name 2x05 Title.mkv", out _);
+ success.Should().BeFalse();
+ }
+}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/GlobalUsing.cs b/AutoTag.Core.Test/GlobalUsing.cs
index adda2d0..6c307ea 100644
--- a/AutoTag.Core.Test/GlobalUsing.cs
+++ b/AutoTag.Core.Test/GlobalUsing.cs
@@ -1,2 +1,2 @@
-global using FluentAssertions;
+global using AwesomeAssertions;
global using Moq;
\ No newline at end of file
diff --git a/AutoTag.Core.Test/Helpers/MockFileSystemBuilder.cs b/AutoTag.Core.Test/Helpers/MockFileSystemBuilder.cs
new file mode 100644
index 0000000..59f54e8
--- /dev/null
+++ b/AutoTag.Core.Test/Helpers/MockFileSystemBuilder.cs
@@ -0,0 +1,73 @@
+using AutoTag.Core.Files;
+using IOPath = System.IO.Path;
+
+namespace AutoTag.Core.Test.Helpers;
+
+public class MockFileSystemBuilder
+{
+ private readonly List _directories = [];
+ private readonly List _files = [];
+
+ private MockFileSystemBuilder(string path)
+ {
+ Path = path;
+ }
+
+ public MockFileSystemBuilder()
+ {
+ Path = OperatingSystem.IsWindows() ? @"C:\" : "/";
+ }
+
+ private string Path { get; }
+
+ public MockFileSystemBuilder WithFile(string name)
+ {
+ _files.Add(IOPath.Combine(Path, name));
+
+ return this;
+ }
+
+ public MockFileSystemBuilder WithDirectory(string name, Action build)
+ {
+ var directory = new MockFileSystemBuilder(IOPath.Combine(Path, name));
+ build(directory);
+
+ _directories.Add(directory);
+
+ return this;
+ }
+
+ private void SetupMock(Mock mock)
+ {
+ mock.Setup(fs => fs.Exists(It.Is(f => f.FullName == Path)))
+ .Returns(true);
+
+ foreach (var file in _files)
+ {
+ mock.Setup(fs => fs.Exists(It.Is(f => f.FullName == file)))
+ .Returns(true);
+ }
+
+ var entries = _files.Select(FileSystemInfo (f) => new FileInfo(f))
+ .Concat(_directories.Select(FileSystemInfo (d) => new DirectoryInfo(d.Path)))
+ .ToList();
+
+ mock.Setup(fs => fs.GetDirectoryContents(It.Is(d => d.FullName == Path)))
+ .Returns(entries);
+
+
+ foreach (var directory in _directories)
+ {
+ directory.SetupMock(mock);
+ }
+ }
+
+ public (IFileSystem FileSystem, FileSystemInfo Root) Build()
+ {
+ var mock = new Mock();
+
+ SetupMock(mock);
+
+ return (mock.Object, new DirectoryInfo(Path));
+ }
+}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs
index a78e370..f4ae641 100644
--- a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs
+++ b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs
@@ -1,4 +1,5 @@
using AutoTag.Core.Config;
+using AutoTag.Core.Files.Parsing;
using AutoTag.Core.TMDB;
using AutoTag.Core.TV;
using TMDbLib.Objects.General;
@@ -9,6 +10,9 @@ namespace AutoTag.Core.Test.TV.TVProcessor;
public class FindEpisodeAsync : TVProcessorTestBase
{
+ private static ParsedTVFileName GetParsedFileName(int? season, int episode) =>
+ new("", null, season, episode, null, null);
+
[Fact]
public async Task Should_SetEpisodeDetails_OnResult()
{
@@ -46,26 +50,27 @@ public async Task Should_SetEpisodeDetails_OnResult()
Name = "Show Name",
GenreIds = [1]
});
-
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 2
- };
- var instance = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object);
- var (result, _) = await instance.FindEpisodeAsync(metadata, show, true);
+ var instance = GetInstance(mockTmdb.Object, cache: mockCache.Object);
+
+ var (result, metadata, _) =
+ await instance.FindEpisodeAsync(GetParsedFileName(1, 2) with { EndEpisode = 3, Part = 2 }, show, true);
result.Should().Be(FindResult.Success);
+ metadata.Should().NotBeNull();
metadata.Id.Should().Be(1);
metadata.SeriesName.Should().Be("Show Name");
+ metadata.Season.Should().Be(1);
+ metadata.Episode.Should().Be(2);
+ metadata.EndEpisode.Should().Be(3);
metadata.SeasonEpisodes.Should().Be(2);
metadata.CoverURL.Should().Be("https://image.tmdb.org/t/p/original/poster");
metadata.Title.Should().Be("S01E02");
metadata.Overview.Should().Be("Episode two");
metadata.Genres.Should().BeEquivalentTo("Genre 1");
+ metadata.Part.Should().Be(2);
}
[Fact]
@@ -75,7 +80,7 @@ public async Task Should_SetExtendedTags_WhenEnabled()
{
ExtendedTagging = true
};
-
+
var mockCache = new Mock();
var season = new TvSeason
@@ -87,7 +92,8 @@ public async Task Should_SetExtendedTags_WhenEnabled()
Name = "S01E01",
EpisodeNumber = 1,
Overview = "Episode one",
- Crew = [
+ Crew =
+ [
new Crew
{
Name = "Director Person",
@@ -119,24 +125,18 @@ public async Task Should_SetExtendedTags_WhenEnabled()
}
]
});
-
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 1
- };
-
var show = new ShowResults(new SearchTv
{
Id = 1,
Name = "Show Name"
});
- var instance = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object, config: config);
+ var instance = GetInstance(mockTmdb.Object, cache: mockCache.Object, config: config);
- await instance.FindEpisodeAsync(metadata, show, true);
+ var (_, metadata, _) = await instance.FindEpisodeAsync(GetParsedFileName(1, 1), show, true);
+ metadata.Should().NotBeNull();
metadata.Director.Should().Be("Director Person");
metadata.Actors.Should().BeEquivalentTo("Actor Person", "Second Actor Person");
metadata.Characters.Should().BeEquivalentTo("Character Person", "Second Character Person");
@@ -151,7 +151,7 @@ public async Task Should_NotSetExtendedTags_WhenDisabledOrNotTaggable(bool exten
{
ExtendedTagging = extendedTagging
};
-
+
var mockCache = new Mock();
var season = new TvSeason
@@ -163,7 +163,8 @@ public async Task Should_NotSetExtendedTags_WhenDisabledOrNotTaggable(bool exten
Name = "S01E01",
EpisodeNumber = 1,
Overview = "Episode one",
- Crew = [
+ Crew =
+ [
new Crew
{
Name = "A Person",
@@ -177,18 +178,13 @@ public async Task Should_NotSetExtendedTags_WhenDisabledOrNotTaggable(bool exten
mockCache.Setup(c => c.TryGetSeason(It.IsAny(), It.IsAny(), out season))
.Returns(true);
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 1
- };
-
var show = new ShowResults(new SearchTv());
var instance = GetInstance(cache: mockCache.Object, config: config);
- await instance.FindEpisodeAsync(metadata, show, taggable);
+ var (_, metadata, _) = await instance.FindEpisodeAsync(GetParsedFileName(1, 1), show, taggable);
+ metadata.Should().NotBeNull();
metadata.Director.Should().BeNull();
metadata.Actors.Should().BeNull();
metadata.Characters.Should().BeNull();
@@ -205,32 +201,98 @@ public async Task Should_AddSeasonToCache_When_Found()
var mockTmdb = new Mock();
var seasonResult = new TvSeason
{
- Episodes = [new TvSeasonEpisode
- {
- EpisodeNumber = 1
- }]
+ Episodes =
+ [
+ new TvSeasonEpisode
+ {
+ EpisodeNumber = 1
+ }
+ ]
};
mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(seasonResult);
-
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 1
- };
+
var show = new ShowResults(new SearchTv
{
Id = 1
});
- var instance = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object);
+ var instance = GetInstance(mockTmdb.Object, cache: mockCache.Object);
- var (result, _) = await instance.FindEpisodeAsync(metadata, show, true);
+ var (result, _, _) = await instance.FindEpisodeAsync(GetParsedFileName(1, 1), show, true);
result.Should().Be(FindResult.Success);
mockCache.Verify(c => c.AddSeason(1, 1, seasonResult));
}
+ [Fact]
+ public async Task Should_MapAbsoluteEpisodeToSeasonAndEpisode()
+ {
+ var mockCache = new Mock();
+ var cachedSeasons = new Dictionary<(int ShowId, int SeasonNumber), TvSeason>();
+ mockCache.Setup(c => c.TryGetSeason(It.IsAny(), It.IsAny(), out It.Ref.IsAny))
+ .Returns((int showId, int seasonNumber, out TvSeason? season) =>
+ {
+ var found = cachedSeasons.TryGetValue((showId, seasonNumber), out var cachedSeason);
+ season = cachedSeason;
+ return found;
+ });
+ mockCache.Setup(c => c.AddSeason(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((showId, seasonNumber, season) =>
+ {
+ cachedSeasons[(showId, seasonNumber)] = season;
+ });
+
+ var mockTmdb = new Mock();
+ mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(1, 1))
+ .ReturnsAsync(new TvSeason
+ {
+ SeasonNumber = 1,
+ Episodes =
+ [
+ new TvSeasonEpisode { EpisodeNumber = 1 },
+ new TvSeasonEpisode { EpisodeNumber = 2 },
+ new TvSeasonEpisode { EpisodeNumber = 3 }
+ ]
+ });
+ mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(1, 2))
+ .ReturnsAsync(new TvSeason
+ {
+ SeasonNumber = 2,
+ Episodes =
+ [
+ new TvSeasonEpisode
+ {
+ EpisodeNumber = 1,
+ Name = "Episode 4",
+ Overview = "Fourth episode"
+ },
+ new TvSeasonEpisode { EpisodeNumber = 2 }
+ ]
+ });
+ mockTmdb.Setup(tmdb => tmdb.GetTvGenreNamesAsync(It.IsAny>()))
+ .ReturnsAsync([]);
+ mockTmdb.Setup(tmdb => tmdb.GetTvShowAsync(It.IsAny()))
+ .ReturnsAsync(new TvShow { Id = 1, NumberOfSeasons = 4 });
+
+ var show = new ShowResults(new SearchTv
+ {
+ Id = 1,
+ Name = "Series"
+ });
+
+ var instance = GetInstance(mockTmdb.Object, cache: mockCache.Object);
+
+ var (result, metadata, _) =
+ await instance.FindEpisodeAsync(GetParsedFileName(null, 4) with { SeriesName = "Series" }, show, true);
+
+ result.Should().Be(FindResult.Success);
+ metadata.Should().NotBeNull();
+ metadata.Season.Should().Be(2);
+ metadata.Episode.Should().Be(1);
+ metadata.Title.Should().Be("Episode 4");
+ }
+
[Fact]
public async Task Should_ReturnSkipWithErrorMessage_When_SeasonNotFound()
{
@@ -241,21 +303,18 @@ public async Task Should_ReturnSkipWithErrorMessage_When_SeasonNotFound()
var mockTmdb = new Mock();
mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync((TvSeason?) null);
-
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 1
- };
+ .ReturnsAsync((TvSeason?)null);
+
+ var parsedDetails = GetParsedFileName(1, 1);
+
var show = new ShowResults(new SearchTv());
- var instance = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object);
+ var instance = GetInstance(mockTmdb.Object, cache: mockCache.Object);
- var (result, msg) = await instance.FindEpisodeAsync(metadata, show, true);
+ var (result, _, msg) = await instance.FindEpisodeAsync(parsedDetails, show, true);
result.Should().Be(FindResult.Skip);
- msg.Should().Be($"Error: Cannot find {metadata} on TheMovieDB");
+ msg.Should().Be($"Error: Cannot find {parsedDetails} on TheMovieDB");
}
[Fact]
@@ -264,29 +323,29 @@ public async Task Should_ReturnSkipWithErrorMessage_When_EpisodeNotFound()
var mockCache = new Mock();
var cachedSeason = new TvSeason
{
- Episodes = [new TvSeasonEpisode
- {
- EpisodeNumber = 20
- }]
+ Episodes =
+ [
+ new TvSeasonEpisode
+ {
+ EpisodeNumber = 20
+ }
+ ]
};
mockCache.Setup(c => c.TryGetSeason(It.IsAny(), It.IsAny(), out cachedSeason))
.Returns(true);
-
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 1
- };
+
+ var parsedDetails = GetParsedFileName(1, 1);
+
var show = new ShowResults(new SearchTv());
var instance = GetInstance(cache: mockCache.Object);
- var (result, msg) = await instance.FindEpisodeAsync(metadata, show, true);
-
+ var (result, _, msg) = await instance.FindEpisodeAsync(parsedDetails, show, true);
+
result.Should().Be(FindResult.Skip);
- msg.Should().Be($"Error: Cannot find {metadata} on TheMovieDB");
+ msg.Should().Be($"Error: Cannot find {parsedDetails} on TheMovieDB");
}
-
+
[Fact]
public async Task Should_UseEpisodeGroupMapping_WhenAvailable()
{
@@ -305,7 +364,7 @@ public async Task Should_UseEpisodeGroupMapping_WhenAvailable()
};
mockCache.Setup(c => c.TryGetSeason(It.IsAny(), It.IsAny(), out season))
.Returns(true);
-
+
var show = new ShowResults(new SearchTv());
show.AddEpisodeGroup(
new TvGroupCollection
@@ -330,17 +389,13 @@ public async Task Should_UseEpisodeGroupMapping_WhenAvailable()
out _
);
- var metadata = new TVFileMetadata
- {
- Season = 1,
- Episode = 1
- };
var instance = GetInstance(cache: mockCache.Object);
- var (result, _) = await instance.FindEpisodeAsync(metadata, show, true);
+ var (result, metadata, _) = await instance.FindEpisodeAsync(GetParsedFileName(1, 1), show, true);
result.Should().Be(FindResult.Success);
+ metadata.Should().NotBeNull();
metadata.Season.Should().Be(1);
metadata.Episode.Should().Be(1);
metadata.Title.Should().Be("S01E02");
@@ -350,7 +405,7 @@ out _
public async Task Should_ReportError_When_EpisodeNotFoundInMapping()
{
var mockUi = new Mock();
-
+
var show = new ShowResults(new SearchTv());
show.AddEpisodeGroup(
new TvGroupCollection
@@ -374,20 +429,16 @@ public async Task Should_ReportError_When_EpisodeNotFoundInMapping()
},
out _
);
-
- var metadata = new TVFileMetadata
- {
- SeriesName = "Show",
- Season = 10,
- Episode = 1
- };
+
+ var parsedDetails = GetParsedFileName(10, 1) with { SeriesName = "Show" };
var instance = GetInstance(ui: mockUi.Object);
- var (result, _) = await instance.FindEpisodeAsync(metadata, show, true);
+ var (result, _, _) = await instance.FindEpisodeAsync(parsedDetails, show, true);
result.Should().Be(FindResult.Fail);
-
- mockUi.Verify(ui => ui.SetStatus($"Error: Cannot find {metadata} in episode group on TheMovieDB", MessageType.Error));
+
+ mockUi.Verify(ui =>
+ ui.SetStatus($"Error: Cannot find {parsedDetails} in episode group on TheMovieDB", MessageType.Error));
}
}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs
index 7e9cd62..f742dfc 100644
--- a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs
+++ b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs
@@ -26,7 +26,7 @@ private static void SetupGetTvShowWithEpisodeGroupsAsync(Mock mock
]
}
});
-
+
[Fact]
public async Task Should_AddSkipToNextSearchResultOption_When_MultipleSearchResults()
{
@@ -36,16 +36,22 @@ public async Task Should_AddSkipToNextSearchResultOption_When_MultipleSearchResu
List> passedOptions = [];
var mockUi = new Mock();
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
- .Returns((string msg, List options) => msg.Contains("Show 1") ? options.Count - 1 : 0) // skip to next search result for first result, first option otherwise
+ .Returns((string msg, List options) =>
+ msg.Contains("Show 1")
+ ? options.Count - 1
+ : 0) // skip to next search result for first result, first option otherwise
.Callback((string _, List options) => passedOptions.Add(options));
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
await instance.FindEpisodeGroupAsync([new SearchTv { Name = "Show 1" }, new SearchTv { Name = "Show 2" }]);
passedOptions.Count.Should().Be(2);
- passedOptions[0].Should().Contain("(Skip to next search result)"); // option should be present for first search result
- passedOptions[^1].Should().NotContain("(Skip to next search result)"); // option should not be present for last search result (since there is no next search result to skip to)
+ passedOptions[0].Should()
+ .Contain("(Skip to next search result)"); // option should be present for first search result
+ passedOptions[^1].Should()
+ .NotContain(
+ "(Skip to next search result)"); // option should not be present for last search result (since there is no next search result to skip to)
}
[Fact]
@@ -73,12 +79,12 @@ public async Task Should_ReturnSelectedShowResultsAndSetMappingTable_When_Groupi
}
]
});
-
+
var mockUi = new Mock();
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns((string msg, List options) => msg.Contains("Show 1") ? options.Count - 1 : 0);
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
var show2 = new SearchTv { Name = "Show 2" };
@@ -86,7 +92,7 @@ public async Task Should_ReturnSelectedShowResultsAndSetMappingTable_When_Groupi
result.Should().BeNull();
newShow!.TvSearchResult.Should().Be(show2);
- newShow!.HasEpisodeGroupMapping.Should().BeTrue();
+ newShow.HasEpisodeGroupMapping.Should().BeTrue();
}
[Fact]
@@ -100,15 +106,15 @@ public async Task Should_ReportError_When_GetTvEpisodeGroupsAsyncReturnsNull()
var mockUi = new Mock();
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns(0);
-
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
var (result, _) = await instance.FindEpisodeGroupAsync([new SearchTv()]);
-
+
mockUi.Verify(ui => ui.SetStatus(It.IsAny(), MessageType.Error));
result.Should().Be(FindResult.Fail);
}
-
+
[Theory]
[InlineData("Season 1 Part 1", "Season 1 Part 2")] // duplicate season 1
[InlineData("Part One", "Part Two")] // no numbers in name
@@ -119,11 +125,13 @@ public async Task Should_ReportError_When_EpisodeGroupNotValid(string groupName1
mockTmdb.Setup(tmdb => tmdb.GetTvEpisodeGroupsAsync(It.IsAny()))
.ReturnsAsync(new TvGroupCollection
{
- Groups = [
+ Groups =
+ [
new TvGroup
{
Name = groupName1,
- Episodes = [
+ Episodes =
+ [
new TvGroupEpisode
{
Order = 0,
@@ -135,7 +143,8 @@ public async Task Should_ReportError_When_EpisodeGroupNotValid(string groupName1
new TvGroup
{
Name = groupName2,
- Episodes = [
+ Episodes =
+ [
new TvGroupEpisode
{
Order = 0,
@@ -150,11 +159,11 @@ public async Task Should_ReportError_When_EpisodeGroupNotValid(string groupName1
var mockUi = new Mock();
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns(0);
-
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
var (result, _) = await instance.FindEpisodeGroupAsync([new SearchTv()]);
-
+
mockUi.Verify(ui => ui.SetStatus(It.IsAny(), MessageType.Error));
result.Should().Be(FindResult.Fail);
}
@@ -168,8 +177,8 @@ public async Task Should_ReturnFindResultSkip_When_NoOptionSelected()
var mockUi = new Mock();
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns((int?)null);
-
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
var (result, _) = await instance.FindEpisodeGroupAsync([new SearchTv { Name = "Show 1" }]);
@@ -191,14 +200,15 @@ public async Task Should_LogWarning_When_TVShowHasNoEpisodeGroups()
});
var mockUi = new Mock();
-
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
await instance.FindEpisodeGroupAsync([new SearchTv()]);
-
- mockUi.Verify(ui => ui.SetStatus(@"No episode groups found for show ""Show""", MessageType.Warning | MessageType.Log));
+
+ mockUi.Verify(ui =>
+ ui.SetStatus(@"No episode groups found for show ""Show""", MessageType.Warning | MessageType.Log));
}
-
+
[Fact]
public async Task Should_ReportWarning_When_NoResultsHaveEpisodeGroups()
{
@@ -214,11 +224,11 @@ public async Task Should_ReportWarning_When_NoResultsHaveEpisodeGroups()
});
var mockUi = new Mock();
-
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
await instance.FindEpisodeGroupAsync([new SearchTv(), new SearchTv()]);
-
+
mockUi.Verify(ui => ui.SetStatus("No episode groups found", MessageType.Warning));
}
}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindPosterAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindPosterAsync.cs
index c2e5493..c8d3c53 100644
--- a/AutoTag.Core.Test/TV/TVProcessor/FindPosterAsync.cs
+++ b/AutoTag.Core.Test/TV/TVProcessor/FindPosterAsync.cs
@@ -14,8 +14,8 @@ public async Task Should_UsePosterUrlFromCache_WhenAvailable()
mockCache.Setup(c => c.TryGetSeasonPoster(It.IsAny(), It.IsAny(), out url))
.Returns(true);
- var metadata = new TVFileMetadata();
-
+ var metadata = new TVFileMetadata { Title = "", SeriesName = "" };
+
var instance = GetInstance(cache: mockCache.Object);
await instance.FindPosterAsync(metadata);
@@ -38,15 +38,15 @@ public async Task Should_UseHighestRatedPoster_When_NotCached()
]
});
- var metadata = new TVFileMetadata();
+ var metadata = new TVFileMetadata { Title = "", SeriesName = "" };
- var instance = GetInstance(tmdb: mockTmdb.Object);
+ var instance = GetInstance(mockTmdb.Object);
await instance.FindPosterAsync(metadata);
metadata.CoverURL.Should().Be("https://image.tmdb.org/t/p/original/file2");
}
-
+
[Fact]
public async Task Should_ReportErrorAndMarkAsIncomplete_When_NoPostersFound()
{
@@ -59,9 +59,9 @@ public async Task Should_ReportErrorAndMarkAsIncomplete_When_NoPostersFound()
var mockUi = new Mock();
- var metadata = new TVFileMetadata();
+ var metadata = new TVFileMetadata { Title = "", SeriesName = "" };
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object);
await instance.FindPosterAsync(metadata);
diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs
index 9858435..506779f 100644
--- a/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs
+++ b/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs
@@ -13,18 +13,20 @@ public class FindShowAsync : TVProcessorTestBase
public async Task Should_NotQueryAPI_When_ShowAlreadyInCache()
{
var mockCache = new Mock();
- mockCache.Setup(c => c.ShowIsCached(It.IsAny())).Returns(true);
+ List? showResult = [];
+ mockCache.Setup(c => c.TryGetShow(It.IsAny(), It.IsAny(), out showResult))
+ .Returns(true);
var mockTmdb = new Mock();
- var tv = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object);
+ var tv = GetInstance(mockTmdb.Object, cache: mockCache.Object);
- var result = await tv.FindShowAsync("a");
+ var (result, _) = await tv.FindShowAsync("a", null);
result.Should().Be(FindResult.Success);
mockTmdb.Verify(t => t.SearchTvShowAsync(It.IsAny()), Times.Never);
}
-
+
[Fact]
public async Task Should_ReportError_When_NoTMDBSearchResults()
{
@@ -37,16 +39,62 @@ public async Task Should_ReportError_When_NoTMDBSearchResults()
var mockUi = new Mock();
- var tv = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object);
+ var tv = GetInstance(mockTmdb.Object, ui: mockUi.Object);
- var result = await tv.FindShowAsync("series name");
+ var (result, _) = await tv.FindShowAsync("series name", null);
result.Should().Be(FindResult.Fail);
mockUi.Verify(ui => ui.SetStatus("Error: Cannot find series series name on TheMovieDB", MessageType.Error),
Times.Once
);
}
-
+
+ [Fact]
+ public async Task Should_OrderResultsWithMatchingYearFirst_When_YearProvided()
+ {
+ var mockTmdb = new Mock();
+ mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny()))
+ .ReturnsAsync(new SearchContainer
+ {
+ Results =
+ [
+ new SearchTv
+ {
+ Id = 1,
+ Name = "Series name",
+ FirstAirDate = new DateTime(1990, 01, 01)
+ },
+ new SearchTv
+ {
+ Id = 2,
+ Name = "2",
+ FirstAirDate = null
+ },
+ new SearchTv
+ {
+ Id = 3,
+ Name = "Series name",
+ FirstAirDate = new DateTime(2007, 01, 01)
+ },
+ new SearchTv
+ {
+ Id = 4,
+ Name = "4",
+ FirstAirDate = new DateTime(2007, 01, 01)
+ }
+ ]
+ });
+
+ var mockUi = new Mock();
+
+ var tv = GetInstance(mockTmdb.Object, ui: mockUi.Object);
+
+ var (_, showResults) = await tv.FindShowAsync("series name", 2007);
+
+ showResults.Should().NotBeNull();
+ showResults[0].TvSearchResult.Id.Should().Be(3);
+ }
+
[Fact]
public async Task Should_OnlyCacheSelectedResult_When_ManualModeEnabled()
{
@@ -88,19 +136,20 @@ public async Task Should_OnlyCacheSelectedResult_When_ManualModeEnabled()
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns(results.IndexOf(selectedResult));
- var tv = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object, ui: mockUi.Object, config: config);
+ var tv = GetInstance(mockTmdb.Object, cache: mockCache.Object, ui: mockUi.Object, config: config);
- var result = await tv.FindShowAsync("a");
+ var (result, _) = await tv.FindShowAsync("a", null);
result.Should().Be(FindResult.Success);
mockUi.Verify(ui => ui.SelectOption("Please choose an option:", It.IsAny>()), Times.Once);
mockCache.Verify(c => c.AddShow(
It.IsAny(),
+ It.IsAny(),
It.Is>(s => s.Count == 1 && s[0].TvSearchResult.Id == selectedResult.Id)),
Times.Once
);
}
-
+
[Fact]
public async Task Should_SkipFile_When_SelectOptionReturnsNull()
{
@@ -108,22 +157,22 @@ public async Task Should_SkipFile_When_SelectOptionReturnsNull()
{
ManualMode = true
};
-
+
var mockTmdb = new Mock();
mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny()))
.ReturnsAsync(new SearchContainer
{
Results = [new SearchTv { Name = "" }]
});
-
+
var mockUi = new Mock();
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns((int?)null);
- var tv = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object, config: config);
+ var tv = GetInstance(mockTmdb.Object, ui: mockUi.Object, config: config);
+
+ var (result, _) = await tv.FindShowAsync("", null);
- var result = await tv.FindShowAsync("");
-
result.Should().Be(FindResult.Skip);
}
@@ -134,14 +183,14 @@ public async Task Should_ReturnResultFromFindEpisodeGroupAsync_When_NotNull()
{
EpisodeGroup = true
};
-
+
var mockTmdb = new Mock();
mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny()))
.ReturnsAsync(new SearchContainer
{
Results = [new SearchTv { Name = "" }]
});
-
+
mockTmdb.Setup(tmdb => tmdb.GetTvShowWithEpisodeGroupsAsync(It.IsAny()))
.ReturnsAsync(new TvShow
{
@@ -151,9 +200,9 @@ public async Task Should_ReturnResultFromFindEpisodeGroupAsync_When_NotNull()
}
});
- var tv = GetInstance(tmdb: mockTmdb.Object, config: config);
+ var tv = GetInstance(mockTmdb.Object, config: config);
- var result = await tv.FindShowAsync("");
+ var (result, _) = await tv.FindShowAsync("", null);
result.Should().Be(FindResult.Skip);
}
@@ -164,7 +213,7 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected()
{
EpisodeGroup = true
};
-
+
var mockTmdb = new Mock();
var expectedShow = new SearchTv { Name = "Show 1" };
@@ -173,7 +222,7 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected()
{
Results = [expectedShow, new SearchTv { Name = "Show 2" }]
});
-
+
mockTmdb.Setup(tmdb => tmdb.GetTvShowWithEpisodeGroupsAsync(It.IsAny()))
.ReturnsAsync(new TvShow
{
@@ -186,11 +235,14 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected()
mockTmdb.Setup(tmdb => tmdb.GetTvEpisodeGroupsAsync(It.IsAny()))
.ReturnsAsync(new TvGroupCollection
{
- Groups = [new TvGroup
- {
- Name = "Season 1",
- Episodes = []
- }]
+ Groups =
+ [
+ new TvGroup
+ {
+ Name = "Season 1",
+ Episodes = []
+ }
+ ]
});
var mockUi = new Mock();
@@ -199,12 +251,13 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected()
var mockCache = new Mock();
- var tv = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object, cache: mockCache.Object, config: config);
+ var tv = GetInstance(mockTmdb.Object, ui: mockUi.Object, cache: mockCache.Object, config: config);
+
+ await tv.FindShowAsync("", null);
- await tv.FindShowAsync("");
-
mockCache.Verify(c => c.AddShow(
It.IsAny(),
+ It.IsAny(),
It.Is>(results => results.Count == 1
&& results.Single().TvSearchResult == expectedShow)
));
diff --git a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs b/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs
deleted file mode 100644
index 144f937..0000000
--- a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using AutoTag.Core.Config;
-using AutoTag.Core.Files;
-using AutoTag.Core.TV;
-
-namespace AutoTag.Core.Test.TV.TVProcessor;
-
-public class ParseFileName : TVProcessorTestBase
-{
- [Theory]
- [InlineData("Fringe 3x03.mkv", "Fringe", 3, 3)]
- [InlineData("Silo S01E10.mkv", "Silo", 1, 10)]
- [InlineData("Inside.No.9.S09E09.mkv", "Inside No 9", 9, 9)] // dot separated
- [InlineData("the_last_of_us_s01e07.mp4", "The Last of Us", 1, 7)] // underscore separated + lowercase
- [InlineData("Alias.S02E01.The.Enemy.Walks.In.1080p.AMZN.WEB-DL.DDP5.1.H.264-LycanHD.mkv", "Alias", 2, 1)] // scene tags
- [InlineData("Warehouse 13 0x01 Episode Title.mp4", "Warehouse 13", 0, 1)] // season 0
- [InlineData("Doctor Who (2005) - 2x13 - Doomsday (2).mkv", "Doctor Who (2005)", 2, 13)] // symbols in series name
- public void Should_ParseCommonNamingFormats(string fileName, string seriesName, int season, int episode)
- {
- var tv = GetInstance();
-
- var file = new TaggingFile
- {
- Path = fileName
- };
- var result = tv.ParseFileName(file);
-
- result.Should().BeEquivalentTo(
- new TVFileMetadata
- {
- SeriesName = seriesName,
- Season = season,
- Episode = episode
- },
- o => o.Using(StringComparer.OrdinalIgnoreCase)
- );
- }
-
- [Fact]
- public void Should_ParseFromFullPath_When_ParsePatternProvided()
- {
- var config = new AutoTagConfig
- {
- ParsePattern = @".*/(?.+)/Season (?\d+)/S\d+E(?\d+)"
- };
-
- var tv = GetInstance(config: config);
-
- var file = new TaggingFile
- {
- Path = "/test/a/b/Series Name/Season 2/S02E05 Title.mkv"
- };
-
- var result = tv.ParseFileName(file);
-
- result.Should().BeEquivalentTo(new TVFileMetadata
- {
- SeriesName = "Series Name",
- Season = 2,
- Episode = 5
- });
- }
-
- [Theory]
- [InlineData("S01E05.mkv", "Error: Unable to parse series name from filename")]
- [InlineData("Continuuim E03 Episode Title.mp4", "Error: Unable to parse required information from filename")]
- [InlineData("Utopia S01.mp4", "Error: Unable to parse required information from filename")]
- public void Should_ReportError_When_PartMissingFromFileName(string fileName, string expectedMessage)
- {
- var mockUi = new Mock();
-
- var tv = GetInstance(ui: mockUi.Object);
-
- var file = new TaggingFile
- {
- Path = fileName
- };
- tv.ParseFileName(file);
-
- mockUi.Verify(ui => ui.SetStatus(expectedMessage, MessageType.Error), Times.Once);
- }
-
- [Fact]
- public void Should_ReportError_When_ParsePatternDoesNotMatch()
- {
- var mockUi = new Mock();
-
- var config = new AutoTagConfig
- {
- ParsePattern = @".*/(?.+)/Season (?\d+)/S\d+E(?\d+)"
- };
-
- var tv = GetInstance(ui: mockUi.Object, config: config);
-
- var file = new TaggingFile
- {
- Path = "/test/a/b/Series Name/S02/Series Name 2x05 Title.mkv"
- };
-
- tv.ParseFileName(file);
-
- mockUi.Verify(ui => ui.SetStatus("Error: Unable to parse required information from filename", MessageType.Error, It.IsAny()),
- Times.Once
- );
- }
-}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs
index fd711d2..af36ba8 100644
--- a/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs
+++ b/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs
@@ -1,5 +1,6 @@
using AutoTag.Core.Config;
using AutoTag.Core.Files;
+using AutoTag.Core.Files.Parsing;
using AutoTag.Core.TMDB;
using AutoTag.Core.TV;
using TMDbLib.Objects.General;
@@ -11,7 +12,7 @@ namespace AutoTag.Core.Test.TV.TVProcessor;
public class ProcessAsync : TVProcessorTestBase
{
[Fact]
- public async Task Should_ReturnFalse_When_FileNameCannotBeParsed()
+ public async Task Should_ReturnParseFailure_When_FileNameCannotBeParsed()
{
var instance = GetInstance();
@@ -20,32 +21,33 @@ public async Task Should_ReturnFalse_When_FileNameCannotBeParsed()
Path = "invalid file name"
});
- result.Should().BeFalse();
+ result.Should().Be(ProcessResult.ParseFailure);
}
[Fact]
- public async Task Should_ReturnFalse_When_UnableToFindShow()
+ public async Task Should_ReturnNotFound_When_UnableToFindShow()
{
var mockTmdb = new Mock();
mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny()))
.ReturnsAsync(new SearchContainer { Results = [] });
- var instance = GetInstance(tmdb: mockTmdb.Object);
+ var instance = GetInstance(mockTmdb.Object);
var result = await instance.ProcessAsync(new TaggingFile
{
- Path = "/Show/Show S01E02.mp4"
+ Path = "/Show/Show S01E02.mp4",
+ TVDetails = new ParsedTVFileName("Show", null, 1, 2, null, null)
});
- result.Should().BeFalse();
+ result.Should().Be(ProcessResult.NotFound);
mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync(It.IsAny()), Times.Once);
}
-
+
[Fact]
- public async Task Should_ReturnTrueAndShowWarning_When_FileSkipped()
+ public async Task Should_ReturnSkippedAndShowWarning_When_FileSkipped()
{
var config = new AutoTagConfig { ManualMode = true };
-
+
var mockTmdb = new Mock();
mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny()))
.ReturnsAsync(new SearchContainer { Results = [new SearchTv { Name = "Show" }] });
@@ -54,24 +56,22 @@ public async Task Should_ReturnTrueAndShowWarning_When_FileSkipped()
mockUi.Setup(ui => ui.SelectOption(It.IsAny(), It.IsAny>()))
.Returns((int?)null);
- var instance = GetInstance(tmdb: mockTmdb.Object, ui: mockUi.Object, config: config);
+ var instance = GetInstance(mockTmdb.Object, ui: mockUi.Object, config: config);
var result = await instance.ProcessAsync(new TaggingFile
{
- Path = "/Show/Show S01E02.mp4"
+ Path = "/Show/Show S01E02.mp4",
+ TVDetails = new ParsedTVFileName("Show", null, 1, 2, null, null)
});
- result.Should().BeTrue();
+ result.Should().Be(ProcessResult.Skipped);
mockUi.Verify(ui => ui.SetStatus("File skipped", MessageType.Warning));
}
-
+
[Fact]
- public async Task Should_ReturnFalse_When_FindEpisodeFails()
+ public async Task Should_ReturnNotFound_When_FindEpisodeFails()
{
var mockCache = new Mock();
-
- mockCache.Setup(c => c.ShowIsCached(It.IsAny()))
- .Returns(true);
var show = new ShowResults(new SearchTv { Name = "Show" });
show.AddEpisodeGroup(
@@ -96,10 +96,10 @@ public async Task Should_ReturnFalse_When_FindEpisodeFails()
},
out _
);
-
- mockCache.Setup(c => c.GetShow(It.IsAny()))
- .Returns([show]);
-
+ List? showResults = [show];
+ mockCache.Setup(c => c.TryGetShow(It.IsAny(), It.IsAny(), out showResults))
+ .Returns(true);
+
TvSeason? season = null;
mockCache.Setup(c => c.TryGetSeason(It.IsAny(), It.IsAny(), out season))
.Returns(false);
@@ -110,23 +110,22 @@ out _
var result = await instance.ProcessAsync(new TaggingFile
{
- Path = "/Show/Show S01E02.mp4"
+ Path = "/Show/Show S01E02.mp4",
+ TVDetails = new ParsedTVFileName("Show", null, 1, 2, null, null)
});
- result.Should().BeFalse();
+ result.Should().Be(ProcessResult.NotFound);
}
[Fact]
- public async Task Should_ReturnFalseAndShowError_When_ReachedEndOfSearchResultsWithoutFindingEpisode()
+ public async Task Should_ReturnNotFoundAndShowError_When_ReachedEndOfSearchResultsWithoutFindingEpisode()
{
var mockCache = new Mock();
-
- mockCache.Setup(c => c.ShowIsCached(It.IsAny()))
+
+ List? showResults = [new ShowResults(new SearchTv { Name = "Show" })];
+ mockCache.Setup(c => c.TryGetShow(It.IsAny(), It.IsAny(), out showResults))
.Returns(true);
-
- mockCache.Setup(c => c.GetShow(It.IsAny()))
- .Returns([ new ShowResults(new SearchTv { Name = "Show" }) ]);
-
+
TvSeason? season = null;
mockCache.Setup(c => c.TryGetSeason(It.IsAny(), It.IsAny(), out season))
.Returns(false);
@@ -137,10 +136,11 @@ public async Task Should_ReturnFalseAndShowError_When_ReachedEndOfSearchResultsW
var result = await instance.ProcessAsync(new TaggingFile
{
- Path = "/Show/Show S01E02.mp4"
+ Path = "/Show/Show S01E02.mp4",
+ TVDetails = new ParsedTVFileName("Show", null, 1, 2, null, null)
});
- result.Should().BeFalse();
+ result.Should().Be(ProcessResult.NotFound);
mockUi.Verify(ui => ui.SetStatus("Error: Cannot find Show S01E02 on TheMovieDB", MessageType.Error));
}
@@ -148,13 +148,11 @@ public async Task Should_ReturnFalseAndShowError_When_ReachedEndOfSearchResultsW
public async Task Should_FindCoverArtFromSeason_When_NoCoverFromEpisode()
{
var mockCache = new Mock();
-
- mockCache.Setup(c => c.ShowIsCached(It.IsAny()))
+
+ List? showResults = [new ShowResults(new SearchTv { Name = "Show" })];
+ mockCache.Setup(c => c.TryGetShow(It.IsAny(), It.IsAny(), out showResults))
.Returns(true);
-
- mockCache.Setup(c => c.GetShow(It.IsAny()))
- .Returns([ new ShowResults(new SearchTv { Name = "Show" }) ]);
-
+
var season = new TvSeason
{
Episodes = [new TvSeasonEpisode { EpisodeNumber = 2 }]
@@ -175,26 +173,25 @@ public async Task Should_FindCoverArtFromSeason_When_NoCoverFromEpisode()
var result = await instance.ProcessAsync(new TaggingFile
{
Path = "/Show/Show S01E02.mp4",
+ TVDetails = new ParsedTVFileName("Show", null, 1, 2, null, null),
Taggable = true
});
- result.Should().BeTrue();
+ result.Should().Be(ProcessResult.Success);
mockCache.Verify(c => c.TryGetSeasonPoster(It.IsAny(), It.IsAny(), out poster));
}
[Fact]
- public async Task Should_WriteFileAndReturnTrue_When_Succeeds()
+ public async Task Should_WriteFileAndReturnSuccess_When_Succeeds()
{
var config = new AutoTagConfig { AddCoverArt = false };
-
+
var mockCache = new Mock();
-
- mockCache.Setup(c => c.ShowIsCached(It.IsAny()))
+
+ List? showResults = [new ShowResults(new SearchTv { Name = "Show" })];
+ mockCache.Setup(c => c.TryGetShow(It.IsAny(), It.IsAny(), out showResults))
.Returns(true);
-
- mockCache.Setup(c => c.GetShow(It.IsAny()))
- .Returns([ new ShowResults(new SearchTv { Name = "Show" }) ]);
-
+
var season = new TvSeason
{
Episodes = [new TvSeasonEpisode { EpisodeNumber = 2 }]
@@ -210,10 +207,11 @@ public async Task Should_WriteFileAndReturnTrue_When_Succeeds()
var result = await instance.ProcessAsync(new TaggingFile
{
- Path = "/Show/Show S01E02.mp4"
+ Path = "/Show/Show S01E02.mp4",
+ TVDetails = new ParsedTVFileName("Show", null, 1, 2, null, null)
});
- result.Should().BeTrue();
+ result.Should().Be(ProcessResult.Success);
mockWriter.Verify(w => w.WriteAsync(It.IsAny(), It.IsAny()));
}
}
\ No newline at end of file
diff --git a/AutoTag.Core.Test/TV/TVProcessor/TVProcessorTestBase.cs b/AutoTag.Core.Test/TV/TVProcessor/TVProcessorTestBase.cs
index f70fca7..b80e7b2 100644
--- a/AutoTag.Core.Test/TV/TVProcessor/TVProcessorTestBase.cs
+++ b/AutoTag.Core.Test/TV/TVProcessor/TVProcessorTestBase.cs
@@ -8,7 +8,7 @@ namespace AutoTag.Core.Test.TV.TVProcessor;
public abstract class TVProcessorTestBase
{
- protected Core.TV.TVProcessor GetInstance(ITMDBService? tmdb = null,
+ protected static Core.TV.TVProcessor GetInstance(ITMDBService? tmdb = null,
IFileWriter? writer = null,
ITVCache? cache = null,
IUserInterface? ui = null,
diff --git a/AutoTag.Core/AutoTag.Core.csproj b/AutoTag.Core/AutoTag.Core.csproj
index 0147bd8..479b048 100644
--- a/AutoTag.Core/AutoTag.Core.csproj
+++ b/AutoTag.Core/AutoTag.Core.csproj
@@ -1,16 +1,15 @@
+
+ net10.0
+ enable
+ enable
+
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/AutoTag.Core/Config/AutoTagConfig.cs b/AutoTag.Core/Config/AutoTagConfig.cs
index ceabbda..0c8ba2b 100644
--- a/AutoTag.Core/Config/AutoTagConfig.cs
+++ b/AutoTag.Core/Config/AutoTagConfig.cs
@@ -1,40 +1,49 @@
+using AutoTag.Core.Files;
+
namespace AutoTag.Core.Config;
public class AutoTagConfig
{
- public const int CurrentVer = 10;
-
+ public const int CurrentVer = 15;
+
public int ConfigVer { get; set; } = CurrentVer;
-
- public Mode Mode { get; set; } = Mode.TV;
-
- public bool ManualMode { get; set; } = false;
-
- public bool Verbose { get; set; } = false;
-
+
+ public Mode Mode { get; set; } = Mode.Auto;
+
+ public bool ManualMode { get; set; }
+
+ public bool Verbose { get; set; }
+
public bool AddCoverArt { get; set; } = true;
-
+
public bool TagFiles { get; set; } = true;
-
+
public bool RenameFiles { get; set; } = true;
-
- public string TVRenamePattern { get; set; } = "%1 - %2x%3:00 - %4";
-
- public string MovieRenamePattern { get; set; } = "%1 (%2)";
-
+
+ public bool RemoveEmptyFolders { get; set; }
+
+ public string TVRenamePattern { get; set; } =
+ "{Series} - {Season}x{Episode:00}{EndEpisode:-00|} - {Title}{Part:pt-0|}";
+
+ public string MovieRenamePattern { get; set; } = "{Title} ({Year})";
+
public string? ParsePattern { get; set; }
-
- public bool WindowsSafe { get; set; } = false;
-
- public bool ExtendedTagging { get; set; } = false;
-
- public bool AppleTagging { get; set; } = false;
-
- public bool RenameSubtitles { get; set; } = false;
-
+
+ public bool WindowsSafe { get; set; }
+
+ public bool ExtendedTagging { get; set; }
+
+ public bool AppleTagging { get; set; }
+
+ public bool RenameSubtitles { get; set; }
+
public string Language { get; set; } = "en";
-
+
+ public List SearchLanguages { get; set; } = [];
+
+ public bool IncludeAdult { get; set; }
+
public bool EpisodeGroup { get; set; }
-
+
public IEnumerable FileNameReplaces { get; set; } = [];
}
\ No newline at end of file
diff --git a/AutoTag.Core/Config/Mode.cs b/AutoTag.Core/Config/Mode.cs
index 6ce0853..34013ed 100644
--- a/AutoTag.Core/Config/Mode.cs
+++ b/AutoTag.Core/Config/Mode.cs
@@ -3,5 +3,6 @@ namespace AutoTag.Core.Config;
public enum Mode
{
TV,
- Movie
+ Movie,
+ Auto
}
\ No newline at end of file
diff --git a/AutoTag.Core/Extensions.cs b/AutoTag.Core/Extensions.cs
index daafd86..c90bbf0 100644
--- a/AutoTag.Core/Extensions.cs
+++ b/AutoTag.Core/Extensions.cs
@@ -1,6 +1,7 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
using AutoTag.Core.Config;
using AutoTag.Core.Files;
+using AutoTag.Core.Files.Parsing;
using AutoTag.Core.Movie;
using AutoTag.Core.TMDB;
using AutoTag.Core.TV;
@@ -21,25 +22,32 @@ public static void AddCoreServices(this IServiceCollection services, string apiK
services.AddSingleton();
services.AddSingleton();
-
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
services.AddSingleton();
-
+ services.AddSingleton();
+
services.AddScoped();
services.AddKeyedScoped(Mode.TV);
services.AddKeyedScoped(Mode.Movie);
services.AddMemoryCache();
-
+
services.AddHttpClient();
- services.RemoveAll(); // disable HttpClient logging - prints unwanted output to console
-
+ services
+ .RemoveAll<
+ IHttpMessageHandlerBuilderFilter>(); // disable HttpClient logging - prints unwanted output to console
+
services.AddScoped(serviceProvider =>
{
var configService = serviceProvider.GetRequiredService();
var config = configService.GetConfig();
-
- return new(apiKey)
+
+ return new TMDbClient(apiKey)
{
DefaultLanguage = config.Language,
DefaultImageLanguage = config.Language
@@ -50,22 +58,32 @@ public static void AddCoreServices(this IServiceCollection services, string apiK
services.AddScoped();
}
- public static bool IsError(this MessageType type)
- => (type & MessageType.Error) == MessageType.Error;
+ public static bool TryFind(this List list, Predicate match, [NotNullWhen(true)] out T? item)
+ {
+ item = list.Find(match);
- public static bool IsWarning(this MessageType type)
- => (type & MessageType.Warning) == MessageType.Warning;
+ return item != null;
+ }
- public static bool IsInformation(this MessageType type)
- => (type & MessageType.Information) == MessageType.Information;
+ public static bool IsSuccess(this ProcessResult result) => result is ProcessResult.Success or ProcessResult.Skipped;
- public static bool IsLog(this MessageType type)
- => (type & MessageType.Log) == MessageType.Log;
+ public static int? GetNullableIntValue(this GroupCollection groups, string groupName)
+ => groups.TryGetValue(groupName, out var match) && int.TryParse(match.Value, out var value)
+ ? value
+ : null;
- public static bool TryFind(this List list, Predicate match, [NotNullWhen(true)] out T? item)
+ extension(MessageType type)
{
- item = list.Find(match);
+ public bool IsError()
+ => (type & MessageType.Error) == MessageType.Error;
- return item != null;
+ public bool IsWarning()
+ => (type & MessageType.Warning) == MessageType.Warning;
+
+ public bool IsInformation()
+ => (type & MessageType.Information) == MessageType.Information;
+
+ public bool IsLog()
+ => (type & MessageType.Log) == MessageType.Log;
}
}
\ No newline at end of file
diff --git a/AutoTag.Core/FileMetadata.cs b/AutoTag.Core/FileMetadata.cs
index 4362e69..22d4cef 100644
--- a/AutoTag.Core/FileMetadata.cs
+++ b/AutoTag.Core/FileMetadata.cs
@@ -1,27 +1,23 @@
-using System.Text.RegularExpressions;
-using AutoTag.Core.Config;
+using AutoTag.Core.Config;
+using AutoTag.Core.Files;
+using TagLib;
+using File = TagLib.File;
namespace AutoTag.Core;
+
public abstract class FileMetadata
{
- public int Id { get; set; }
- public string? Title { get; set; }
- public string? Overview { get; set; }
+ public int Id { get; init; }
+ public required string Title { get; init; }
+ public string? Overview { get; init; }
public string? CoverURL { get; set; }
- public bool Success { get; set; }
- public bool Complete { get; set; }
+ public bool Complete { get; set; } = true;
public string? Director { get; set; }
public IEnumerable? Actors { get; set; }
public IEnumerable? Characters { get; set; }
- public IEnumerable? Genres { get; set; }
-
- public FileMetadata()
- {
- Success = true;
- Complete = true;
- }
+ public IEnumerable? Genres { get; init; }
- public virtual void WriteToFile(TagLib.File file, AutoTagConfig config, IUserInterface ui)
+ public virtual void WriteToFile(File file, AutoTagConfig config, IUserInterface ui)
{
file.Tag.Title = Title;
file.Tag.Description = Overview;
@@ -31,7 +27,7 @@ public virtual void WriteToFile(TagLib.File file, AutoTagConfig config, IUserInt
file.Tag.Genres = Genres.ToArray();
}
- if (config.ExtendedTagging && (file.TagTypes & TagLib.TagTypes.Matroska) == TagLib.TagTypes.Matroska)
+ if (config.ExtendedTagging && (file.TagTypes & TagTypes.Matroska) == TagTypes.Matroska)
{
file.Tag.Conductor = Director;
file.Tag.Performers = Actors?.ToArray();
@@ -39,21 +35,9 @@ public virtual void WriteToFile(TagLib.File file, AutoTagConfig config, IUserInt
}
}
- public abstract string GetFileName(AutoTagConfig config);
-
- protected static readonly Regex RenameRegex = new(@"%(?\d+)(?:\:(?[0#]+))?");
+ public abstract string GetRenamePattern(AutoTagConfig config);
- protected static string FormatRenameNumber(Match match, int value)
- {
- if (match.Groups.TryGetValue("format", out var format))
- {
- return value.ToString(format.Value);
- }
- else
- {
- return value.ToString();
- }
- }
+ public abstract IEnumerable GetRenameFields();
public abstract override string ToString();
}
\ No newline at end of file
diff --git a/AutoTag.Core/FileNameReplace.cs b/AutoTag.Core/FileNameReplace.cs
deleted file mode 100644
index c56db5d..0000000
--- a/AutoTag.Core/FileNameReplace.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-namespace AutoTag.Core;
-
-public class FileNameReplace
-{
- public required string Replace { get; set; }
- public required string Replacement { get; set; }
-
- public string Apply(string str) => str.Replace(Replace, Replacement);
-
- public static IEnumerable FromStrings(IEnumerable strings)
- {
- if (strings.Count() % 2 != 0)
- {
- throw new ArgumentException("Collection must have an even number of elements", nameof(strings));
- }
-
- string? previous = null;
- foreach (string str in strings)
- {
- if (previous == null)
- {
- previous = str;
- }
- else
- {
- yield return new FileNameReplace
- {
- Replace = previous,
- Replacement = str
- };
-
- previous = null;
- }
- }
- }
-
- public static IEnumerable FromDictionary(IDictionary dict)
- => dict.Select(x => new FileNameReplace { Replace = x.Key, Replacement = x.Value });
-}
\ No newline at end of file
diff --git a/AutoTag.Core/Files/FileFinder.cs b/AutoTag.Core/Files/FileFinder.cs
index a3755b8..b3e9726 100644
--- a/AutoTag.Core/Files/FileFinder.cs
+++ b/AutoTag.Core/Files/FileFinder.cs
@@ -1,4 +1,5 @@
using AutoTag.Core.Config;
+using AutoTag.Core.Files.Parsing;
namespace AutoTag.Core.Files;
@@ -7,57 +8,101 @@ public interface IFileFinder
List FindFilesToProcess(IEnumerable entries);
}
-public class FileFinder(AutoTagConfig config, IFileSystem fs, IUserInterface ui) : IFileFinder
+public class FileFinder(AutoTagConfig config, IFileSystem fs, IUserInterface ui, IFileNameParser parser) : IFileFinder
{
- private static readonly string[] VideoExtensions = [".mp4", ".m4v", ".mkv"];
- private static readonly string[] SubtitleExtensions = [".srt", ".vtt", ".sub", ".ssa"];
-
+ private static readonly HashSet ProcessableVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".mp4",
+ ".m4v",
+ ".mkv",
+ ".avi",
+ ".mov",
+ ".wmv",
+ ".mpg",
+ ".mpeg",
+ ".ts",
+ ".m2ts",
+ ".mts",
+ ".webm",
+ ".flv",
+ ".3gp",
+ ".ogv",
+ ".asf",
+ ".mxf"
+ };
+
+ private static readonly HashSet TaggableVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".mp4",
+ ".m4v",
+ ".mkv"
+ };
+
+ private static readonly HashSet SubtitleExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".srt",
+ ".vtt",
+ ".sub",
+ ".ssa",
+ ".ass"
+ };
+
public List FindFilesToProcess(IEnumerable entries)
{
var files = FindFilesInDirectory(entries)
- .DistinctBy(f => f.Path);
+ .DistinctBy(f => f.Path)
+ .Select(f =>
+ {
+ var (tvResult, movieResult) = parser.ParseFileName(f.Path);
- if (config.RenameSubtitles && string.IsNullOrEmpty(config.ParsePattern))
+ return f with { TVDetails = tvResult, MovieDetails = movieResult };
+ })
+ .ToList();
+
+ if (config.RenameSubtitles)
{
- files = files
- .GroupBy(f => Path.GetFileNameWithoutExtension(f.Path))
- .SelectMany(g => GroupSubtitles(g));
+ files = files.GroupBy(f => (f.TVDetails, f.MovieDetails))
+ .SelectMany(GroupSubtitles)
+ .ToList();
}
return files
.OrderBy(f => f.Path)
.ToList();
}
-
+
private IEnumerable FindFilesInDirectory(IEnumerable entries)
{
foreach (var entry in entries)
{
- if (entry.Exists)
+ if (fs.Exists(entry))
{
- if (entry is DirectoryInfo directory)
- {
- ui.DisplayMessage($"Adding all files in directory '{directory}'", MessageType.Log);
-
- foreach (var file in FindFilesInDirectory(fs.GetDirectoryContents(directory)))
- {
- yield return file;
- }
- }
- else if (entry is FileInfo file && IsSupportedFile(file))
+ switch (entry)
{
- // add file if not already added and has a supported file extension
- ui.DisplayMessage($"Adding file '{file}'", MessageType.Log);
-
- yield return new TaggingFile
- {
- Path = file.FullName,
- Taggable = VideoExtensions.Contains(file.Extension)
- };
- }
- else
- {
- ui.DisplayMessage($"Unsupported file: '{entry}'", MessageType.Log | MessageType.Error);
+ case DirectoryInfo directory:
+ ui.DisplayMessage($"Adding all files in directory '{directory}'", MessageType.Log);
+
+ foreach (var file in FindFilesInDirectory(fs.GetDirectoryContents(directory)))
+ {
+ yield return file;
+ }
+
+ break;
+
+ case FileInfo file when IsSupportedFile(file):
+ // add file if not already added and has a supported file extension
+ ui.DisplayMessage($"Adding file '{file}'", MessageType.Log);
+
+ yield return new TaggingFile
+ {
+ Path = file.FullName,
+ Taggable = IsTaggableVideoFile(file.Extension)
+ };
+ break;
+
+ default:
+ ui.DisplayMessage($"Unsupported file: '{entry}'", MessageType.Log | MessageType.Error);
+ break;
}
}
else
@@ -66,21 +111,29 @@ private IEnumerable