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 FindFilesInDirectory(IEnumerable GroupSubtitles(IGrouping files) + + + private IEnumerable GroupSubtitles( + IGrouping<(ParsedTVFileName? TVResult, ParsedMovieFileName? MovieResult), TaggingFile> files) { - if (files.Count() == 1) + if (files.Key is { TVResult: null, MovieResult: null }) + { + foreach (var file in files) + { + yield return file; + } + } + else if (files.Count() == 1) { yield return files.First(); } - else if (files.Count(f => IsVideoFile(Path.GetExtension(f.Path))) > 1 - || files.Count(f => IsSubtitleFile(Path.GetExtension(f.Path))) > 1) + else if (files.Count(f => IsVideoFile(Path.GetExtension(f.Path))) > 1) { ui.DisplayMessage( - $@"Warning, detected multiple files named ""{files.Key}"", files will be processed separately", + "Warning, detected possible duplicate video files, files will be processed separately", MessageType.Log | MessageType.Warning ); - + foreach (var f in files) { yield return f; @@ -88,33 +141,35 @@ private IEnumerable GroupSubtitles(IGrouping f } else { - string? videoPath = files.FirstOrDefault(f => IsVideoFile(Path.GetExtension(f.Path)))?.Path; - string? subPath = files.FirstOrDefault(f => IsSubtitleFile(Path.GetExtension(f.Path)))?.Path; + var video = files.FirstOrDefault(f => IsVideoFile(Path.GetExtension(f.Path))); + var subs = files.Where(f => IsSubtitleFile(Path.GetExtension(f.Path))).ToList(); - if (videoPath != null) + if (video != null) { - yield return new TaggingFile + yield return video with { - Path = videoPath, - SubtitlePath = subPath + SubtitlePaths = subs.Select(s => s.Path).ToList() }; } - else if (subPath != null) + else if (subs.Count > 0) { - yield return new TaggingFile + yield return subs[0] with { - Path = subPath, - Taggable = false + SubtitlePaths = subs.Skip(1).Select(s => s.Path).ToList() }; } } } - - private bool IsSupportedFile(FileInfo info) - => IsVideoFile(info.Extension) - || config.RenameSubtitles && IsSubtitleFile(info.Extension); - private bool IsVideoFile(string extension) => VideoExtensions.Contains(extension); - private bool IsSubtitleFile(string extension) => SubtitleExtensions.Contains(extension); + private bool IsSupportedFile(FileInfo info) => + IsVideoFile(info.Extension) + || (config.RenameSubtitles && IsSubtitleFile(info.Extension)); + + + private static bool IsVideoFile(string extension) => ProcessableVideoExtensions.Contains(extension); + + private static bool IsTaggableVideoFile(string extension) => TaggableVideoExtensions.Contains(extension); + + private static bool IsSubtitleFile(string extension) => SubtitleExtensions.Contains(extension); } \ No newline at end of file diff --git a/AutoTag.Core/Files/FileNameField.cs b/AutoTag.Core/Files/FileNameField.cs new file mode 100644 index 0000000..abf3529 --- /dev/null +++ b/AutoTag.Core/Files/FileNameField.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; + +namespace AutoTag.Core.Files; + +public interface IFileNameField +{ + string Specifier { get; } + string? LegacySpecifier { get; } + + string GetFormattedValue(string? format); +} + +public class StringFileNameField(string specifier, string? legacySpecifier, string? value) : IFileNameField +{ + private string? Value { get; } = value; + public string Specifier { get; } = specifier; + public string? LegacySpecifier { get; } = legacySpecifier; + + public string GetFormattedValue(string? format) => Value ?? ""; +} + +public partial class IntegerFileNameField(string specifier, string? legacySpecifier, int? value) : IFileNameField +{ + [GeneratedRegex("[0#]+")] + private static partial Regex FormatSpecifierRegex { get; } + + private int? Value { get; } = value; + public string Specifier { get; } = specifier; + public string? LegacySpecifier { get; } = legacySpecifier; + + public string GetFormattedValue(string? format) + { + if (string.IsNullOrEmpty(format)) + { + return Value?.ToString() ?? ""; + } + + var split = format.Split('|', 2); + + if (split.Length == 2 && Value is null or 0) + { + return split[1]; + } + + return FormatSpecifierRegex.Replace(split[0], m => Value?.ToString(m.Value) ?? ""); + } +} \ No newline at end of file diff --git a/AutoTag.Core/Files/FileNameReplace.cs b/AutoTag.Core/Files/FileNameReplace.cs new file mode 100644 index 0000000..c975302 --- /dev/null +++ b/AutoTag.Core/Files/FileNameReplace.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace AutoTag.Core.Files; + +public class FileNameReplace(string replace, string replacement) +{ + [JsonInclude] + private string Replace { get; } = replace; + + [JsonInclude] + private string Replacement { get; } = replacement; + + public string Apply(string str) => str.Replace(Replace, Replacement); + + public static IEnumerable FromDictionary(IDictionary dict) + => dict.Select(x => new FileNameReplace(x.Key, x.Value)); + + public override bool Equals(object? obj) => + obj is FileNameReplace r && r.Replace == Replace && r.Replacement == Replacement; + + public override int GetHashCode() => HashCode.Combine(Replace, Replacement); +} \ No newline at end of file diff --git a/AutoTag.Core/Files/FileNamer.cs b/AutoTag.Core/Files/FileNamer.cs new file mode 100644 index 0000000..6c4d4e0 --- /dev/null +++ b/AutoTag.Core/Files/FileNamer.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; +using AutoTag.Core.Config; + +namespace AutoTag.Core.Files; + +public interface IFileNamer +{ + (string Result, bool ReplacedInvalid) GetNewFileName(FileMetadata metadata); +} + +public partial class FileNamer(AutoTagConfig config) : IFileNamer +{ + private static readonly char[] InvalidNtfsChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + + private readonly char[] _invalidFilenameChars = GetInvalidFileNameChars(config); + + [GeneratedRegex( + @"{(?[A-z]+)(?:\:(?[^}]+))?}|%(?\d+)(?:\:(?[0#]+))?")] + private static partial Regex RenameRegex { get; } + + public (string Result, bool ReplacedInvalid) GetNewFileName(FileMetadata metadata) + { + var pattern = metadata.GetRenamePattern(config); + + var fields = metadata.GetRenameFields().ToList(); + var fieldsBySpecifier = fields.ToDictionary(f => f.Specifier); + var fieldsByLegacySpecifier = fields + .Where(f => f.LegacySpecifier is not null) + .ToDictionary(f => f.LegacySpecifier!); + + var removedInvalid = false; + var path = RenameRegex.Replace(pattern, m => + { + IFileNameField? field = null; + var formatGroup = ""; + if (m.Groups.TryGetValue("specifier", out var specifier) && + fieldsBySpecifier.TryGetValue(specifier.Value, out var f1)) + { + field = f1; + formatGroup = "specifierFormat"; + } + else if (m.Groups.TryGetValue("legacySpecifier", out var advancedSpecifier) && + fieldsByLegacySpecifier.TryGetValue(advancedSpecifier.Value, out var f2)) + { + field = f2; + formatGroup = "legacySpecifierFormat"; + } + + if (field == null) return ""; + + var (result, removed) = + ApplyReplaces(field.GetFormattedValue(m.Groups.GetValueOrDefault(formatGroup)?.Value)); + + removedInvalid |= removed; + + return result; + }); + + return (path, removedInvalid); + } + + private (string Result, bool RemovedInvalid) ApplyReplaces(string value) + { + var result = value; + foreach (var replace in config.FileNameReplaces) + { + result = replace.Apply(result); + } + + var sanitisedName = RemoveInvalidFileNameChars(result); + + return (sanitisedName, sanitisedName != result); + } + + private string RemoveInvalidFileNameChars(string fileName) + => string.Concat(fileName.Where(c => !_invalidFilenameChars.Contains(c))); + + private static char[] GetInvalidFileNameChars(AutoTagConfig config) + => [..Path.GetInvalidFileNameChars(), ..config.WindowsSafe ? InvalidNtfsChars : []]; +} \ No newline at end of file diff --git a/AutoTag.Core/Files/FileSystem.cs b/AutoTag.Core/Files/FileSystem.cs index df87110..90521e9 100644 --- a/AutoTag.Core/Files/FileSystem.cs +++ b/AutoTag.Core/Files/FileSystem.cs @@ -1,16 +1,28 @@ -using System.Diagnostics.CodeAnalysis; - namespace AutoTag.Core.Files; public interface IFileSystem { IEnumerable GetDirectoryContents(DirectoryInfo directoryInfo); + bool Exists(FileSystemInfo info); + bool Exists([NotNullWhen(true)] string? path); - + void Move(string sourceFileName, string destFileName); - void CreateDirectory(DirectoryInfo directoryInfo); + void CreateDirectory(DirectoryInfo directory); + + void CreateDirectory(string path); + + bool DirectoryExists(string path); + + bool DirectoryIsEmpty(string path); + + void DeleteDirectory(string path); + + bool PathContainsDirectory(string path); + + string? GetDirectoryPath(string path); Stream OpenReadStream(string path); @@ -22,11 +34,25 @@ public class FileSystem : IFileSystem public IEnumerable GetDirectoryContents(DirectoryInfo directoryInfo) => directoryInfo.GetFileSystemInfos(); + public bool Exists(FileSystemInfo info) => info.Exists; + public bool Exists([NotNullWhen(true)] string? path) => File.Exists(path); public void Move(string sourceFileName, string destFileName) => File.Move(sourceFileName, destFileName); - public void CreateDirectory(DirectoryInfo directoryInfo) => directoryInfo.Create(); + public void CreateDirectory(DirectoryInfo directory) => directory.Create(); + + public void CreateDirectory(string path) => CreateDirectory(new DirectoryInfo(path)); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public bool DirectoryIsEmpty(string path) => !Directory.EnumerateFileSystemEntries(path).Any(); + + public void DeleteDirectory(string path) => Directory.Delete(path); + + public bool PathContainsDirectory(string path) => path.Contains(Path.DirectorySeparatorChar); + + public string? GetDirectoryPath(string path) => Path.GetDirectoryName(path); public Stream OpenReadStream(string path) => new FileStream(path, FileMode.Open, FileAccess.Read); diff --git a/AutoTag.Core/Files/FileWriter.cs b/AutoTag.Core/Files/FileWriter.cs index f9026db..cc4a5a8 100644 --- a/AutoTag.Core/Files/FileWriter.cs +++ b/AutoTag.Core/Files/FileWriter.cs @@ -1,4 +1,6 @@ using AutoTag.Core.Config; +using TagLib; +using File = TagLib.File; namespace AutoTag.Core.Files; @@ -7,24 +9,62 @@ public interface IFileWriter Task WriteAsync(TaggingFile taggingFile, FileMetadata metadata); } -public class FileWriter(ICoverArtFetcher coverArtFetcher, AutoTagConfig config, IFileSystem fs, IUserInterface ui) : IFileWriter +public class FileWriter( + ICoverArtFetcher coverArtFetcher, + AutoTagConfig config, + IFileSystem fs, + IUserInterface ui, + IFileNamer namer) : IFileWriter { public async Task WriteAsync(TaggingFile taggingFile, FileMetadata metadata) { - bool fileSuccess = true; + var fileSuccess = true; if (config.TagFiles && taggingFile.Taggable) { - fileSuccess = await TagFileAsync(taggingFile, metadata); + fileSuccess &= await TagFileAsync(taggingFile, metadata); } if (config.RenameFiles) { - fileSuccess &= RenameFile(taggingFile.Path, metadata.GetFileName(config), null); + var (targetPath, removedInvalid) = namer.GetNewFileName(metadata); - if (!string.IsNullOrEmpty(taggingFile.SubtitlePath)) + var isDirectoryPath = fs.PathContainsDirectory(targetPath); + var fullTargetPath = GetFullOutputPath(taggingFile.Path, targetPath); + + var subtitlePaths = taggingFile.SubtitlePaths + .Select((s, i) => (Path: s, + NewPath: GetFullOutputPath(s, + GetSubtitleTargetFileName(targetPath, i, taggingFile.SubtitlePaths.Count)))) + .ToList(); + + if (IsAlreadyNamedCorrectly(taggingFile, fullTargetPath, subtitlePaths)) + { + ui.SetStatus("Rename skipped - already named correctly", MessageType.Information); + } + else { - fileSuccess &= RenameFile(taggingFile.SubtitlePath, metadata.GetFileName(config), "subtitle "); + if (removedInvalid) + { + ui.SetStatus("Warning: Invalid characters in file name, automatically removing", + MessageType.Warning); + } + + var renameSuccess = true; + renameSuccess &= RenameFile(taggingFile.Path, fullTargetPath, isDirectoryPath, null); + + foreach (var subtitle in subtitlePaths) + { + renameSuccess &= RenameFile(subtitle.Path, subtitle.NewPath, isDirectoryPath, " subtitle"); + } + + if (renameSuccess && isDirectoryPath && config.RemoveEmptyFolders) + { + RemoveSourceDirectoryIfEmpty(fs.GetDirectoryPath(taggingFile.Path), + fs.GetDirectoryPath(fullTargetPath)!); + } + + fileSuccess &= renameSuccess; } } @@ -33,12 +73,12 @@ public async Task WriteAsync(TaggingFile taggingFile, FileMetadata metadat private async Task TagFileAsync(TaggingFile taggingFile, FileMetadata metadata) { - bool fileSuccess = true; - - TagLib.File? file = null; + var fileSuccess = true; + + File? file = null; try { - using (file = TagLib.File.Create(taggingFile.Path)) + using (file = File.Create(taggingFile.Path)) { metadata.WriteToFile(file, config, ui); @@ -52,14 +92,13 @@ private async Task TagFileAsync(TaggingFile taggingFile, FileMetadata meta $"Error: failed to download cover art{(config.Verbose ? $"({metadata.CoverURL})" : "")}", MessageType.Error ); - + fileSuccess = false; } else { - file.Tag.Pictures = [new TagLib.Picture(imgBytes) { Filename = "cover.jpg" }]; + file.Tag.Pictures = [new Picture(imgBytes) { Filename = "cover.jpg" }]; } - } else if (string.IsNullOrEmpty(metadata.CoverURL) && config.AddCoverArt) { @@ -77,89 +116,86 @@ private async Task TagFileAsync(TaggingFile taggingFile, FileMetadata meta catch (Exception ex) { ui.SetStatus("Error: Failed to write tags to file", MessageType.Error, ex); - if (file != null && file.CorruptionReasons?.Any() == true) + if (file?.CorruptionReasons?.Any() == true) { ui.SetStatus($"File corruption reasons: {string.Join(", ", file.CorruptionReasons)})", MessageType.Error | MessageType.Log ); } - + fileSuccess = false; } + finally + { + file?.Dispose(); + } return fileSuccess; } - private bool RenameFile(string path, string newName, string? msgPrefix) + private bool RenameFile(string path, string newPath, bool isDirectoryPath, string? msgPrefix) { - bool fileSuccess = true; - string newPath = Path.Combine( - Path.GetDirectoryName(path)!, - GetFileName( - newName, - Path.GetFileNameWithoutExtension(path) - ) - + Path.GetExtension(path) - ); - - if (path != newPath) + var fileSuccess = true; + try { - try + if (fs.Exists(newPath)) { - if (fs.Exists(newPath)) - { - ui.SetStatus($"Error: Could not rename - {msgPrefix}file already exists", MessageType.Error); - fileSuccess = false; - } - else - { - fs.Move(path, newPath); - ui.SetFilePath(newPath); - ui.SetStatus($"Successfully renamed {msgPrefix}file to '{Path.GetFileName(newPath)}'", MessageType.Information); - } + ui.SetStatus($"Error: Could not rename - {msgPrefix}file already exists", MessageType.Error); + fileSuccess = false; } - catch (Exception ex) + else { - ui.SetStatus($"Error: Failed to rename {msgPrefix}file", MessageType.Error, ex); - fileSuccess = false; + if (isDirectoryPath) + { + fs.CreateDirectory(fs.GetDirectoryPath(newPath)!); + } + + fs.Move(path, newPath); + ui.SetFilePath(newPath); + + ui.SetStatus( + $"Successfully {(isDirectoryPath ? "moved" : "renamed")} {msgPrefix}file to '{(isDirectoryPath ? newPath : Path.GetFileName(newPath))}'", + MessageType.Information); } } + catch (Exception ex) + { + ui.SetStatus($"Error: Failed to rename {msgPrefix}file", MessageType.Error, ex); + fileSuccess = false; + } return fileSuccess; } - private string GetFileName(string fileName, string oldFileName) + private void RemoveSourceDirectoryIfEmpty(string? sourceDirectory, string targetDirectory) { - string result = fileName; - foreach (var replace in config.FileNameReplaces) + if (string.IsNullOrEmpty(sourceDirectory) + || sourceDirectory == targetDirectory + || !fs.DirectoryExists(sourceDirectory) + || !fs.DirectoryIsEmpty(sourceDirectory)) { - result = replace.Apply(result); - } - - var sanitisedName = RemoveInvalidFileNameChars(result); - if (sanitisedName != oldFileName && sanitisedName.Length != fileName.Length) - { - ui.SetStatus("Warning: Invalid characters in file name, automatically removing", MessageType.Warning); + return; } - return sanitisedName; + fs.DeleteDirectory(sourceDirectory); + ui.SetStatus($"Removed empty folder '{sourceDirectory}'", MessageType.Information); } - private string RemoveInvalidFileNameChars(string fileName) + private string GetFullOutputPath(string path, string newPath) { - if (InvalidFilenameChars == null) - { - InvalidFilenameChars = Path.GetInvalidFileNameChars(); + var isDirectoryPath = fs.PathContainsDirectory(newPath); + var extension = Path.GetExtension(path); - if (config.WindowsSafe) - { - InvalidFilenameChars = InvalidFilenameChars.Union(InvalidNtfsChars).ToArray(); - } - } - - return string.Concat(fileName.Where(c => !InvalidFilenameChars.Contains(c))); + return (isDirectoryPath ? newPath : Path.Combine(fs.GetDirectoryPath(path)!, newPath)) + extension; } - private static char[]? InvalidFilenameChars { get; set; } - private static readonly char[] InvalidNtfsChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + private static bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string newPath, + IEnumerable<(string Path, string NewPath)> subtitlePaths) + => taggingFile.Path == Path.GetFullPath(newPath) && + subtitlePaths.All(p => p.Path == Path.GetFullPath(p.NewPath)); + + private static string GetSubtitleTargetFileName(string targetFileName, int index, int subtitleCount) + => subtitleCount == 1 + ? targetFileName + : $"{targetFileName}.{index + 1}"; } \ No newline at end of file diff --git a/AutoTag.Core/Files/Parsing/FileNameParser.cs b/AutoTag.Core/Files/Parsing/FileNameParser.cs new file mode 100644 index 0000000..29b0371 --- /dev/null +++ b/AutoTag.Core/Files/Parsing/FileNameParser.cs @@ -0,0 +1,28 @@ +using AutoTag.Core.Config; + +namespace AutoTag.Core.Files.Parsing; + +public interface IFileNameParser +{ + (ParsedTVFileName? TVResult, ParsedMovieFileName? MovieResult) ParseFileName(string filePath); +} + +public class FileNameParser(AutoTagConfig config, TVFileNameParser tvParser, MovieFileNameParser movieParser) : IFileNameParser +{ + public (ParsedTVFileName? TVResult, ParsedMovieFileName? MovieResult) ParseFileName(string filePath) + { + ParsedTVFileName? tvResult = null; + if (config.Mode != Mode.Movie && tvParser.TryParse(filePath, out var tv)) + { + tvResult = tv; + } + + ParsedMovieFileName? movieResult = null; + if (config.Mode != Mode.TV && movieParser.TryParse(filePath, out var movie)) + { + movieResult = movie; + } + + return (tvResult, movieResult); + } +} diff --git a/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs b/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs new file mode 100644 index 0000000..8d093e9 --- /dev/null +++ b/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs @@ -0,0 +1,207 @@ +using System.Text.RegularExpressions; + +namespace AutoTag.Core.Files.Parsing; + +public record ParsedMovieFileName(string Title, int? Year) +{ + public override string ToString() => $"{Title}{(Year.HasValue ? $" ({Year})" : "")}"; +} + +public class MovieFileNameParser +{ + private const RegexOptions SharedRegexOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + private const RegexOptions DomainTailRegexOptions = RegexOptions.CultureInvariant; + + private static readonly Regex SiteMarkerDomainTailRegex = + new( + @"[._\-\s]+(?:dir|www\d?|site|blog)(?:[._\-\s]+[a-z0-9]{2,}){0,8}\.(?:com|net|org|info|biz|co|io|tv|me|cc|ws|lt|mx|am)$", + DomainTailRegexOptions); + + private static readonly Regex DomainTailRegex = + new(@"[._\-\s]+[a-z0-9]{2,}(?:[._-][a-z0-9]{2,}){0,2}\.(?:com|net|org|info|biz|co|io|tv|me|cc|ws|lt|mx|am)$", + DomainTailRegexOptions); + + private static readonly Regex TrailingReleaseTokenRegex = new(@"\s+[._-][a-z0-9]{2,20}$", SharedRegexOptions); + private static readonly Regex SquareBracketGroupRegex = new(@"\[[^\]]+\]", SharedRegexOptions); + + private static readonly Regex ParenthesisedYearRegex = + new(@"\((?:[^,)]*,\s*)?(?(19|20)\d{2})\)", SharedRegexOptions); + + private static readonly Regex BareYearRegex = new(@"\b(?(19|20)\d{2})\b", SharedRegexOptions); + private static readonly Regex SeparatorRegex = new("[._-]+", SharedRegexOptions); + private static readonly Regex BracketCharacterRegex = new(@"[(){}\[\]]", SharedRegexOptions); + private static readonly Regex MultiSpaceRegex = new(@"\s+", SharedRegexOptions); + private static readonly Regex TrimmedPunctuationRegex = new(@"^[\s\p{P}]+|[\s\p{P}]+$", SharedRegexOptions); + private static readonly char[] TrimCharacters = [' ', '.', '-', '_']; + + private static readonly string[] TechnicalTermPatterns = + [ + @"\b\d{3,4}[pi]\b", + @"\b4K\b", + @"\bUHD\b", + @"\bBluRay\b", + @"\bBRRip\b", + @"\bWEB-?DL\b", + @"\bWEBRip\b", + @"\bDVDRip\b", + @"\bHDRip\b", + @"\bBDRip\b", + @"\bHDTV\b", + @"\bAMZN\b", + @"\bHMAX\b", + @"\bNF\b", + @"\bREMUX\b", + @"\bx\.?264\b", + @"\bx\.?265\b", + @"\bHEVC\b", + @"\bAVC\b", + @"\bH\.?264\b", + @"\bH\.?265\b", + @"\bAAC(?:\d(?:\.\d)?)?\b", + @"\bAC3(?:\.\d)?\b", + @"\bDTS(?:-HD)?\b", + @"\bFLAC\b", + @"\bDDP\d(?:\.\d)?\b", + @"\bTrueHD\b", + @"\bAtmos\b", + @"\bDual\s*Audio\b", + @"\bMulti\s*Audio\b", + @"\bRepack\b", + @"\bProper\b", + @"\bExtended\b", + @"\bUnrated\b", + @"\bInternal\b" + ]; + + private static readonly string[] LanguageTermPatterns = + [ + @"\bDublado\b", + @"\bLegendado\b", + @"\bDubbed\b", + @"\bSubbed\b", + @"\bSubtitles?\b", + @"\bSubtitled\b", + @"\bPT-BR\b", + @"\bENG\b" + ]; + + public bool TryParse(string filePath, [NotNullWhen(true)] out ParsedMovieFileName? result) + { + var workingTitle = Path.GetFileNameWithoutExtension(filePath); + if (string.IsNullOrWhiteSpace(workingTitle)) + { + result = null; + return false; + } + + workingTitle = SiteMarkerDomainTailRegex.Replace(workingTitle, ""); + workingTitle = DomainTailRegex.Replace(workingTitle, ""); + workingTitle = SquareBracketGroupRegex.Replace(workingTitle, " "); + + int? year = null; + if (TryExtractParenthesisedYear(workingTitle, out var parenthesisedYear, out var withoutParenthesisedYear)) + { + year = parenthesisedYear; + workingTitle = withoutParenthesisedYear; + } + else if (TryExtractTrailingYear(workingTitle, out var trailingYear, out var withoutTrailingYear)) + { + year = trailingYear; + workingTitle = withoutTrailingYear; + } + + var hadTechnicalNoise = false; + foreach (var pattern in TechnicalTermPatterns) + { + workingTitle = Regex.Replace(workingTitle, pattern, + _ => + { + hadTechnicalNoise = true; + return " "; + }, + SharedRegexOptions + ); + } + + foreach (var pattern in LanguageTermPatterns) + { + workingTitle = Regex.Replace(workingTitle, pattern, " ", SharedRegexOptions); + } + + if (hadTechnicalNoise) + { + workingTitle = TrailingReleaseTokenRegex.Replace(workingTitle, ""); + } + + workingTitle = SeparatorRegex.Replace(workingTitle, " "); + workingTitle = BracketCharacterRegex.Replace(workingTitle, " "); + workingTitle = MultiSpaceRegex.Replace(workingTitle, " ").Trim(); + workingTitle = TrimmedPunctuationRegex.Replace(workingTitle, ""); + + if (!string.IsNullOrWhiteSpace(workingTitle)) + { + result = new ParsedMovieFileName(workingTitle, year); + return true; + } + + result = null; + return false; + } + + private static bool TryExtractParenthesisedYear(string title, [NotNullWhen(true)] out int? year, + [NotNullWhen(true)] + out string? updatedTitle) + { + year = null; + updatedTitle = null; + + var match = ParenthesisedYearRegex.Match(title); + if (!match.Success) + { + return false; + } + + var candidateTitle = ParenthesisedYearRegex.Replace(title, " ", 1); + if (!HasUsefulTitle(candidateTitle)) + { + return false; + } + + year = int.Parse(match.Groups["Year"].Value); + updatedTitle = candidateTitle; + return true; + } + + private static bool TryExtractTrailingYear(string title, [NotNullWhen(true)] out int? year, + [NotNullWhen(true)] + out string? updatedTitle) + { + year = null; + updatedTitle = null; + + var matches = BareYearRegex.Matches(title); + if (matches.Count == 0) + { + return false; + } + + var match = matches[^1]; + if (match.Index == 0) + { + return false; + } + + var candidateTitle = title.Remove(match.Index, match.Length); + if (!HasUsefulTitle(candidateTitle)) + { + return false; + } + + year = int.Parse(match.Groups["Year"].Value); + updatedTitle = candidateTitle; + return true; + } + + private static bool HasUsefulTitle(string title) => + !string.IsNullOrWhiteSpace(MultiSpaceRegex.Replace(title, " ").Trim(TrimCharacters)); +} \ No newline at end of file diff --git a/AutoTag.Core/Files/Parsing/TVFileNameParser.cs b/AutoTag.Core/Files/Parsing/TVFileNameParser.cs new file mode 100644 index 0000000..9d189e4 --- /dev/null +++ b/AutoTag.Core/Files/Parsing/TVFileNameParser.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; +using AutoTag.Core.Config; + +namespace AutoTag.Core.Files.Parsing; + +public record ParsedTVFileName(string SeriesName, int? Year, int? Season, int Episode, int? EndEpisode, int? Part) +{ + public override string ToString() => + $"{SeriesName} {(Season.HasValue ? $"S{Season:00}" : "")}E{Episode:00}{(EndEpisode.HasValue ? $"-{EndEpisode:00}" : "")}"; +} + +public partial class TVFileNameParser(AutoTagConfig config) +{ + [GeneratedRegex( + @"^(?(?!s\d).{2,}?)[._ -]*(?:\(?(?(?:19|20)\d{2})\)?)?[._ -]*(?:(?:s?(?\d+)[ex._ ](?\d+\b))|(?:[e(]?(?\d+\b(?!.*s?\d+[ex]\d+))))(?:-e?(?\d+))?(?:.*p(?:ar)?t(?\d+))?.*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] + private static partial Regex TVRegex { get; } + + [GeneratedRegex(@"[.\-_ ]+")] + private static partial Regex SeparatorRegex { get; } + + [GeneratedRegex(@"\b(?:dublado|legendado|[ds]ubbed|subtitle[sd]?|pt-br|eng)\b|\[[^\[]+\]", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex TitleRemoveRegex { get; } + + public bool TryParse(string filePath, [NotNullWhen(true)] out ParsedTVFileName? result) + { + var match = Match(filePath); + if (match.Success) + { + var year = match.Groups.GetNullableIntValue("Year"); + var season = match.Groups.GetNullableIntValue("Season"); + var episode = match.Groups.GetNullableIntValue("Episode"); + var absoluteEpisode = match.Groups.GetNullableIntValue("AbsoluteEpisode"); + + // try to distinguish between a year and an absolute numbered episode + if (!season.HasValue && !episode.HasValue && + (!absoluteEpisode.HasValue || (!year.HasValue && absoluteEpisode is > 1900 and < 2099))) + { + result = null; + return false; + } + + result = new ParsedTVFileName( + CleanupTitle(match.Groups["SeriesName"].Value), + year, + season, + episode ?? absoluteEpisode!.Value, + match.Groups.GetNullableIntValue("EndEpisode"), + match.Groups.GetNullableIntValue("Part") + ); + + return true; + } + + result = null; + return false; + } + + private Match Match(string filePath) + { + if (string.IsNullOrEmpty(config.ParsePattern)) + { + return TVRegex.Match(Path.GetFileNameWithoutExtension(filePath)); + } + + var regex = new Regex(config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + var match = regex.Match(filePath); + + if (!match.Success) + { + if (config.ParsePattern.Contains('/') && Path.DirectorySeparatorChar != '/') + { + return regex.Match(filePath.Replace(Path.DirectorySeparatorChar, '/')); + } + + if (config.ParsePattern.Contains(@"\\") && Path.DirectorySeparatorChar != '\\') + { + return regex.Match(filePath.Replace(Path.DirectorySeparatorChar, '\\')); + } + } + + return match; + } + + private static string CleanupTitle(string title) => + SeparatorRegex.Replace(TitleRemoveRegex.Replace(title, ""), " ").Trim(); +} \ No newline at end of file diff --git a/AutoTag.Core/Files/TaggingFile.cs b/AutoTag.Core/Files/TaggingFile.cs index e6a7c67..6cc9f68 100644 --- a/AutoTag.Core/Files/TaggingFile.cs +++ b/AutoTag.Core/Files/TaggingFile.cs @@ -1,14 +1,17 @@ +using AutoTag.Core.Files.Parsing; + namespace AutoTag.Core.Files; -public class TaggingFile + +public record TaggingFile { - public string Path { get; set; } = null!; - public string? SubtitlePath { get; set; } - public bool Taggable { get; set; } = true; + public required string Path { get; init; } + public List SubtitlePaths { get; init; } = []; + public bool Taggable { get; init; } = true; public string Status { get; set; } = ""; public bool Success { get; set; } = true; - public override string ToString() - { - return $"{System.IO.Path.GetFileName(Path)}: {Status}"; - } + public ParsedTVFileName? TVDetails { get; init; } + public ParsedMovieFileName? MovieDetails { get; init; } + + public override string ToString() => $"{System.IO.Path.GetFileName(Path)}: {Status}"; } \ No newline at end of file diff --git a/AutoTag.Core/GlobalUsing.cs b/AutoTag.Core/GlobalUsing.cs new file mode 100644 index 0000000..ed97197 --- /dev/null +++ b/AutoTag.Core/GlobalUsing.cs @@ -0,0 +1 @@ +global using System.Diagnostics.CodeAnalysis; \ No newline at end of file diff --git a/AutoTag.Core/IProcessor.cs b/AutoTag.Core/IProcessor.cs index 869235a..bb86f3b 100644 --- a/AutoTag.Core/IProcessor.cs +++ b/AutoTag.Core/IProcessor.cs @@ -3,7 +3,7 @@ namespace AutoTag.Core; public interface IProcessor { - Task ProcessAsync( + Task ProcessAsync( TaggingFile file ); } \ No newline at end of file diff --git a/AutoTag.Core/Movie/MovieFileMetadata.cs b/AutoTag.Core/Movie/MovieFileMetadata.cs index bac0a66..abe552e 100644 --- a/AutoTag.Core/Movie/MovieFileMetadata.cs +++ b/AutoTag.Core/Movie/MovieFileMetadata.cs @@ -1,53 +1,41 @@ using AutoTag.Core.Config; +using AutoTag.Core.Files; +using TagLib; +using TagLib.Mpeg4; +using File = TagLib.File; namespace AutoTag.Core.Movie; public class MovieFileMetadata : FileMetadata { - public DateTime? Date { get; set; } + private const byte StikMovie = 9; + public DateTime? Date { get; init; } - public override void WriteToFile(TagLib.File file, AutoTagConfig config, IUserInterface ui) + public override void WriteToFile(File file, AutoTagConfig config, IUserInterface ui) { base.WriteToFile(file, config, ui); if (Date.HasValue) { - file.Tag.Year = (uint) Date.Value.Year; + file.Tag.Year = (uint)Date.Value.Year; } - if (config.AppleTagging && (file.TagTypes & TagLib.TagTypes.Apple) == TagLib.TagTypes.Apple) + if (config.AppleTagging && (file.TagTypes & TagTypes.Apple) == TagTypes.Apple) { - var appleTags = (TagLib.Mpeg4.AppleTag) file.GetTag(TagLib.TagTypes.Apple); + var appleTags = (AppleTag)file.GetTag(TagTypes.Apple); // Media Type - allows Apple software to recognise as a movie - appleTags.SetData("stik", new TagLib.ByteVector(StikMovie), (uint) TagLib.Mpeg4.AppleDataBox.FlagType.ContainsData); + appleTags.SetData("stik", new ByteVector(StikMovie), (uint)AppleDataBox.FlagType.ContainsData); } } - private const byte StikMovie = 9; + public override string GetRenamePattern(AutoTagConfig config) => config.MovieRenamePattern; - public override string GetFileName(AutoTagConfig config) + public override IEnumerable GetRenameFields() { - return RenameRegex.Replace(config.MovieRenamePattern, (m) => - { - return m.Groups["num"].Value switch - { - "1" => Title!, - "2" => Date.HasValue ? FormatRenameNumber(m, Date.Value.Year) : "", - _ => m.Value - }; - }); + yield return new StringFileNameField("Title", "1", Title); + yield return new IntegerFileNameField("Year", "2", Date?.Year); } - public override string ToString() - { - if (Date.HasValue) - { - return $"{Title} ({Date.Value.Year})"; - } - else - { - return Title!; - } - } + public override string ToString() => $"{Title}{(Date.HasValue ? $" ({Date.Value.Year})" : "")}"; } \ No newline at end of file diff --git a/AutoTag.Core/Movie/MovieProcessor.cs b/AutoTag.Core/Movie/MovieProcessor.cs index 514f3e3..17fd3c8 100644 --- a/AutoTag.Core/Movie/MovieProcessor.cs +++ b/AutoTag.Core/Movie/MovieProcessor.cs @@ -1,147 +1,181 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.RegularExpressions; -using AutoTag.Core.Config; -using AutoTag.Core.Files; -using AutoTag.Core.TMDB; -using TMDbLib.Objects.General; -using TMDbLib.Objects.Search; - -namespace AutoTag.Core.Movie; -public class MovieProcessor(ITMDBService tmdb, IFileWriter writer, IUserInterface ui, AutoTagConfig config) : IProcessor -{ - public async Task ProcessAsync(TaggingFile file) - { - if (!TryParseFileName(Path.GetFileName(file.Path), out string? title, out int? year)) - { - ui.SetStatus("Error: Failed to parse required information from filename", MessageType.Error); - return false; - } - - ui.SetStatus($"Parsed file as {title}", MessageType.Log); - - var (findMovieResult, selectedResult) = await FindMovieAsync(title, year); - switch (findMovieResult) - { - case FindResult.Fail: - return false; - case FindResult.Skip: - return true; - } - - ui.SetStatus($"Found {selectedResult!.Title} ({selectedResult.ReleaseDate?.Year.ToString() ?? "unknown year"}) on TheMovieDB", MessageType.Information); - - var result = await GetMovieMetadataAsync(selectedResult, file.Taggable); - - bool taggingSuccess = await writer.WriteAsync(file, result); - - return taggingSuccess && result.Success && result.Complete; - } - - private static readonly Regex FileNameRegex = new( - @"^((?.+?)[\. _-]?)" + // get title by reading from start to a field (whichever field comes first) - "?(" + - @"([\(]?(?<Year>(19|20)[0-9]{2})[\)]?)|" + // year - extract for use in searching - "([0-9]{3,4}(p|i))|" + // resolution (e.g. 1080p, 720i) - @"((?:PPV\.)?[HPS]DTV|[. ](?:HD)?CAM[| ]|B[DR]Rip|[.| ](?:HD-?)?TS[.| ]|(?:PPV )?WEB-?DL(?: DVDRip)?|HDRip|DVDRip|CamRip|W[EB]Rip|BluRay|DvDScr|hdtv|REMUX|3D|Half-(OU|SBS)+|4K|NF|AMZN)|" + // rip type - @"(xvid|[hx]\.?26[45]|AVC)|" + // video codec - @"(MP3|DD5\.?1|Dual[\- ]Audio|LiNE|DTS[-HD]+|AAC[.-]LC|AAC(?:\.?2\.0)?|AC3(?:\.5\.1)?|7\.1|DDP5.1)|" + // audio codec - "(REPACK|INTERNAL|PROPER)|" + // scene tags - @"\.(mp4|m4v|mkv)$" + // file extensions - ")" - ); - private bool TryParseFileName(string fileName, [NotNullWhen(true)] out string? title, out int? year) - { - Match match = FileNameRegex.Match(fileName); - if (match.Success) - { - title = match.Groups["Title"].Value.Replace('.', ' '); - - var yearStr = match.Groups["Year"].Value; - year = string.IsNullOrEmpty(yearStr) - ? null - : int.Parse(yearStr); - } - else - { - title = null; - year = null; - } - - return !string.IsNullOrEmpty(title); - } - - private async Task<(FindResult, SearchMovie?)> FindMovieAsync(string title, int? year) - { - SearchContainer<SearchMovie> searchResults; - if (year.HasValue) - { - searchResults = await tmdb.SearchMovieAsync(title, year.Value); // if year was parsed, use it to narrow down search further - } - else - { - searchResults = await tmdb.SearchMovieAsync(title); - } - - if (searchResults.Results.Count == 0) - { - ui.SetStatus($"Error: failed to find title {title} on TheMovieDB", MessageType.Error); - return (FindResult.Fail, null); - } - - if (!config.ManualMode) - { - return (FindResult.Success, searchResults.Results[0]); - } - - var selection = ui.SelectOption( - "Please choose an option", - searchResults.Results - .Select(m => $"{m.Title} ({m.ReleaseDate?.Year.ToString() ?? "Unknown"})") - .ToList() - ); - - if (selection.HasValue) - { - var selected = searchResults.Results[selection.Value]; - ui.SetStatus($"Selected {selected.Title} ({selected.ReleaseDate?.Year.ToString() ?? "Unknown"})", MessageType.Information); - - return (FindResult.Success, selected); - } - - ui.SetStatus("File skipped", MessageType.Warning); - return (FindResult.Skip, null); - } - - private async Task<MovieFileMetadata> GetMovieMetadataAsync(SearchMovie selectedResult, bool fileIsTaggable) - { - var result = new MovieFileMetadata - { - Id = selectedResult.Id, - Title = selectedResult.Title, - Overview = selectedResult.Overview, - CoverURL = string.IsNullOrEmpty(selectedResult.PosterPath) - ? null - : $"https://image.tmdb.org/t/p/original{selectedResult.PosterPath}", - Date = selectedResult.ReleaseDate - }; - - result.Genres = await tmdb.GetMovieGenreNamesAsync(selectedResult.GenreIds); - - if (config.ExtendedTagging && fileIsTaggable) - { - var credits = await tmdb.GetMovieCreditsAsync(selectedResult.Id); - - result.Director = credits.Crew.FirstOrDefault(c => c.Job == "Director")?.Name; - result.Actors = credits.Cast.Select(c => c.Name).ToList(); - result.Characters = credits.Cast.Select(c => c.Character).ToList(); - } - - if (string.IsNullOrEmpty(result.CoverURL)) - { - ui.SetStatus("Error: failed to fetch movie cover", MessageType.Error); - result.Complete = false; - } - - return result; - } -} +using AutoTag.Core.Config; +using AutoTag.Core.Files; +using AutoTag.Core.TMDB; + +namespace AutoTag.Core.Movie; + +public class MovieProcessor(ITMDBService tmdb, IFileWriter writer, IUserInterface ui, AutoTagConfig config) : IProcessor +{ + public async Task<ProcessResult> ProcessAsync(TaggingFile file) + { + if (file.MovieDetails is null) + { + return ProcessResult.ParseFailure; + } + + ui.SetStatus($"Parsed file as {file.MovieDetails}", MessageType.Log); + + var (findMovieResult, selectedResult) = await FindMovieAsync(file.MovieDetails.Title, file.MovieDetails.Year); + switch (findMovieResult) + { + case FindResult.Fail: + return ProcessResult.NotFound; + case FindResult.Skip: + return ProcessResult.Skipped; + } + + ui.SetStatus( + $"Found {selectedResult!.Title} ({selectedResult.ReleaseDate?.Year.ToString() ?? "unknown year"}) on TheMovieDB", + MessageType.Information); + + var result = await GetMovieMetadataAsync(selectedResult, file.Taggable); + + var taggingSuccess = await writer.WriteAsync(file, result); + + return taggingSuccess && result.Complete + ? ProcessResult.Success + : ProcessResult.Fail; + } + + private async Task<(FindResult, TMDBMovie?)> FindMovieAsync(string title, int? year) + { + var manualResults = new List<TMDBMovie>(); + var seenResultIds = new HashSet<int>(); + + foreach (var attempt in GetSearchAttempts(title, year)) + { + ui.DisplayMessage( + $"Searching TheMovieDB for movie {attempt.ToString(config)}", + MessageType.Log + ); + + var searchResults = await tmdb.SearchMovieAsync(attempt.Query, attempt.Language, attempt.Year); + if (searchResults.Count == 0) + { + continue; + } + + if (!config.ManualMode) + { + return (FindResult.Success, searchResults[0]); + } + + foreach (var result in searchResults.Where(result => seenResultIds.Add(result.Id))) + { + manualResults.Add(result); + } + } + + if (manualResults.Count == 0) + { + ui.SetStatus($"Error: failed to find title {title} on TheMovieDB", MessageType.Error); + return (FindResult.Fail, null); + } + + var selection = ui.SelectOption( + "Please choose an option", + manualResults + .Select(m => $"{m.Title} ({m.ReleaseDate?.Year.ToString() ?? "Unknown"})") + .ToList() + ); + + if (selection.HasValue) + { + var selected = manualResults[selection.Value]; + ui.SetStatus($"Selected {selected.Title} ({selected.ReleaseDate?.Year.ToString() ?? "Unknown"})", + MessageType.Information); + + return (FindResult.Success, selected); + } + + ui.SetStatus("File skipped", MessageType.Warning); + return (FindResult.Skip, null); + } + + private async Task<MovieFileMetadata> GetMovieMetadataAsync(TMDBMovie selectedResult, bool fileIsTaggable) + { + // refetch in metadata language if search language was different + var movie = selectedResult.Language == config.Language + ? selectedResult + : await tmdb.GetMovieAsync(selectedResult.Id); + + var result = new MovieFileMetadata + { + Id = selectedResult.Id, + Title = movie.Title, + Overview = movie.Overview, + CoverURL = string.IsNullOrEmpty(movie.PosterPath) + ? null + : $"https://image.tmdb.org/t/p/original{movie.PosterPath}", + Date = movie.ReleaseDate, + Genres = movie.Genres + }; + + if (config.ExtendedTagging && fileIsTaggable) + { + var credits = await tmdb.GetMovieCreditsAsync(selectedResult.Id); + + result.Director = credits.Crew!.FirstOrDefault(c => c.Job == "Director")?.Name; + result.Actors = credits.Cast!.Select(c => c.Name!).ToList(); + result.Characters = credits.Cast!.Select(c => c.Character!).ToList(); + } + + if (string.IsNullOrEmpty(result.CoverURL)) + { + ui.SetStatus("Error: failed to fetch movie cover", MessageType.Error); + result.Complete = false; + } + + return result; + } + + private IEnumerable<MovieSearchAttempt> GetSearchAttempts(string title, int? year) + { + foreach (var candidate in GetSearchCandidates(title)) + { + foreach (var language in GetSearchLanguages()) + { + if (year.HasValue) + { + yield return new MovieSearchAttempt(candidate, year, language); + } + + yield return new MovieSearchAttempt(candidate, null, language); + } + } + } + + private static HashSet<string> GetSearchCandidates(string title) + { + var candidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + title + }; + + var words = title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length > 4) + { + for (var removedWords = 1; removedWords <= Math.Min(3, words.Length - 3); removedWords++) + { + candidates.Add(string.Join(' ', words.Take(words.Length - removedWords))); + } + } + + return candidates; + } + + private IEnumerable<string> GetSearchLanguages() + { + IEnumerable<string> languages = [config.Language, ..config.SearchLanguages]; + + return languages.Distinct(StringComparer.OrdinalIgnoreCase); + } + + private readonly record struct MovieSearchAttempt(string Query, int? Year, string Language) + { + public string ToString(AutoTagConfig config) => + $""" + "{Query}"{(Year.HasValue ? $" ({Year.Value})" : "")}{(Language.ToLower() != config.Language ? $" [{Language}]" : "")} + """; + } +} \ No newline at end of file diff --git a/AutoTag.Core/ProcessResult.cs b/AutoTag.Core/ProcessResult.cs new file mode 100644 index 0000000..0e97d87 --- /dev/null +++ b/AutoTag.Core/ProcessResult.cs @@ -0,0 +1,10 @@ +namespace AutoTag.Core; + +public enum ProcessResult +{ + Success, + ParseFailure, + NotFound, + Skipped, + Fail +} \ No newline at end of file diff --git a/AutoTag.Core/TMDB/TMDBMovie.cs b/AutoTag.Core/TMDB/TMDBMovie.cs new file mode 100644 index 0000000..1d27343 --- /dev/null +++ b/AutoTag.Core/TMDB/TMDBMovie.cs @@ -0,0 +1,47 @@ +using TMDbLib.Objects.Search; + +namespace AutoTag.Core.TMDB; + +public class TMDBMovie +{ + private TMDBMovie() + { + } + + public int Id { get; private init; } + + public string Title { get; private init; } = null!; + + public string Overview { get; private init; } = null!; + + public string? PosterPath { get; private init; } + + public DateTime? ReleaseDate { get; private init; } + + public List<string> Genres { get; private init; } = null!; + + public string Language { get; private init; } = null!; + + public static TMDBMovie FromSearchMovie(SearchMovie movie, string language, Dictionary<int, string> genreLookup) => + new() + { + Id = movie.Id, + Title = movie.Title!, + Overview = movie.Overview!, + PosterPath = movie.PosterPath, + ReleaseDate = movie.ReleaseDate, + Genres = movie.GenreIds!.Select(g => genreLookup[g]).ToList(), + Language = language + }; + + public static TMDBMovie FromMovie(TMDbLib.Objects.Movies.Movie movie, string language) => new() + { + Id = movie.Id, + Title = movie.Title!, + Overview = movie.Overview!, + PosterPath = movie.PosterPath, + ReleaseDate = movie.ReleaseDate, + Genres = movie.Genres!.Select(g => g.Name!).ToList(), + Language = language + }; +} \ No newline at end of file diff --git a/AutoTag.Core/TMDB/TMDBService.cs b/AutoTag.Core/TMDB/TMDBService.cs index 9b12b65..d0e0139 100644 --- a/AutoTag.Core/TMDB/TMDBService.cs +++ b/AutoTag.Core/TMDB/TMDBService.cs @@ -11,6 +11,8 @@ public interface ITMDBService { Task<SearchContainer<SearchTv>> SearchTvShowAsync(string query); + Task<TvShow> GetTvShowAsync(int id); + Task<TvShow> GetTvShowWithEpisodeGroupsAsync(int id); Task<TvGroupCollection?> GetTvEpisodeGroupsAsync(string id); @@ -18,28 +20,31 @@ public interface ITMDBService Task<TvSeason?> GetTvSeasonAsync(int tvShowId, int seasonNumber); Task<List<string>> GetTvGenreNamesAsync(IEnumerable<int> genreIds); - + Task<CreditsWithGuestStars> GetTvEpisodeCreditsAsync(int tvShowId, int seasonNumber, int episodeNumber); Task<ImagesWithId> GetTvShowImagesAsync(int id); - Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0); + Task<List<TMDBMovie>> SearchMovieAsync(string query, string language, int? year); - Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreIds); + Task<TMDBMovie> GetMovieAsync(int movieId); Task<Credits> GetMovieCreditsAsync(int movieId); } public class TMDBService(TMDbClient client, AutoTagConfig config) : ITMDBService { - private Dictionary<int, string> TVGenres = []; private Dictionary<int, string> MovieGenres = []; - + private Dictionary<int, string> TVGenres = []; + public Task<SearchContainer<SearchTv>> SearchTvShowAsync(string query) - => client.SearchTvShowAsync(query, config.Language); + => client.SearchTvShowAsync(query, config.Language, includeAdult: config.IncludeAdult)!; + + public Task<TvShow> GetTvShowAsync(int id) + => client.GetTvShowAsync(id, language: config.Language)!; public Task<TvShow> GetTvShowWithEpisodeGroupsAsync(int id) - => client.GetTvShowAsync(id, TvShowMethods.EpisodeGroups, config.Language); + => client.GetTvShowAsync(id, TvShowMethods.EpisodeGroups, config.Language)!; public Task<TvGroupCollection?> GetTvEpisodeGroupsAsync(string id) => client.GetTvEpisodeGroupsAsync(id, config.Language); @@ -51,34 +56,47 @@ public async Task<List<string>> GetTvGenreNamesAsync(IEnumerable<int> genreIds) { if (TVGenres.Count == 0) { - TVGenres = (await client.GetTvGenresAsync(config.Language)) - .ToDictionary(g => g.Id, g => g.Name); + TVGenres = (await client.GetTvGenresAsync(config.Language))! + .ToDictionary(g => g.Id, g => g.Name!); } return genreIds.Select(g => TVGenres[g]).ToList(); } public Task<CreditsWithGuestStars> GetTvEpisodeCreditsAsync(int tvShowId, int seasonNumber, int episodeNumber) - => client.GetTvEpisodeCreditsAsync(tvShowId, seasonNumber, episodeNumber, config.Language); + => client.GetTvEpisodeCreditsAsync(tvShowId, seasonNumber, episodeNumber, config.Language)!; public Task<ImagesWithId> GetTvShowImagesAsync(int id) - => client.GetTvShowImagesAsync(id, $"{config.Language},null"); - - public Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0) - => client.SearchMovieAsync(query, config.Language, year: year); + => client.GetTvShowImagesAsync(id, $"{config.Language},null")!; - - public async Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreIds) + public async Task<List<TMDBMovie>> SearchMovieAsync(string query, string language, int? year) { - if (MovieGenres.Count == 0) + var results = + await client.SearchMovieAsync(query, language, includeAdult: config.IncludeAdult, year: year ?? 0); + + if (results!.Results!.Count == 0) { - MovieGenres = (await client.GetMovieGenresAsync(config.Language)) - .ToDictionary(g => g.Id, g => g.Name); + return []; } - return genreIds.Select(g => MovieGenres[g]).ToList(); + await GetMovieGenreNamesAsync(); + + return results.Results.Select(r => TMDBMovie.FromSearchMovie(r, language, MovieGenres)).ToList(); } + public async Task<TMDBMovie> GetMovieAsync(int movieId) + => TMDBMovie.FromMovie((await client.GetMovieAsync(movieId, config.Language))!, config.Language); + public Task<Credits> GetMovieCreditsAsync(int movieId) - => client.GetMovieCreditsAsync(movieId); + => client.GetMovieCreditsAsync(movieId)!; + + + private async Task GetMovieGenreNamesAsync() + { + if (MovieGenres.Count == 0) + { + MovieGenres = (await client.GetMovieGenresAsync(config.Language))! + .ToDictionary(g => g.Id, g => g.Name!); + } + } } \ No newline at end of file diff --git a/AutoTag.Core/TV/EpisodeNumberMapping.cs b/AutoTag.Core/TV/EpisodeNumberMapping.cs new file mode 100644 index 0000000..bf4ba71 --- /dev/null +++ b/AutoTag.Core/TV/EpisodeNumberMapping.cs @@ -0,0 +1,23 @@ +using TMDbLib.Objects.Search; +using TMDbLib.Objects.TvShows; + +namespace AutoTag.Core.TV; + +public class EpisodeNumberMapping(List<TvSeason> seasons) +{ + public (TvSeason Season, TvSeasonEpisode Episode)? GetByEpisodeNumber(int episodeNumber) + { + var episodeCounter = 0; + foreach (var season in seasons) + { + if (episodeNumber <= episodeCounter + season.Episodes!.Count) + { + return (season, season.Episodes[episodeNumber - episodeCounter - 1]); + } + + episodeCounter += season.Episodes.Count; + } + + return null; + } +} \ No newline at end of file diff --git a/AutoTag.Core/TV/EpisodeParser.cs b/AutoTag.Core/TV/EpisodeParser.cs deleted file mode 100644 index 3b3d977..0000000 --- a/AutoTag.Core/TV/EpisodeParser.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.RegularExpressions; - -namespace AutoTag.Core.TV; -public class EpisodeParser -{ - // Based on SubtitleFetcher's filename parsing - // https://github.com/pheiberg/SubtitleFetcher - - static RegexOptions RegexOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; - static readonly Regex[] Patterns = - [ - new(@"^((?<SeriesName>.+?)[\[. _-]+)?(?<Season>\d+)x(?<Episode>\d+)(([. _-]*x|-)(?<EndEpisode>(?!(1080|720)[pi])(?!(?<=x)264)\d+))*[\]. _-]*((?<ExtraInfo>.+?)((?<![. _-])-(?<ReleaseGroup>[^-]+))?)?$", RegexOptions), - new(@"^((?<SeriesName>.+?)[. _-]+)?s(?<Season>\d+)[. _-]*e(?<Episode>\d+)(([. _-]*e|-)(?<EndEpisode>(?!(1080|720)[pi])\d+))*[. _-]*((?<ExtraInfo>.+?)((?<![. _-])-(?<ReleaseGroup>[^-]+))?)?$", RegexOptions) - ]; - - public static bool TryParseEpisodeInfo(string fileName, - [NotNullWhen(true)] out TVFileMetadata? metadata, - [NotNullWhen(false)] out string? failureReason) - { - metadata = null; - - foreach (var pattern in Patterns) - { - var match = pattern.Match(fileName); - if (!match.Success) - continue; - - var seriesName = match.Groups["SeriesName"].Value.Replace('.', ' ').Replace('_', ' ').Trim(); - var season = match.Groups["Season"].Value; - var episode = match.Groups["Episode"].Value; - - if (string.IsNullOrWhiteSpace(seriesName)) - { - failureReason = "Unable to parse series name from filename"; - return false; - } - else if (string.IsNullOrWhiteSpace(season)) - { - failureReason = "Unable to parse season from filename"; - return false; - } - else if (string.IsNullOrWhiteSpace(episode)) - { - failureReason = "Unable to parse episode from filename"; - return false; - } - - failureReason = null; - metadata = new TVFileMetadata - { - SeriesName = seriesName, - Season = int.Parse(season), - Episode = int.Parse(episode) - }; - - return true; - } - - failureReason = "Unable to parse required information from filename"; - return false; - } -} \ No newline at end of file diff --git a/AutoTag.Core/TV/ShowResults.cs b/AutoTag.Core/TV/ShowResults.cs index 983bb8b..02817a8 100644 --- a/AutoTag.Core/TV/ShowResults.cs +++ b/AutoTag.Core/TV/ShowResults.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; @@ -6,22 +5,14 @@ namespace AutoTag.Core.TV; /// <summary> -/// Container class to hold episode as well as related episode group search results +/// Container class to hold episode as well as related episode group search results /// </summary> public partial class ShowResults { - /* settings */ - [GeneratedRegex(@"^\S+\s+(?<episode>\d+)")] - private static partial Regex EpisodeRegex(); - - /* vars */ - public SearchTv TvSearchResult { get; } - - public bool HasEpisodeGroupMapping => _episodeGroupMappingTable is not null; private Dictionary<(int season, int episode), (int season, int episode)>? _episodeGroupMappingTable; /// <summary> - /// Create simple ShowResult container with <see cref="SearchTv"/> base + /// Create simple ShowResult container with <see cref="SearchTv" /> base /// </summary> /// <param name="tvSearchResult"></param> public ShowResults(SearchTv tvSearchResult) @@ -29,20 +20,27 @@ public ShowResults(SearchTv tvSearchResult) TvSearchResult = tvSearchResult; } + /* vars */ + public SearchTv TvSearchResult { get; } + + public bool HasEpisodeGroupMapping => _episodeGroupMappingTable is not null; + + /* settings */ + [GeneratedRegex(@"^\S+\s+(?<episode>\d+)")] + private static partial Regex EpisodeRegex(); + public static implicit operator ShowResults(SearchTv tv) => new(tv); /// <summary> - /// Generates <see cref="ShowResults"/> list from any <see cref="SearchTv"/> enumerable + /// Generates <see cref="ShowResults" /> list from any <see cref="SearchTv" /> enumerable /// </summary> /// <param name="results">Result from tmdb client</param> - public static List<ShowResults> FromSearchResults(IEnumerable<SearchTv> results) - { - return results.Select(result => (ShowResults)result).ToList(); - } + public static List<ShowResults> FromSearchResults(IEnumerable<SearchTv> results) => + results.Select(result => (ShowResults)result).ToList(); /// <summary> - /// Add optional episode group to tv result + /// Add optional episode group to tv result /// </summary> /// <param name="episodeGroup">Episode group fetched from tmdb api</param> /// <param name="failureReason">Reason for failure (if returns <see langword="false" />)</param> @@ -55,14 +53,15 @@ public bool AddEpisodeGroup(TvGroupCollection episodeGroup, [NotNullWhen(false)] } /// <summary> - /// Try to retrieve episode mapping for episode group order + /// Try to retrieve episode mapping for episode group order /// </summary> /// <param name="seasonNumber">Season number as defined in episode group</param> /// <param name="episodeNumber">Episode number as defined in episode group</param> /// <param name="numbering">Matching episode number and season of "standard" order</param> /// <returns>True if mapping exists, false if not</returns> public bool TryGetMapping(int seasonNumber, int episodeNumber, - [NotNullWhen(true)] out (int Season, int Episode)? numbering) + [NotNullWhen(true)] + out (int Season, int Episode)? numbering) { numbering = null; if (_episodeGroupMappingTable?.TryGetValue((seasonNumber, episodeNumber), out var result) ?? false) @@ -75,23 +74,25 @@ public bool TryGetMapping(int seasonNumber, int episodeNumber, } /// <summary> - /// Tries to generate mapping table between TMDB standard sorting of show - /// and the given Episode Group + /// Tries to generate mapping table between TMDB standard sorting of show + /// and the given Episode Group /// </summary> /// <param name="collection">Episode Group from TMDB</param> /// <param name="parsedTable">Filled parsing table. Only filled when method returns true</param> /// <param name="failureReason">Reason for failure (if returns <see langword="false" />)</param> /// <returns>True if successful, false if not</returns> private static bool TryGenerateMappingTable(TvGroupCollection collection, - [NotNullWhen(true)] out Dictionary<(int season, int episode), (int season, int episode)>? parsedTable, - [NotNullWhen(false)] out string? failureReason) + [NotNullWhen(true)] + out Dictionary<(int season, int episode), (int season, int episode)>? parsedTable, + [NotNullWhen(false)] + out string? failureReason) { parsedTable = []; - foreach (var tvGroup in collection.Groups) + foreach (var tvGroup in collection.Groups!) { // determine season number - var sanitizedGroupName = tvGroup.Name.ToLower().Trim(); + var sanitizedGroupName = tvGroup.Name!.ToLower().Trim(); int? seasonNumber = null; var seasonMatch = EpisodeRegex().Match(sanitizedGroupName); @@ -111,11 +112,11 @@ private static bool TryGenerateMappingTable(TvGroupCollection collection, } // create mapping - foreach (var episode in tvGroup.Episodes) + foreach (var episode in tvGroup.Episodes!) { var mappingIsUnique = parsedTable.TryAdd( - (seasonNumber.Value, episode.Order + 1), // order starts at 0, episodes at 1 - (episode.SeasonNumber, episode.EpisodeNumber) + (seasonNumber.Value, (int)episode.Order + 1), // order starts at 0, episodes at 1 + (episode.SeasonNumber, (int)episode.EpisodeNumber) ); if (!mappingIsUnique) diff --git a/AutoTag.Core/TV/TVCache.cs b/AutoTag.Core/TV/TVCache.cs index 4bc2a32..6ab30f2 100644 --- a/AutoTag.Core/TV/TVCache.cs +++ b/AutoTag.Core/TV/TVCache.cs @@ -1,18 +1,15 @@ -using System.Diagnostics.CodeAnalysis; using TMDbLib.Objects.TvShows; namespace AutoTag.Core.TV; public interface ITVCache { - bool ShowIsCached(string seriesName); - - void AddShow(string seriesName, List<ShowResults> show); - - List<ShowResults> GetShow(string seriesName); + void AddShow(string seriesName, int? year, List<ShowResults> show); + + bool TryGetShow(string seriesName, int? year, [NotNullWhen(true)] out List<ShowResults>? results); bool TryGetSeason(int showId, int seasonNumber, [NotNullWhen(true)] out TvSeason? season); - + void AddSeason(int showId, int seasonNumber, TvSeason season); bool TryGetSeasonPoster(int showId, int seasonNumber, [NotNullWhen(true)] out string? url); @@ -22,18 +19,17 @@ public interface ITVCache public class TVCache : ITVCache { - private readonly Dictionary<string, List<ShowResults>> CachedShows = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary<(int, int), TvSeason> CachedSeasons = new(); private readonly Dictionary<(int, int), string> CachedSeasonPosters = new(); + private readonly Dictionary<(int, int), TvSeason> CachedSeasons = new(); - public bool ShowIsCached(string seriesName) - => CachedShows.ContainsKey(seriesName); + private readonly Dictionary<(string ShowName, int? Year), List<ShowResults>> CachedShows = + new(new ShowYearComparer()); - public void AddShow(string seriesName, List<ShowResults> show) - => CachedShows.Add(seriesName, show); + public void AddShow(string seriesName, int? year, List<ShowResults> show) + => CachedShows.Add((seriesName, year), show); - public List<ShowResults> GetShow(string seriesName) - => CachedShows[seriesName]; + public bool TryGetShow(string seriesName, int? year, [NotNullWhen(true)] out List<ShowResults>? results) + => CachedShows.TryGetValue((seriesName, year), out results); public bool TryGetSeason(int showId, int seasonNumber, [NotNullWhen(true)] out TvSeason? season) => CachedSeasons.TryGetValue((showId, seasonNumber), out season); @@ -46,4 +42,12 @@ public bool TryGetSeasonPoster(int showId, int seasonNumber, [NotNullWhen(true)] public void AddSeasonPoster(int showId, int seasonNumber, string url) => CachedSeasonPosters.Add((showId, seasonNumber), url); +} + +internal class ShowYearComparer : IEqualityComparer<(string ShowName, int? Year)> +{ + public bool Equals((string ShowName, int? Year) x, (string ShowName, int? Year) y) + => StringComparer.OrdinalIgnoreCase.Equals(x.ShowName, y.ShowName) && x.Year == y.Year; + + public int GetHashCode((string ShowName, int? Year) obj) => HashCode.Combine(obj.ShowName, obj.Year); } \ No newline at end of file diff --git a/AutoTag.Core/TV/TVFileMetadata.cs b/AutoTag.Core/TV/TVFileMetadata.cs index dedcce9..5af9cdc 100644 --- a/AutoTag.Core/TV/TVFileMetadata.cs +++ b/AutoTag.Core/TV/TVFileMetadata.cs @@ -1,24 +1,37 @@ using AutoTag.Core.Config; +using AutoTag.Core.Files; +using TagLib; +using TagLib.Mpeg4; +using File = TagLib.File; +using Tag = TagLib.Matroska.Tag; namespace AutoTag.Core.TV; public class TVFileMetadata : FileMetadata { - public string SeriesName { get; set; } = null!; - - public int Season { get; set; } - - public int Episode { get; set; } - - public int SeasonEpisodes { get; set; } - - public override void WriteToFile(TagLib.File file, AutoTagConfig config, IUserInterface ui) + private const byte StikTVShow = 10; + + public required string SeriesName { get; init; } + + public int? Year { get; init; } + + public int Season { get; init; } + + public int Episode { get; init; } + + public int? EndEpisode { get; init; } + + public int SeasonEpisodes { get; init; } + + public int? Part { get; init; } + + public override void WriteToFile(File file, AutoTagConfig config, IUserInterface ui) { base.WriteToFile(file, config, ui); - if ((file.TagTypes & TagLib.TagTypes.Matroska) == TagLib.TagTypes.Matroska) + if ((file.TagTypes & TagTypes.Matroska) == TagTypes.Matroska) { - var custom = (TagLib.Matroska.Tag)file.GetTag(TagLib.TagTypes.Matroska); + var custom = (Tag)file.GetTag(TagTypes.Matroska); // workaround for https://github.com/mono/taglib-sharp/issues/263 - Tag.Album property writes to TITLE tag instead of ALBUM // how has this still not been fixed?? custom.Set("ALBUM", null, SeriesName); @@ -33,41 +46,43 @@ public override void WriteToFile(TagLib.File file, AutoTagConfig config, IUserIn file.Tag.Album = SeriesName; } - file.Tag.Disc = (uint) Season; - file.Tag.Track = (uint) Episode; - file.Tag.TrackCount = (uint) SeasonEpisodes; + file.Tag.Disc = (uint)Season; + file.Tag.Track = (uint)Episode; + file.Tag.TrackCount = (uint)SeasonEpisodes; // set extra tags because Apple is stupid and uses different tags for some reason // for a list of tags see https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/ - if (config.AppleTagging && (file.TagTypes & TagLib.TagTypes.Apple) == TagLib.TagTypes.Apple) + if (config.AppleTagging && (file.TagTypes & TagTypes.Apple) == TagTypes.Apple) { - var appleTags = (TagLib.Mpeg4.AppleTag) file.GetTag(TagLib.TagTypes.Apple); + var appleTags = (AppleTag)file.GetTag(TagTypes.Apple); // Media Type - allows Apple software to recognise as a TV show // for a list of values see http://www.zoyinc.com/?p=1004 - appleTags.SetData("stik", new TagLib.ByteVector(StikTVShow), (uint) TagLib.Mpeg4.AppleDataBox.FlagType.ContainsData); + appleTags.SetData("stik", new ByteVector(StikTVShow), (uint)AppleDataBox.FlagType.ContainsData); // Series appleTags.SetText("tvsh", SeriesName); - if (Season >= byte.MinValue && Season <= byte.MaxValue) + if (Season is >= byte.MinValue and <= byte.MaxValue) { // Season number - appleTags.SetData("tvsn", new TagLib.ByteVector((byte) Season), (uint) TagLib.Mpeg4.AppleDataBox.FlagType.ContainsData); + appleTags.SetData("tvsn", new ByteVector((byte)Season), (uint)AppleDataBox.FlagType.ContainsData); } else { - ui.SetStatus($"Warning: cannot add Apple tag for season number - value out of range", MessageType.Warning); + ui.SetStatus("Warning: cannot add Apple tag for season number - value out of range", + MessageType.Warning); } - if (Episode >= byte.MinValue && Episode <= byte.MaxValue) + if (Episode is >= byte.MinValue and <= byte.MaxValue) { // Episode number - appleTags.SetData("tves", new TagLib.ByteVector((byte) Episode), (uint) TagLib.Mpeg4.AppleDataBox.FlagType.ContainsData); + appleTags.SetData("tves", new ByteVector((byte)Episode), (uint)AppleDataBox.FlagType.ContainsData); } else { - ui.SetStatus($"Warning: cannot add Apple tag for episode number - value out of range", MessageType.Warning); + ui.SetStatus("Warning: cannot add Apple tag for episode number - value out of range", + MessageType.Warning); } // Sort name - allows older Apple software to sort correctly (sorts by title instead of season and episode on older devices) @@ -75,32 +90,19 @@ public override void WriteToFile(TagLib.File file, AutoTagConfig config, IUserIn } } - private const byte StikTVShow = 10; + public override string GetRenamePattern(AutoTagConfig config) => config.TVRenamePattern; - public override string GetFileName(AutoTagConfig config) + public override IEnumerable<IFileNameField> GetRenameFields() { - return RenameRegex.Replace(config.TVRenamePattern, (m) => - { - return m.Groups["num"].Value switch - { - "1" => SeriesName, - "2" => FormatRenameNumber(m, Season), - "3" => FormatRenameNumber(m, Episode), - "4" => Title!, - _ => m.Value - }; - }); + yield return new StringFileNameField("Series", "1", SeriesName); + yield return new IntegerFileNameField("Season", "2", Season); + yield return new IntegerFileNameField("Episode", "3", Episode); + yield return new StringFileNameField("Title", "4", Title); + yield return new IntegerFileNameField("Year", null, Year); + yield return new IntegerFileNameField("EndEpisode", null, EndEpisode); + yield return new IntegerFileNameField("Part", null, Part); } public override string ToString() - { - if (!string.IsNullOrEmpty(Title)) - { - return $"{SeriesName} S{Season:00}E{Episode:00} ({Title})"; - } - else - { - return $"{SeriesName} S{Season:00}E{Episode:00}"; - } - } + => $"{SeriesName} S{Season:00}E{Episode:00} ({Title})"; } \ No newline at end of file diff --git a/AutoTag.Core/TV/TVProcessor.cs b/AutoTag.Core/TV/TVProcessor.cs index d71e2cd..ced1a6f 100644 --- a/AutoTag.Core/TV/TVProcessor.cs +++ b/AutoTag.Core/TV/TVProcessor.cs @@ -1,61 +1,71 @@ -using System.Text.RegularExpressions; -using AutoTag.Core.Config; +using AutoTag.Core.Config; using AutoTag.Core.Files; +using AutoTag.Core.Files.Parsing; using AutoTag.Core.TMDB; using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; namespace AutoTag.Core.TV; -public class TVProcessor(ITMDBService tmdb, IFileWriter writer, ITVCache cache, IUserInterface ui, AutoTagConfig config) : IProcessor + +public class TVProcessor(ITMDBService tmdb, IFileWriter writer, ITVCache cache, IUserInterface ui, AutoTagConfig config) + : IProcessor { - public async Task<bool> ProcessAsync(TaggingFile file) + private readonly Dictionary<int, EpisodeNumberMapping> _episodeNumberMappings = new(); + + public async Task<ProcessResult> ProcessAsync(TaggingFile file) { - var metadata = ParseFileName(file); - if (metadata == null) + if (file.TVDetails is null) { - return false; + return ProcessResult.ParseFailure; } - ui.SetStatus($"Parsed file as {metadata}", MessageType.Log); + ui.SetStatus($"Parsed file as {file.TVDetails}", MessageType.Log); - var findShowResult = await FindShowAsync(metadata.SeriesName); + var (findShowResult, showResults) = await FindShowAsync(file.TVDetails.SeriesName, file.TVDetails.Year); switch (findShowResult) { case FindResult.Fail: - return false; + return ProcessResult.NotFound; case FindResult.Skip: ui.SetStatus("File skipped", MessageType.Warning); - return true; + return ProcessResult.Skipped; } + TVFileMetadata? metadata = null; string? lastResultMessage = null; - + // try searching for episode in each series search result - foreach (var show in cache.GetShow(metadata.SeriesName)) + foreach (var show in showResults!) { - var (findEpisodeResult, _lastResultMessage) = await FindEpisodeAsync(metadata, show, file.Taggable); - lastResultMessage = _lastResultMessage; - + var (findEpisodeResult, resultMetadata, resultMessage) = + await FindEpisodeAsync(file.TVDetails, show, file.Taggable); + lastResultMessage = resultMessage; + if (findEpisodeResult == FindResult.Fail) { - return false; + return ProcessResult.NotFound; } - else if (findEpisodeResult == FindResult.Success) + + if (findEpisodeResult == FindResult.Success) { + metadata = resultMetadata; lastResultMessage = null; break; } } // if reached the end of the search results without finding the episode - if (lastResultMessage != null) + if (metadata is null) { - ui.SetStatus(lastResultMessage, MessageType.Error); + if (lastResultMessage is not null) + { + ui.SetStatus(lastResultMessage, MessageType.Error); + } - return false; + return ProcessResult.NotFound; } - ui.SetStatus($"Found {metadata} ({metadata.Title}) on TheMovieDB", MessageType.Information); + ui.SetStatus($"Found {metadata} on TheMovieDB", MessageType.Information); if (config.AddCoverArt && string.IsNullOrEmpty(metadata.CoverURL) && file.Taggable) { @@ -64,63 +74,30 @@ public async Task<bool> ProcessAsync(TaggingFile file) var taggingSuccess = await writer.WriteAsync(file, metadata); - return taggingSuccess && metadata.Success && metadata.Complete; + return taggingSuccess && metadata.Complete + ? ProcessResult.Success + : ProcessResult.Fail; } - public TVFileMetadata? ParseFileName(TaggingFile file) + public async Task<(FindResult Result, List<ShowResults>? Shows)> FindShowAsync(string seriesName, int? year) { - if (string.IsNullOrEmpty(config.ParsePattern)) + if (cache.TryGetShow(seriesName, year, out var cachedResult)) { - if (EpisodeParser.TryParseEpisodeInfo(Path.GetFileName(file.Path), out var parsedMetadata, - out string? failureReason)) - { - return parsedMetadata; - } - else - { - ui.SetStatus($"Error: {failureReason}", MessageType.Error); - return null; - } - } - else - { - try - { - var match = Regex.Match(Path.GetFullPath(file.Path), config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - - return new TVFileMetadata - { - SeriesName = match.Groups["SeriesName"].Value, - Season = int.Parse(match.Groups["Season"].Value), - Episode = int.Parse(match.Groups["Episode"].Value) - }; - } - catch (FormatException ex) - { - ui.SetStatus("Error: Unable to parse required information from filename", MessageType.Error, ex); - return null; - } + return (FindResult.Success, cachedResult); } - } - public async Task<FindResult> FindShowAsync(string seriesName) - { - if (cache.ShowIsCached(seriesName)) - { - return FindResult.Success; - } - // if not already searched for series var searchResults = await tmdb.SearchTvShowAsync(seriesName); - var seriesResults = searchResults.Results - .OrderByDescending(searchResult => SeriesNameSimilarity(seriesName, searchResult.Name)) + var seriesResults = searchResults.Results! + .OrderByDescending(searchResult => SeriesNameSimilarity(seriesName, searchResult.Name!)) + .ThenByDescending(r => r.FirstAirDate.HasValue && r.FirstAirDate.Value.Year == year) .ToList(); if (seriesResults.Count == 0) { ui.SetStatus($"Error: Cannot find series {seriesName} on TheMovieDB", MessageType.Error); - return FindResult.Fail; + return (FindResult.Fail, null); } List<ShowResults> resultsToCache; @@ -129,7 +106,7 @@ public async Task<FindResult> FindShowAsync(string seriesName) var chosen = ui.SelectOption( "Please choose an option:", seriesResults - .Select(t => $"{t.Name} ({t.FirstAirDate?.Year.ToString() ?? "Unknown"})") + .Select(t => $"{t.Name} ({t.FirstAirDate?.Year.ToString() ?? "Unknown year"})") .ToList() ); @@ -137,11 +114,13 @@ public async Task<FindResult> FindShowAsync(string seriesName) { var chosenSeries = seriesResults[chosen.Value]; resultsToCache = [chosenSeries]; - ui.SetStatus($"Selected {chosenSeries.Name} ({chosenSeries.FirstAirDate?.Year.ToString() ?? "Unknown"})", MessageType.Information); + ui.SetStatus( + $"Selected {chosenSeries.Name} ({chosenSeries.FirstAirDate?.Year.ToString() ?? "Unknown year"})", + MessageType.Information); } else { - return FindResult.Skip; + return (FindResult.Skip, null); } } else @@ -149,13 +128,14 @@ public async Task<FindResult> FindShowAsync(string seriesName) resultsToCache = ShowResults.FromSearchResults(seriesResults); } + var result = FindResult.Success; if (config.EpisodeGroup) { - var (result, newShow) = await FindEpisodeGroupAsync(resultsToCache); + var (groupResult, newShow) = await FindEpisodeGroupAsync(resultsToCache); - if (result.HasValue) + if (groupResult.HasValue) { - return result.Value; + result = groupResult.Value; } if (newShow != null) @@ -163,20 +143,20 @@ public async Task<FindResult> FindShowAsync(string seriesName) resultsToCache = [newShow]; } } - - cache.AddShow(seriesName, resultsToCache); - return FindResult.Success; + + cache.AddShow(seriesName, year, resultsToCache); + return (result, resultsToCache); } public async Task<(FindResult?, ShowResults?)> FindEpisodeGroupAsync(List<ShowResults> searchResults) { - for (int i = 0; i < searchResults.Count; i++) + for (var i = 0; i < searchResults.Count; i++) { var seriesResult = searchResults[i]; var tvShow = await tmdb.GetTvShowWithEpisodeGroupsAsync(seriesResult.TvSearchResult.Id); var groups = tvShow.EpisodeGroups; - if (groups.Results.Count != 0) + if (groups!.Results!.Count != 0) { var options = groups.Results .Select(g => $"[{g.Type}] {g.Name} ({g.GroupCount} seasons, {g.EpisodeCount} episodes)") @@ -186,7 +166,7 @@ public async Task<FindResult> FindShowAsync(string seriesName) { options.Add("(Skip to next search result)"); } - + var chosenGroup = ui.SelectOption( $"Please choose an episode ordering for {seriesResult.TvSearchResult.Name}:", options @@ -199,109 +179,170 @@ public async Task<FindResult> FindShowAsync(string seriesName) // skip to next search result option was selected continue; } - - var groupInfo = await tmdb.GetTvEpisodeGroupsAsync(groups.Results[chosenGroup.Value].Id); + + var groupInfo = await tmdb.GetTvEpisodeGroupsAsync(groups.Results[chosenGroup.Value].Id!); if (groupInfo is null) { ui.SetStatus($@"Error: Could not retrieve TV episode group for show ""{tvShow.Name}""", MessageType.Error); return (FindResult.Fail, null); } - else if (seriesResult.AddEpisodeGroup(groupInfo, out string? failureReason)) + + if (seriesResult.AddEpisodeGroup(groupInfo, out var failureReason)) { ui.SetStatus($"Selected {groupInfo.Name} episode ordering", MessageType.Information); - + // override cache to force the show for the selected episode group when there were // multiple search results return (null, seriesResult); } - else - { - ui.SetStatus($@"Error: Cannot process episode group ""{groupInfo.Name}"" ({failureReason})", MessageType.Error); - return (FindResult.Fail, null); - } - } - else - { - return (FindResult.Skip, null); + + ui.SetStatus($@"Error: Cannot process episode group ""{groupInfo.Name}"" ({failureReason})", + MessageType.Error); + return (FindResult.Fail, null); } + + return (FindResult.Skip, null); } - else - { - ui.SetStatus($@"No episode groups found for show ""{tvShow.Name}""", MessageType.Warning | MessageType.Log); - } + + ui.SetStatus($@"No episode groups found for show ""{tvShow.Name}""", MessageType.Warning | MessageType.Log); } - + ui.SetStatus("No episode groups found", MessageType.Warning); return (null, null); } - public async Task<(FindResult Result, string? LastResultErrorMessage)> FindEpisodeAsync(TVFileMetadata metadata, ShowResults show, bool fileIsTaggable) + public async Task<(FindResult Result, TVFileMetadata? metadata, string? LastResultErrorMessage)> FindEpisodeAsync( + ParsedTVFileName parsedDetails, + ShowResults show, bool fileIsTaggable) { var showData = show.TvSearchResult; - + // lookup season/episode is the episode number in the default ordering // if episode groups are used we need to map from the ordering scheme used in the file name to the default // ordering to find the episode details - var lookupSeason = metadata.Season; - var lookupEpisode = metadata.Episode; + var lookupSeason = parsedDetails.Season; + var lookupEpisode = parsedDetails.Episode; if (show.HasEpisodeGroupMapping) { - if (show.TryGetMapping(metadata.Season, metadata.Episode, out var groupNumbering)) + if (!parsedDetails.Season.HasValue) + { + ui.SetStatus("Error: Cannot apply episode group numbering to absolute episode numbers", + MessageType.Error); + return (FindResult.Fail, null, null); + } + + if (show.TryGetMapping(parsedDetails.Season.Value, parsedDetails.Episode, out var groupNumbering)) { lookupSeason = groupNumbering.Value.Season; lookupEpisode = groupNumbering.Value.Episode; } else { - ui.SetStatus($"Error: Cannot find {metadata} in episode group on TheMovieDB", MessageType.Error); - return (FindResult.Fail, null); + ui.SetStatus($"Error: Cannot find {parsedDetails} in episode group on TheMovieDB", MessageType.Error); + return (FindResult.Fail, null, null); } } - metadata.Id = showData.Id; - metadata.SeriesName = showData.Name; + var result = await GetEpisodeAsync(showData.Id, lookupSeason, lookupEpisode); + if (result is null) + { + return (FindResult.Skip, null, $"Error: Cannot find {parsedDetails} on TheMovieDB"); + } - if (!cache.TryGetSeason(showData.Id, lookupSeason, out var seasonResult)) + var metadata = new TVFileMetadata { - seasonResult = await tmdb.GetTvSeasonAsync(showData.Id, lookupSeason); + Id = showData.Id, + SeriesName = showData.Name!, + Year = parsedDetails.Year, + Season = parsedDetails.Season ?? result.Value.Season.SeasonNumber, + Episode = parsedDetails.Season.HasValue + ? parsedDetails.Episode + : (int)result.Value.Episode.EpisodeNumber, + EndEpisode = parsedDetails.EndEpisode, + SeasonEpisodes = result.Value.Season.Episodes!.Count, + CoverURL = !string.IsNullOrEmpty(result.Value.Season.PosterPath) + ? $"https://image.tmdb.org/t/p/original/{result.Value.Season.PosterPath}" + : null, + Title = result.Value.Episode.Name!, + Overview = result.Value.Episode.Overview, + Genres = await tmdb.GetTvGenreNamesAsync(show.TvSearchResult.GenreIds!), + Part = parsedDetails.Part + }; - if (seasonResult != null) - { - cache.AddSeason(showData.Id, lookupSeason, seasonResult); - } + if (config.ExtendedTagging && fileIsTaggable) + { + metadata.Director = result.Value.Episode.Crew!.Find(c => c.Job == "Director")?.Name; + + var credits = await tmdb.GetTvEpisodeCreditsAsync(showData.Id, result.Value.Season.SeasonNumber, + (int)result.Value.Episode.EpisodeNumber); + metadata.Actors = credits.Cast!.Select(c => c.Name!).ToArray(); + metadata.Characters = credits.Cast!.Select(c => c.Character!).ToArray(); + } + + return (FindResult.Success, metadata, null); + } + + private async Task<TvSeason?> GetSeasonAsync(int showId, int seasonNumber) + { + if (cache.TryGetSeason(showId, seasonNumber, out var seasonResult)) + { + return seasonResult; } - if (seasonResult == null || - !seasonResult.Episodes.TryFind(e => e.EpisodeNumber == lookupEpisode, out var episodeResult)) + seasonResult = await tmdb.GetTvSeasonAsync(showId, seasonNumber); + if (seasonResult != null) { - return (FindResult.Skip, $"Error: Cannot find {metadata} on TheMovieDB"); + cache.AddSeason(showId, seasonNumber, seasonResult); } - - metadata.SeasonEpisodes = seasonResult.Episodes.Count; - if (!string.IsNullOrEmpty(seasonResult.PosterPath)) + return seasonResult; + } + + private async Task<(TvSeason Season, TvSeasonEpisode Episode)?> GetEpisodeAsync(int showId, int? seasonNumber, + int episodeNumber) + { + if (seasonNumber.HasValue) { - metadata.CoverURL = $"https://image.tmdb.org/t/p/original/{seasonResult.PosterPath}"; + var season = await GetSeasonAsync(showId, seasonNumber.Value); + + if (season != null && season.Episodes!.TryFind(e => e.EpisodeNumber == episodeNumber, out var episode)) + { + return (season, episode); + } + + return null; } - metadata.Title = episodeResult.Name; - metadata.Overview = episodeResult.Overview; - - metadata.Genres = await tmdb.GetTvGenreNamesAsync(show.TvSearchResult.GenreIds); + var mapping = await GetEpisodeNumberMapping(showId); + return mapping.GetByEpisodeNumber(episodeNumber); + } - if (config.ExtendedTagging && fileIsTaggable) + private async Task<EpisodeNumberMapping> GetEpisodeNumberMapping(int showId) + { + if (_episodeNumberMappings.TryGetValue(showId, out var mapping)) + { + return mapping; + } + + var show = await tmdb.GetTvShowAsync(showId); + List<TvSeason> seasons = new(show.NumberOfSeasons); + for (var season = 1; season <= show.NumberOfSeasons; season++) { - metadata.Director = episodeResult.Crew.Find(c => c.Job == "Director")?.Name; + var seasonResult = await GetSeasonAsync(showId, season); - var credits = await tmdb.GetTvEpisodeCreditsAsync(showData.Id, lookupSeason, lookupEpisode); - metadata.Actors = credits.Cast.Select(c => c.Name).ToArray(); - metadata.Characters = credits.Cast.Select(c => c.Character).ToArray(); + if (seasonResult is not null) + { + seasons.Add(seasonResult); + } } - return (FindResult.Success, null); + var newMapping = new EpisodeNumberMapping(seasons); + _episodeNumberMappings[showId] = newMapping; + + return newMapping; } public async Task FindPosterAsync(TVFileMetadata metadata) @@ -314,7 +355,7 @@ public async Task FindPosterAsync(TVFileMetadata metadata) { var seriesImages = await tmdb.GetTvShowImagesAsync(metadata.Id); - if (seriesImages.Posters.Count > 0) + if (seriesImages.Posters?.Count > 0) { var bestVotedImage = seriesImages.Posters.OrderByDescending(p => p.VoteAverage).First(); @@ -328,12 +369,12 @@ public async Task FindPosterAsync(TVFileMetadata metadata) } } } - + private static double SeriesNameSimilarity(string parsedName, string seriesName) { - if (seriesName.ToLower().Contains(parsedName.ToLower())) + if (seriesName.Contains(parsedName, StringComparison.OrdinalIgnoreCase)) { - return parsedName.Length / (double) seriesName.Length; + return parsedName.Length / (double)seriesName.Length; } return 0; diff --git a/README.md b/README.md index 476a96b..5da532a 100644 --- a/README.md +++ b/README.md @@ -2,97 +2,221 @@ ### Automatic tagging and renaming of TV show episodes and movies -Inspired by [Auto TV Tagger](https://sourceforge.net/projects/autotvtagger/), AutoTag is a command-line utility to make it very easy to organise your <sup>completely legitimate</sup> TV show and movie collection. +Inspired by [Auto TV Tagger](https://sourceforge.net/projects/autotvtagger/), AutoTag is a command-line utility to make +it very easy to organise your <sup>completely legitimate</sup> TV show and movie collection. -AutoTag interprets the file name to find the specific series, season and episode, or movie title, then fetches the relevant information from TheMovieDB, adds the information to the file and renames it to a set format. - -AutoTag v3 is a rewrite of v2 in .NET Core. This means that binaries can now be run natively on Linux without Mono! It also has a proper fully-functional command-line interface, however, **v3 is currently a command-line only application**. - -This is because building cross-platform user interfaces with .NET Core is still quite difficult, and the documentation of current frameworks for this leave *a lot* to be desired. I personally use AutoTag over SSH to my server, so I have little motivation to develop a GUI that I will never use. +AutoTag interprets the file name to find the specific series, season and episode, or movie title, then fetches the +relevant information from TheMovieDB, adds the information to the file and renames it to a set format. ## Features + - Information fetched from [themoviedb.org](https://www.themoviedb.org/) - Configurable renaming and full metadata tagging, including cover art - Manual tagging mode -- Full Linux support (and presumably macOS?) -- Supports mp4 and mkv containers -- Subtitle file renaming +- Supports tagging mp4 and mkv containers +- Subtitle file renaming for .srt, .vtt, .sub, .ssa and .ass files ## Usage + ``` USAGE: - autotag [paths] [OPTIONS] + autotag [paths] [OPTIONS] ARGUMENTS: - [paths] Files or directories to process + [paths] Files or directories to process OPTIONS: - -h, --help Prints help information - -c, --config <PATH> Config file path - -p, --pattern <PATTERN> Custom regex to parse TV episode information - -v, --verbose Enable verbose output mode - --set-default Set the current arguments as the default - --print-config Print loaded configuration and exit - --version Print version and exit - --no-rename Disable file and subtitle renaming - --tv-pattern <PATTERN> Rename pattern for TV episodes - --movie-pattern <PATTERN> Rename pattern for movies - --windows-safe Remove invalid Windows file name characters when renaming - --rename-subs Rename subtitle files - --replace <REPLACE=REPLACEMENT> Replace <REPLACE> with <REPLACEMENT> in file names - -t, --tv TV tagging mode - -m, --movie Movie tagging mode - --no-tag Disable file tagging - --no-cover Disable cover art tagging - --manual Manually choose the TV series/movie for a file from search results - --extended-tagging Add more information to Matroska file tags. Reduces tagging speed - --apple-tagging Add extra tags to mp4 files for use with Apple devices and software - -l, --language <LANGUAGE> Metadata language (default: en) - -g, --episode-group Manually choose alternate episode orderings for a TV show - + -h, --help Prints help information + -c, --config <PATH> Config file path + -p, --pattern <PATTERN> Custom regex to parse TV episode information + -v, --verbose Enable verbose output mode + --set-default Set the current arguments as the default + --print-config Print loaded configuration and exit + --version Print version and exit + --no-rename Disable file and subtitle renaming + --tv-pattern <PATTERN> Rename pattern for TV episodes + --movie-pattern <PATTERN> Rename pattern for movies + --windows-safe Remove invalid Windows file name characters when renaming + --rename-subs Rename subtitle files + --replace <REPLACE=REPLACEMENT> Replace <REPLACE> with <REPLACEMENT> in file names + -a, --auto Auto tagging mode + -t, --tv TV tagging mode + -m, --movie Movie tagging mode + --no-tag Disable file tagging + --no-cover Disable cover art tagging + --manual Manually choose the TV series/movie for a file from search results + --extended-tagging Add more information to Matroska file tags. Reduces tagging speed + --apple-tagging Add extra tags to mp4 files for use with Apple devices and software + -l, --language <LANGUAGE> Metadata language (default: en) + --search-language <LANGUAGE> Additional languages to use when searching TMDB + -g, --episode-group Manually choose alternate episode orderings for a TV show + --include-adult Include adult titles in TMDB searches + --remove-empty-folders Remove source folders after moving files if they are empty ``` -### Rename Patterns -The TV and movie rename patterns are strings used to create the new file name when renaming is enabled. They can use the following variables: +## Parsing + +AutoTag should be able to parse most common naming schemes for TV and movie files. + +AutoTag defaults to auto tagging mode, which will try to parse and tag files as a TV episode then a movie. You can use +the `-t/--tv` and `-m/--movie` options to specify the media type and force it to only parse and tag that type. + +For TV both season-episode and absolute episode numbering is supported (though absolute numbering may incur a +performance penalty due to the extra data lookups required). **Absolute numbering is only supported for parsing, not +renaming**. An absolute numbered file will be renamed to season-episode numbering. + +If a year is detected in the filename this will be used to improve the chance of selecting the correct result. +Multi-episode and multi-part files are also supported (though this is only used in renaming and has no effect on +tagging). -- `%1`: TV Series Name/Movie Title -- `%2`: TV Season Number/Movie Year -- `%3`: TV Episode Number -- `%4`: TV Episode Title +### Custom Parsing Regex -#### Numeric Format Strings -Numeric variables (TV season/episode and movie year) also support a format string to specify the format of the number. They support the standard numeric format specifiers of `0` and `#`. +If AutoTag is not able to parse your file structure (e.g. if the series name is not in the file name like +`Series/Season 1/S01E01 Title.mkv`), then you can provide a custom parsing regex using the `-p` or `--pattern` option. -Example: to get the name "Series S01E01 Title.mkv", use the format `%1 S%2:00E%3:00 %4`. +The custom regex pattern is used on the full file path, not just the file name. -### Regex Pattern -The custom regex pattern is used on the full file path, not just the file name. This allows AutoTag to tag file structures where the series name is not in the file name, e.g. for the structure `Series/Season 1/S01E01 Title.mkv`. +The regex pattern should have the following named capturing groups: -The regex pattern should have 3 named capturing groups: `SeriesName`, `Season` and `Episode`. For the example given above, a pattern could be `.*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\d+)`. +- `SeriesName` +- `Season` and `Episode` OR `AbsoluteEpisode` +- `Year` (optional) +- `EndEpisode` (optional) +- `Part` (optional) + +For the example given above, a pattern could be `.*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\d+)`. Note that on Windows all directory separators (`\`) must be escaped as `\\`. +## Renaming + +AutoTag supports both relative and absolute rename patterns. An absolute rename pattern is an absolute path (i.e. +contains directories), so this can be used to automatically move files into a particular directory. + +When using an absolute rename pattern the `--remove-empty-folders` option can be enabled to automatically delete the +directory a file was sourced from after moving to the new directory. + +### Rename Fields + +AutoTag supports the following rename fields for TV and movie files. I recommend using the new specifiers as they are +more flexible (see [Formatting](#formatting) section below), but the legacy specifiers are still supported. New and +legacy specifiers can be mixed in the same rename pattern. + +#### TV + +| Specifier | Legacy Specifier | Description | +|----------------|------------------|----------------------------------------------------------------------| +| `{Series}` | `%1` | TV series/show name | +| `{Season}` | `%2` | TV season number | +| `{Episode}` | `%3` | TV episode number | +| `{Title}` | `%4` | TV episode title | +| `{Year}` | - | TV show year<sup>1</sup> | +| `{EndEpisode}` | - | TV end episode (for multi-episode files)<sup>1</sup> | +| `{Part}` | - | TV episode part (for episodes split into multiple files)<sup>1</sup> | + +<sup>1</sup>**The `Year`, `EndEpisode` and `Part` fields will only be present if the original file had them in the +name.** +These fields are provided so you can persist this information and avoid the files being renamed back every time AutoTag +is run on them. + +#### Movies + +| Specifier | Legacy Specifier | Description | +|-----------|------------------|-------------| +| `{Title}` | `%1` | Movie title | +| `{Year}` | `%2` | Movie year | + +#### Formatting + +Numeric variables (TV season/episode and movie year) also support a format string to specify the format of the number. +They support the standard numeric format specifiers of `0` and `#`. + +Example: to get the name "Series S01E01 Title.mkv", use the format `{Series} {Season:S00}{Episode:E00} {Title}`. + +Other characters can be included in the format and the `0` and `#` format strings will be replaced with the value for +that field. + +A fallback can also be provided for when the value is `null` or `0` by adding a pipe (`|`) in the format followed by the +fallback value. For example, to use "Specials" instead of "Season 0" use `{Season:Season 0|Specials}`. To omit the field +entirely when the value is missing simply provide an empty fallback (e.g. `{Year: (0)|}`). + +Note: legacy specifiers only support `0` and `#` in the format - to embed other characters in the format you must use +the new specifiers. + +### Examples + +| Output | Pattern | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| Series S01E02 | `{Series} {Season:S00}{Episode:E00}` | +| Series 1x02 Title | `{Series} {Season}x{Episode:00} {Title}` | +| Series (2005) S01E02 | `{Series}{Year: (0)\|} {Season:S00}{Episode:E00}` | +| Series S01E02-03 | `{Series} {Season:S00}{Episode:E00}{EndEpisode:-00\|}` | +| Series S01E02 pt1 | `{Series} {Season:S00}{Episode:E00}{Part: pt0\|}` | +| /TV/Series/Season 1/Series 1x02 Title | `/TV/{Series}/{Season:Season 0\|Specials}/{Series} {Season}x{Episode:00} {Title}` | +| C:\TV\Series\Season 1\Series 1x02 Title | `C:\TV\{Series}\{Season:Season 0\|Specials}\{Series} {Season}x{Episode:00} {Title}`<sup>2</sup> | + +<sup>2</sup>Directory separators in Windows paths (`\`) will need to be escaped as `\\` if editing the config file +manually. + +### Subtitles + +The `--rename-subs` option can be enabled to rename separate subtitle files. These will be renamed alongside video files +using the same rename pattern. If there are multiple subtitle files for the same episode/movie they will have a number +appended to each file name. + ### Windows Safe -The `--windows-safe` option is for use on Linux/macOS where the files written may be accessed by a Windows host, or are being written to an NTFS filesystem. It automatically removes any invalid NTFS file name characters. + +The `--windows-safe` option is for use on Linux/macOS where the files written may be accessed by a Windows host, or are +being written to an NTFS filesystem. It automatically removes any invalid NTFS file name characters. ### File Name Replacements -The `--replace` option allows specific characters or strings in a file name to be replaced, e.g. `--replace a=b` will replace all the `a` characters in the file name with `b`. This option can be used multiple times for multiple replacements, e.g. `--replace a=b --replace foo=bar --replace c=''`. **Note: the arguments for this option are case sensitive.** -Any values for the replace option containing an equals (`=`) cannot be set via command line arguments currently. To use such values you can add them to the config file manually using a text editor. +The `--replace` option allows specific characters or strings in a file name to be replaced, e.g. `--replace a=b` will +replace all the `a` characters in the file name with `b`. This option can be used multiple times for multiple +replacements, e.g. `--replace a=b --replace foo=bar --replace c=''`. **Note: the arguments for this option are case +sensitive.** + +Any values for the replace option containing an equals (`=`) cannot be set via command line arguments currently. To use +such values you can add them to the config file manually using a text editor. + +## Tagging + +### Manual Mode + +AutoTag may not always select the correct TV series or movie, especially if there are multiple search results with the +same title. To work around this you can enable manual mode with the `--manual` option. This will display an interactive +menu for you to select the correct search result when searching for a match. Once manually selected that result will +be used for all subsequent files parsed with the same series name/movie title. ### Extended Tagging -The `--extended-tagging` option adds additional information to Matroska video files such as actors and their characters. This option is not enabled by default because it may reduce tagging speed significantly due to the additional API requests needed. + +The `--extended-tagging` option adds additional information to Matroska video files such as actors and their characters. +This option is not enabled by default because it may reduce tagging speed significantly due to the additional API +requests needed. ### Language -The language of the metadata can be set using the `-l` or `--language` option. This accepts a [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) with optional [ISO 3166 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) for regional variants. E.g., to get metadata in German use `-l de`, or for Brazilian Portuguese use `-l pt-BR`. Note that the data for other languages is probably less complete than it is for English. If data in a given language is not available it will fall back to some alternative, likely English. + +The language of the metadata can be set using the `-l` or `--language` option. This accepts +a [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) with +optional [ISO 3166 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) for regional variants. E.g., +to get metadata in German use `-l de`, or for Brazilian Portuguese use `-l pt-BR`. Note that the data for other +languages is probably less complete than it is for English. If data in a given language is not available it will fall +back to some alternative, likely English. + +Additional fallback languages for searching can be specified via the `--search-languages` option. For example, +with `-l pt-BR` and `--search-languages en-US` AutoTag will write metadata in Brazilian Portuguese, +but will retry searches in English if the Portuguese search fails. ### Alternate Episode Orderings (Episode Groups) -The `--episode-group` option allows you to choose one of the additional episodes group collections created on TMDB as source for the episode ordering. All contained episode groups must follow the naming scheme `<NAME> XX`. Episode groups whose names begin with `special` in their names are also valid and will be treated as `Season 0`. + +The `--episode-group` option allows you to choose one of the additional episodes group collections created on TMDB as +source for the episode ordering. All contained episode groups must follow the naming scheme `<NAME> XX`. Episode groups +whose names begin with `special` in their names are also valid and will be treated as `Season 0`. Enabling this option will prompt you to select the episode ordering for each show manually. -| Group Name | Valid | -|-----------------|--------| +| Group Name | Valid | +|-----------------|-------| | Season 01 | ✅ | | Staffel 02 | ✅ | | Volume 9 | ✅ | @@ -101,15 +225,21 @@ Enabling this option will prompt you to select the episode ordering for each sho | Volume Part 1 | ❌ | ## Config -AutoTag creates a config file to store default preferences at `~/.config/autotag/conf.json` or `%APPDATA%\Roaming\autotag\conf.json`. A different config file can be specified using the `-c` option. If the file does not exist, a file will be created with the default settings: + +AutoTag creates a config file to store default preferences at `~/.config/autotag/conf.json` or +`%APPDATA%\Roaming\autotag\conf.json`. A different config file can be specified using the `-c` option. If the file does +not exist, a file will be created with the default settings: + ``` -"configVer": 9, // Internal use -"mode": 0, // Default tagging mode, 0 = TV, 1 = Movie +"configVer": 14, // Internal use +"mode": 0, // Default tagging mode, 0 = TV, 1 = Movie, 2 = Auto "manualMode": false, // Manual tagging mode "verbose": false, // Verbose output "addCoverArt": true, // Add cover art to files "tagFiles": true, // Write tags to files "renameFiles": true, // Rename files +"organizeFolders": false, // Move files into TV season folders or movie folders +"removeEmptyFolders": false, // Remove source folders after moving files if they are empty "tvRenamePattern": "%1 - %2x%3:00 - %4", // Pattern to rename TV files, %1 = Series Name, %2 = Season, %3 = Episode, %4 = Episode Title "movieRenamePattern": "%1 (%2)", // Pattern to rename movie files, %1 = Title, %2 = Year "parsePattern": "", // Custom regex to parse TV episode information @@ -118,27 +248,81 @@ AutoTag creates a config file to store default preferences at `~/.config/autotag "appleTagging": false, // Add extra tags to mp4 files for use with Apple devices and software "renameSubtitles": false, // Rename subtitle files "language": "en", // Metadata language, +"searchLanguages": [], // Additional fallback languages to use when searching movies on TMDB +"includeAdult": false, // Include adult titles in TMDB searches "episodeGroup": false, // Enable alternate episode ordering selection "fileNameReplaces": [] // File name character replacements. Array of objects of the form { "replace": "", "replacement": "" } ``` -## Moving away from TheTVDB -**v3.1.0 and above use TheMovieDB as the TV metadata source instead of TheTVDB.** This is due to the declining quality of metadata, and TheTVDB's free API being deprecated in favour of a paid model. - -Unfortunately there are many differences in the episode numbering between TheTVDB and TheMovieDB, so you may have to manually rename some files in order for them to be found on TheMovieDB. In the long term this is a good thing as the numbering on TheMovieDB generally makes much more sense than TheTVDB, and is a much friendlier community. - ## Known Issues -- Some files will refuse to tag with an error such as "File not writeable" or "Invalid EBML format read". This is caused by the tagging library taglib-sharp, which sometimes refuses to tag certain files. The cause of this isn't immediately clear, but a workaround is to simply remux the file using ffmpeg (`ffmepg -i in.mkv -c copy out.mkv`), after which the file should tag successfully. + +- Some files will refuse to tag with an error such as "File not writeable" or "Invalid EBML format read". This is caused + by the tagging library taglib-sharp, which sometimes refuses to tag certain files. The cause of this isn't immediately + clear, but a workaround is to simply remux the file using ffmpeg (`ffmepg -i in.mkv -c copy out.mkv`), after which the + file should tag successfully. ## Download + Downloads for Linux, macOS and Windows can be found [here](https://github.com/jamerst/AutoTag/releases). -The macOS build is untested, I don't own any Apple devices so I can't easily test it. Please report any issues and I'll try to investigate them. +The macOS build is untested, I don't own any Apple devices so I can't easily test it. Please report any issues and I'll +try to investigate them. Build file sizes are quite large due to bundled .NET runtimes. +## Development + +To run AutoTag from source, install the .NET 10 SDK and set your TMDB API key with the `TMDB_API_KEY` environment +variable: + +PowerShell: + +```powershell +$env:TMDB_API_KEY="your_tmdb_api_key" +``` + +Linux/macOS: + +```sh +export TMDB_API_KEY="your_tmdb_api_key" +``` + +You should also set the environment variable within your IDE to allow debugging. If using Jetbrains Rider you will need +to set it in the run configuration for each project and also as an MSBuild Global Property under Settings > Build, +Execution, Deployment > Toolset and Build. + +Note that the API key only needs to be set at build-time, it is inlined into the output so doesn't need to be set at +runtime. + +To run the CLI run `dotnet run --project AutoTag.CLI -- [arguments]`. + +### Testing + +#### Unit Tests + +Unit tests should be implemented for any new features. AutoTag uses xUnit and AwesomeAssertions for tests. Unit tests +can be executed by running `dotnet test AutoTag.Core.Test`. + +**Note: unit tests should avoid side effects (e.g. writing files to disk) and should work cross-platform.** + +#### Integration Tests + +Integration tests for the command-line interface should also be implemented for any large/main path features, but they +don't need to have full coverage. + +Integration tests can be executed by running `dotnet test AutoTag.CLI.Test`. Tests are run against a production build so +you will need to set your TMDB API key as above. + +Integration test guidelines: + +- Integration tests should provide assurance that the main key features of AutoTag are functioning correctly. +- Unit tests are preferred for complex or lesser-used features as they are easier to implement and faster to execute. +- Integration tests can have side effects (e.g. write files to disk), but these should be cleaned up automatically and + avoid conflicts with other tests to allow parallel execution. +- **Integration tests must work cross-platform** - the GitHub workflows run them under Linux, macOS and Windows. + ## Attributions -- TV filename parsing based on [SubtitleFetcher](https://github.com/pheiberg/SubtitleFetcher) + - File tagging provided by [taglib-sharp](https://github.com/mono/taglib-sharp) - TheMovieDB API support provided by [TMDbLib](https://github.com/LordMike/TMDbLib) - Data sourced from [themoviedb.org](https://www.themoviedb.org/) using their free API diff --git a/autotag.sln b/autotag.sln index ef635da..02da4ae 100644 --- a/autotag.sln +++ b/autotag.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoTag.cli", "AutoTag.CLI\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoTag.Core.Test", "AutoTag.Core.Test\AutoTag.Core.Test.csproj", "{F68C84FA-E812-4D0E-9DDA-BCA5FC18D892}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoTag.CLI.Test", "AutoTag.CLI.Test\AutoTag.CLI.Test.csproj", "{FFE0E097-BEEE-4276-A898-65C4C2694271}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -58,5 +60,17 @@ Global {F68C84FA-E812-4D0E-9DDA-BCA5FC18D892}.Release|x64.Build.0 = Release|Any CPU {F68C84FA-E812-4D0E-9DDA-BCA5FC18D892}.Release|x86.ActiveCfg = Release|Any CPU {F68C84FA-E812-4D0E-9DDA-BCA5FC18D892}.Release|x86.Build.0 = Release|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Debug|x64.ActiveCfg = Debug|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Debug|x64.Build.0 = Debug|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Debug|x86.ActiveCfg = Debug|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Debug|x86.Build.0 = Debug|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Release|Any CPU.Build.0 = Release|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Release|x64.ActiveCfg = Release|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Release|x64.Build.0 = Release|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Release|x86.ActiveCfg = Release|Any CPU + {FFE0E097-BEEE-4276-A898-65C4C2694271}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal