From 6ffb726da06ffe718769ab9f8938523017150201 Mon Sep 17 00:00:00 2001 From: mozartsempiano Date: Tue, 17 Mar 2026 20:32:59 -0300 Subject: [PATCH 01/35] Improve media detection, TMDB fallback, and CLI key handling --- AutoTag.CLI/ApiKeys.cs | 31 ++ AutoTag.CLI/AutoTag.CLI.csproj | 3 - AutoTag.CLI/CLIInterface.cs | 75 ++++- AutoTag.CLI/Keys.cs.template | 4 +- AutoTag.CLI/RootCommand.cs | 13 +- .../Files/FileWriter/WriteAsync.cs | 77 +++++ .../TV/TVProcessor/FindShowAsync.cs | 36 +- .../TV/TVProcessor/ParseFileName.cs | 5 +- AutoTag.Core/Config/AutoTagConfig.cs | 6 +- AutoTag.Core/Files/FileWriter.cs | 36 +- AutoTag.Core/Movie/MovieNameNormalizer.cs | 229 +++++++++++++ AutoTag.Core/Movie/MovieProcessor.cs | 313 ++++++++++-------- AutoTag.Core/TMDB/TMDBService.cs | 14 +- AutoTag.Core/TV/EpisodeParser.cs | 12 +- AutoTag.Core/TV/TVProcessor.cs | 81 +++-- README.md | 5 +- 16 files changed, 723 insertions(+), 217 deletions(-) create mode 100644 AutoTag.CLI/ApiKeys.cs create mode 100644 AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs create mode 100644 AutoTag.Core/Movie/MovieNameNormalizer.cs diff --git a/AutoTag.CLI/ApiKeys.cs b/AutoTag.CLI/ApiKeys.cs new file mode 100644 index 0000000..145ac5d --- /dev/null +++ b/AutoTag.CLI/ApiKeys.cs @@ -0,0 +1,31 @@ +using System.Reflection; + +namespace AutoTag.CLI; + +public static class ApiKeys +{ + public static string? TMDBKey + { + get + { + var envKey = Normalise(Environment.GetEnvironmentVariable("TMDB_API_KEY")); + if (envKey != null) + { + return envKey; + } + + // Support the repo's older optional Keys.cs override without requiring it for builds. + var legacyKeysType = Assembly.GetExecutingAssembly().GetType("AutoTag.CLI.Keys"); + var legacyKey = legacyKeysType? + .GetProperty("TMDBKey", BindingFlags.Public | BindingFlags.Static)? + .GetValue(null) as string; + + return Normalise(legacyKey); + } + } + + private static string? Normalise(string? value) + => string.IsNullOrWhiteSpace(value) || value == "TMDB_API_KEY" + ? null + : value; +} diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 1f98609..e8dfa77 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -29,7 +29,4 @@ - - - diff --git a/AutoTag.CLI/CLIInterface.cs b/AutoTag.CLI/CLIInterface.cs index 1ba4f6a..85662c7 100644 --- a/AutoTag.CLI/CLIInterface.cs +++ b/AutoTag.CLI/CLIInterface.cs @@ -1,6 +1,7 @@ using System.Reflection; using AutoTag.Core.Config; using AutoTag.Core.Files; +using AutoTag.Core.Movie; using Microsoft.Extensions.DependencyInjection; namespace AutoTag.CLI; @@ -18,7 +19,8 @@ public class CLIInterface(IServiceProvider serviceProvider) : IUserInterface public async Task RunAsync(IEnumerable entries) { Config = serviceProvider.GetRequiredService(); - var processor = serviceProvider.GetRequiredKeyedService(Config.Mode); + var movieProcessor = serviceProvider.GetRequiredKeyedService(Mode.Movie); + var tvProcessor = serviceProvider.GetRequiredKeyedService(Mode.TV); var fileFinder = serviceProvider.GetRequiredService(); AnsiConsole.WriteLine($"AutoTag v{GetVersion()}"); @@ -37,7 +39,7 @@ public async Task RunAsync(IEnumerable entries) CurrentFile = file; AnsiConsole.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]"); - Success &= await processor.ProcessAsync(file); + Success &= await ProcessWithFallbackAsync(file, movieProcessor, tvProcessor); } return ReportResults(Files.Count); @@ -177,4 +179,71 @@ public void SetStatus(string status, MessageType type, Exception ex) public void SetFilePath(string path) { } -} \ No newline at end of file + + private async Task ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor, IProcessor tvProcessor) + { + var (primaryMode, secondaryMode) = GetProcessorOrder(file.Path); + + bool successBeforeAttempt = Success; + var primaryResult = await GetProcessor(primaryMode, movieProcessor, tvProcessor).ProcessAsync(file); + if (primaryResult) + { + return true; + } + + if (!ShouldTryAlternateProcessor(file.Path, file.Status, secondaryMode)) + { + return false; + } + + DisplayMessage($"Retrying as {(secondaryMode == Mode.Movie ? "movie" : "TV")}", MessageType.Warning); + + Success = successBeforeAttempt; + file.Success = true; + file.Status = ""; + + return await GetProcessor(secondaryMode, movieProcessor, tvProcessor).ProcessAsync(file); + } + + private (Mode Primary, Mode Secondary) GetProcessorOrder(string path) + { + var fileName = Path.GetFileName(path); + if (MovieNameNormalizer.LooksLikeTvEpisode(fileName)) + { + return (Mode.TV, Mode.Movie); + } + + if (MovieNameNormalizer.LooksLikeMovieCandidate(fileName)) + { + return (Mode.Movie, Mode.TV); + } + + return Config.Mode == Mode.Movie + ? (Mode.Movie, Mode.TV) + : (Mode.TV, Mode.Movie); + } + + private static IProcessor GetProcessor(Mode mode, IProcessor movieProcessor, IProcessor tvProcessor) + => mode == Mode.Movie + ? movieProcessor + : tvProcessor; + + private static bool ShouldTryAlternateProcessor(string path, string status, Mode secondaryMode) + { + if (!IsAlternateProcessorRetryableStatus(status)) + { + return false; + } + + var fileName = Path.GetFileName(path); + return secondaryMode == Mode.TV + ? MovieNameNormalizer.LooksLikeTvEpisode(fileName) + : MovieNameNormalizer.LooksLikeMovieCandidate(fileName); + } + + private static bool IsAlternateProcessorRetryableStatus(string status) + => status.StartsWith("Error: Failed to parse required information from filename", StringComparison.OrdinalIgnoreCase) + || status.StartsWith("Error: failed to find title ", StringComparison.OrdinalIgnoreCase) + || status.StartsWith("Error: Cannot find series ", StringComparison.OrdinalIgnoreCase) + || status.StartsWith("Error: Unable to parse ", StringComparison.OrdinalIgnoreCase); +} diff --git a/AutoTag.CLI/Keys.cs.template b/AutoTag.CLI/Keys.cs.template index e9ef2d7..c89e252 100644 --- a/AutoTag.CLI/Keys.cs.template +++ b/AutoTag.CLI/Keys.cs.template @@ -1,5 +1,7 @@ /* -Copy this file to "Keys.cs" and add your API key +Optional legacy override: +- Preferred: set the TMDB_API_KEY environment variable +- Alternative: copy this file to "Keys.cs" and replace the placeholder */ namespace AutoTag.CLI { diff --git a/AutoTag.CLI/RootCommand.cs b/AutoTag.CLI/RootCommand.cs index 062a410..895dcf3 100644 --- a/AutoTag.CLI/RootCommand.cs +++ b/AutoTag.CLI/RootCommand.cs @@ -16,9 +16,10 @@ public override async Task ExecuteAsync(CommandContext context, RootCommand AnsiConsole.WriteLine(CLIInterface.GetVersion()); return 0; } - + + var tmdbKey = ApiKeys.TMDBKey; var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { DisableDefaults = true }); - builder.Services.AddCoreServices(Keys.TMDBKey); + builder.Services.AddCoreServices(tmdbKey ?? string.Empty); builder.Services.AddScoped(); using var host = builder.Build(); @@ -39,6 +40,12 @@ public override async Task ExecuteAsync(CommandContext context, RootCommand await configService.SaveToDiskAsync(); } + if (cmdSettings.Paths.Any() && string.IsNullOrWhiteSpace(tmdbKey)) + { + AnsiConsole.MarkupLine("[red]Error:[/] TMDB API key missing. Set the `TMDB_API_KEY` environment variable or add `AutoTag.CLI/Keys.cs` before processing files."); + return 1; + } + var ui = (CLIInterface)host.Services.GetRequiredService(); return await ui.RunAsync(cmdSettings.Paths); } @@ -47,4 +54,4 @@ public override async Task ExecuteAsync(CommandContext context, RootCommand { WriteIndented = true }; -} \ 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..83d824f --- /dev/null +++ b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs @@ -0,0 +1,77 @@ +using AutoTag.Core.Config; +using AutoTag.Core.Files; +using AutoTag.Core.Movie; + +namespace AutoTag.Core.Test.Files.FileWriter; + +public class WriteAsync +{ + [Fact] + public async Task Should_Skip_When_Video_And_Subtitle_Are_Already_Correctly_Named() + { + var config = new AutoTagConfig + { + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock(); + var mockUi = new Mock(); + var mockCoverArtFetcher = new Mock(); + + var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Movie (2020).mkv", + SubtitlePath = @"C:\Media\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("File skipped - already named correctly", MessageType.Information), Times.Once); + } + + [Fact] + public async Task Should_Not_Skip_When_Subtitle_Name_Is_Still_Wrong() + { + var config = new AutoTagConfig + { + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock(); + mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020).srt")) + .Returns(false); + + var mockUi = new Mock(); + var mockCoverArtFetcher = new Mock(); + + var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Movie (2020).mkv", + SubtitlePath = @"C:\Media\subtitle.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(@"C:\Media\subtitle.srt", @"C:\Media\Movie (2020).srt"), Times.Once); + mockUi.Verify(ui => ui.SetStatus("File skipped - already named correctly", MessageType.Information), Times.Never); + } +} diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs index 9858435..c103379 100644 --- a/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs +++ b/AutoTag.Core.Test/TV/TVProcessor/FindShowAsync.cs @@ -46,6 +46,40 @@ public async Task Should_ReportError_When_NoTMDBSearchResults() Times.Once ); } + + [Fact] + public async Task Should_RetryWithoutTrailingYear_When_InitialSearchHasNoResults() + { + var mockTmdb = new Mock(); + mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show (2011)")) + .ReturnsAsync(new SearchContainer { Results = [] }); + mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show")) + .ReturnsAsync(new SearchContainer + { + Results = + [ + new SearchTv + { + Id = 1, + Name = "The Looney Tunes Show" + } + ] + }); + + var mockCache = new Mock(); + var tv = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object); + + var result = await tv.FindShowAsync("The Looney Tunes Show (2011)"); + + result.Should().Be(FindResult.Success); + mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show (2011)"), Times.Once); + mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show"), Times.Once); + mockCache.Verify(c => c.AddShow( + "The Looney Tunes Show (2011)", + It.Is>(results => results.Count == 1 && results[0].TvSearchResult.Name == "The Looney Tunes Show")), + Times.Once + ); + } [Fact] public async Task Should_OnlyCacheSelectedResult_When_ManualModeEnabled() @@ -209,4 +243,4 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected() && results.Single().TvSearchResult == expectedShow) )); } -} \ No newline at end of file +} diff --git a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs b/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs index 144f937..d3b8209 100644 --- a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs +++ b/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs @@ -14,6 +14,7 @@ public class ParseFileName : TVProcessorTestBase [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 + [InlineData("Serial Experiments Lain E12 Landscape 1080p BluRay FLAC 2.0 x264-Chotab.mkv", "Serial Experiments Lain", 1, 12)] // episode-only numbering defaults to season 1 public void Should_ParseCommonNamingFormats(string fileName, string seriesName, int season, int episode) { var tv = GetInstance(); @@ -62,7 +63,7 @@ public void Should_ParseFromFullPath_When_ParsePatternProvided() [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("Continuum Episode 03.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) { @@ -102,4 +103,4 @@ public void Should_ReportError_When_ParsePatternDoesNotMatch() Times.Once ); } -} \ No newline at end of file +} diff --git a/AutoTag.Core/Config/AutoTagConfig.cs b/AutoTag.Core/Config/AutoTagConfig.cs index ceabbda..1f8ff1f 100644 --- a/AutoTag.Core/Config/AutoTagConfig.cs +++ b/AutoTag.Core/Config/AutoTagConfig.cs @@ -2,7 +2,7 @@ namespace AutoTag.Core.Config; public class AutoTagConfig { - public const int CurrentVer = 10; + public const int CurrentVer = 11; public int ConfigVer { get; set; } = CurrentVer; @@ -33,8 +33,10 @@ public class AutoTagConfig public bool RenameSubtitles { get; set; } = false; public string Language { get; set; } = "en"; + + public List SearchLanguages { get; set; } = []; public bool EpisodeGroup { get; set; } public IEnumerable FileNameReplaces { get; set; } = []; -} \ No newline at end of file +} diff --git a/AutoTag.Core/Files/FileWriter.cs b/AutoTag.Core/Files/FileWriter.cs index f9026db..c7581a6 100644 --- a/AutoTag.Core/Files/FileWriter.cs +++ b/AutoTag.Core/Files/FileWriter.cs @@ -12,6 +12,13 @@ public class FileWriter(ICoverArtFetcher coverArtFetcher, AutoTagConfig config, public async Task WriteAsync(TaggingFile taggingFile, FileMetadata metadata) { bool fileSuccess = true; + var targetFileName = GetFileName(metadata.GetFileName(config), Path.GetFileNameWithoutExtension(taggingFile.Path)); + + if (config.RenameFiles && IsAlreadyNamedCorrectly(taggingFile, targetFileName)) + { + ui.SetStatus("File skipped - already named correctly", MessageType.Information); + return true; + } if (config.TagFiles && taggingFile.Taggable) { @@ -20,11 +27,11 @@ public async Task WriteAsync(TaggingFile taggingFile, FileMetadata metadat if (config.RenameFiles) { - fileSuccess &= RenameFile(taggingFile.Path, metadata.GetFileName(config), null); + fileSuccess &= RenameFile(taggingFile.Path, targetFileName, null); if (!string.IsNullOrEmpty(taggingFile.SubtitlePath)) { - fileSuccess &= RenameFile(taggingFile.SubtitlePath, metadata.GetFileName(config), "subtitle "); + fileSuccess &= RenameFile(taggingFile.SubtitlePath, targetFileName, "subtitle "); } } @@ -93,14 +100,7 @@ private async Task TagFileAsync(TaggingFile taggingFile, FileMetadata meta private bool RenameFile(string path, string newName, string? msgPrefix) { bool fileSuccess = true; - string newPath = Path.Combine( - Path.GetDirectoryName(path)!, - GetFileName( - newName, - Path.GetFileNameWithoutExtension(path) - ) - + Path.GetExtension(path) - ); + string newPath = GetTargetPath(path, newName); if (path != newPath) { @@ -128,6 +128,20 @@ private bool RenameFile(string path, string newName, string? msgPrefix) return fileSuccess; } + private bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string targetFileName) + { + if (taggingFile.Path != GetTargetPath(taggingFile.Path, targetFileName)) + { + return false; + } + + return string.IsNullOrEmpty(taggingFile.SubtitlePath) + || taggingFile.SubtitlePath == GetTargetPath(taggingFile.SubtitlePath, targetFileName); + } + + private string GetTargetPath(string path, string targetFileName) + => Path.Combine(Path.GetDirectoryName(path)!, targetFileName + Path.GetExtension(path)); + private string GetFileName(string fileName, string oldFileName) { string result = fileName; @@ -162,4 +176,4 @@ private string RemoveInvalidFileNameChars(string fileName) private static char[]? InvalidFilenameChars { get; set; } private static readonly char[] InvalidNtfsChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; -} \ No newline at end of file +} diff --git a/AutoTag.Core/Movie/MovieNameNormalizer.cs b/AutoTag.Core/Movie/MovieNameNormalizer.cs new file mode 100644 index 0000000..ba7d8f3 --- /dev/null +++ b/AutoTag.Core/Movie/MovieNameNormalizer.cs @@ -0,0 +1,229 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace AutoTag.Core.Movie; + +public static class MovieNameNormalizer +{ + public static bool LooksLikeTvEpisode(string fileName) + => TVEpisodePatternRegex.IsMatch(Path.GetFileNameWithoutExtension(fileName)); + + public static bool LooksLikeMovieCandidate(string fileName) + { + var name = Path.GetFileNameWithoutExtension(fileName); + if (string.IsNullOrWhiteSpace(name) || LooksLikeTvEpisode(fileName)) + { + return false; + } + + return ParenthesisedYearRegex.IsMatch(name) + || BareYearRegex.IsMatch(name) + || SiteMarkerDomainTailRegex.IsMatch(name) + || DomainTailRegex.IsMatch(name) + || TechnicalTermPatterns.Any(pattern => Regex.IsMatch(name, pattern, SharedRegexOptions)); + } + + public static bool TryParseFileName(string fileName, [NotNullWhen(true)] out string? title, out int? year) + { + year = null; + + var workingTitle = Path.GetFileNameWithoutExtension(fileName); + if (string.IsNullOrWhiteSpace(workingTitle)) + { + title = null; + return false; + } + + var hadTechnicalNoise = TechnicalTermPatterns.Any(pattern => Regex.IsMatch(workingTitle, pattern, SharedRegexOptions)); + + workingTitle = SiteMarkerDomainTailRegex.Replace(workingTitle, ""); + workingTitle = DomainTailRegex.Replace(workingTitle, ""); + workingTitle = SquareBracketGroupRegex.Replace(workingTitle, " "); + + 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; + } + + foreach (var pattern in TechnicalTermPatterns) + { + workingTitle = Regex.Replace(workingTitle, pattern, " ", 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, ""); + + title = string.IsNullOrWhiteSpace(workingTitle) + ? null + : workingTitle; + + return title != null; + } + + public static IReadOnlyList GetSearchCandidates(string title) + { + var candidates = new List(); + AddCandidate(candidates, title); + + var words = title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length > 4) + { + for (int removedWords = 1; removedWords <= Math.Min(3, words.Length - 3); removedWords++) + { + AddCandidate(candidates, string.Join(' ', words.Take(words.Length - removedWords))); + } + } + + return candidates; + } + + private static void AddCandidate(List candidates, string candidate) + { + if (!string.IsNullOrWhiteSpace(candidate) && + !candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + candidates.Add(candidate); + } + } + + private static bool TryExtractParenthesisedYear(string title, out int year, [NotNullWhen(true)] out string? updatedTitle) + { + var match = ParenthesisedYearRegex.Match(title); + if (!match.Success) + { + year = default; + updatedTitle = null; + return false; + } + + var candidateTitle = ParenthesisedYearRegex.Replace(title, " ", 1); + if (!HasUsefulTitle(candidateTitle)) + { + year = default; + updatedTitle = null; + return false; + } + + year = int.Parse(match.Groups["Year"].Value); + updatedTitle = candidateTitle; + return true; + } + + private static bool TryExtractTrailingYear(string title, out int year, [NotNullWhen(true)] out string? updatedTitle) + { + var matches = BareYearRegex.Matches(title); + if (matches.Count == 0) + { + year = default; + updatedTitle = null; + return false; + } + + var match = matches[matches.Count - 1]; + if (match.Index == 0) + { + year = default; + updatedTitle = null; + return false; + } + + var candidateTitle = title.Remove(match.Index, match.Length); + if (!HasUsefulTitle(candidateTitle)) + { + year = default; + updatedTitle = null; + 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)); + + 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 TVEpisodePatternRegex = new(@"\b(?:s\d{1,2}\s*e\d{1,3}|\d{1,2}x\d{1,3}|episode\s*\d{1,3}|e\d{1,3})\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" + ]; +} diff --git a/AutoTag.Core/Movie/MovieProcessor.cs b/AutoTag.Core/Movie/MovieProcessor.cs index 514f3e3..774f7b2 100644 --- a/AutoTag.Core/Movie/MovieProcessor.cs +++ b/AutoTag.Core/Movie/MovieProcessor.cs @@ -1,147 +1,166 @@ -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; +using TMDbLib.Objects.Search; +using TMDbMovie = TMDbLib.Objects.Movies.Movie; + +namespace AutoTag.Core.Movie; +public class MovieProcessor(ITMDBService tmdb, IFileWriter writer, IUserInterface ui, AutoTagConfig config) : IProcessor +{ + public async Task<bool> ProcessAsync(TaggingFile file) + { + if (MovieNameNormalizer.LooksLikeTvEpisode(Path.GetFileName(file.Path))) + { + ui.SetStatus("File skipped - filename looks like a TV episode", MessageType.Warning); + return true; + } + + if (!MovieNameNormalizer.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 async Task<(FindResult, SearchMovie?)> FindMovieAsync(string title, int? year) + { + var manualResults = new List<SearchMovie>(); + var seenResultIds = new HashSet<int>(); + + foreach (var attempt in GetSearchAttempts(title, year)) + { + ui.DisplayMessage( + $@"Searching TMDB for movie ""{attempt.Query}""{(attempt.Year.HasValue ? $" ({attempt.Year.Value})" : "")}{(string.Equals(attempt.Language, config.Language, StringComparison.OrdinalIgnoreCase) ? "" : $" [{attempt.Language}]")}", + MessageType.Log + ); + + var searchResults = attempt.Year.HasValue + ? await tmdb.SearchMovieAsync(attempt.Query, attempt.Year.Value, attempt.Language) + : await tmdb.SearchMovieAsync(attempt.Query, language: attempt.Language); + + if (searchResults.Results.Count == 0) + { + continue; + } + + if (!config.ManualMode) + { + return (FindResult.Success, searchResults.Results[0]); + } + + foreach (var result in searchResults.Results.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(SearchMovie selectedResult, bool fileIsTaggable) + { + TMDbMovie movie = 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 + }; + + result.Genres = movie.Genres.Select(g => g.Name).ToList(); + + 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 MovieNameNormalizer.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 IEnumerable<string> GetSearchLanguages() + { + var languages = new List<string>(); + + if (!string.IsNullOrWhiteSpace(config.Language)) + { + languages.Add(config.Language); + } + + languages.AddRange(config.SearchLanguages.Where(language => !string.IsNullOrWhiteSpace(language))); + + return languages.Distinct(StringComparer.OrdinalIgnoreCase); + } + + private readonly record struct MovieSearchAttempt(string Query, int? Year, string Language); +} diff --git a/AutoTag.Core/TMDB/TMDBService.cs b/AutoTag.Core/TMDB/TMDBService.cs index 9b12b65..6eeea7a 100644 --- a/AutoTag.Core/TMDB/TMDBService.cs +++ b/AutoTag.Core/TMDB/TMDBService.cs @@ -4,6 +4,7 @@ using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; using Credits = TMDbLib.Objects.Movies.Credits; +using TMDbMovie = TMDbLib.Objects.Movies.Movie; namespace AutoTag.Core.TMDB; @@ -23,7 +24,9 @@ public interface ITMDBService Task<ImagesWithId> GetTvShowImagesAsync(int id); - Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0); + Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0, string? language = null); + + Task<TMDbMovie> GetMovieAsync(int movieId); Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreIds); @@ -64,8 +67,11 @@ public Task<CreditsWithGuestStars> GetTvEpisodeCreditsAsync(int tvShowId, int se 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); + public Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0, string? language = null) + => client.SearchMovieAsync(query, language ?? config.Language, year: year); + + public Task<TMDbMovie> GetMovieAsync(int movieId) + => client.GetMovieAsync(movieId, language: config.Language); public async Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreIds) @@ -81,4 +87,4 @@ public async Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreId public Task<Credits> GetMovieCreditsAsync(int movieId) => client.GetMovieCreditsAsync(movieId); -} \ No newline at end of file +} diff --git a/AutoTag.Core/TV/EpisodeParser.cs b/AutoTag.Core/TV/EpisodeParser.cs index 3b3d977..b8895fd 100644 --- a/AutoTag.Core/TV/EpisodeParser.cs +++ b/AutoTag.Core/TV/EpisodeParser.cs @@ -11,7 +11,8 @@ public class EpisodeParser 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) + new(@"^((?<SeriesName>.+?)[. _-]+)?s(?<Season>\d+)[. _-]*e(?<Episode>\d+)(([. _-]*e|-)(?<EndEpisode>(?!(1080|720)[pi])\d+))*[. _-]*((?<ExtraInfo>.+?)((?<![. _-])-(?<ReleaseGroup>[^-]+))?)?$", RegexOptions), + new(@"^((?<SeriesName>.+?)[. _-]+)?e(?<Episode>\d+)(([. _-]*e|-)(?<EndEpisode>(?!(1080|720)[pi])\d+))*[. _-]*((?<ExtraInfo>.+?)((?<![. _-])-(?<ReleaseGroup>[^-]+))?)?$", RegexOptions) ]; public static bool TryParseEpisodeInfo(string fileName, @@ -35,11 +36,6 @@ public static bool TryParseEpisodeInfo(string fileName, 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"; @@ -50,7 +46,7 @@ public static bool TryParseEpisodeInfo(string fileName, metadata = new TVFileMetadata { SeriesName = seriesName, - Season = int.Parse(season), + Season = string.IsNullOrWhiteSpace(season) ? 1 : int.Parse(season), Episode = int.Parse(episode) }; @@ -60,4 +56,4 @@ public static bool TryParseEpisodeInfo(string fileName, failureReason = "Unable to parse required information from filename"; return false; } -} \ No newline at end of file +} diff --git a/AutoTag.Core/TV/TVProcessor.cs b/AutoTag.Core/TV/TVProcessor.cs index d71e2cd..71143b3 100644 --- a/AutoTag.Core/TV/TVProcessor.cs +++ b/AutoTag.Core/TV/TVProcessor.cs @@ -6,8 +6,10 @@ 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 +{ + private static readonly Regex SeriesYearSuffixRegex = new(@"\s+\((19|20)\d{2}\)$", RegexOptions.CultureInvariant); + public async Task<bool> ProcessAsync(TaggingFile file) { var metadata = ParseFileName(file); @@ -82,16 +84,22 @@ public async Task<bool> ProcessAsync(TaggingFile file) 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), + else + { + try + { + var fullPath = Path.GetFullPath(file.Path); + var match = Regex.Match(fullPath, config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success && fullPath.Contains(Path.DirectorySeparatorChar)) + { + var normalisedPath = fullPath.Replace(Path.DirectorySeparatorChar, '/'); + match = Regex.Match(normalisedPath, 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) }; } @@ -103,15 +111,23 @@ public async Task<bool> ProcessAsync(TaggingFile file) } } - 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); + 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); + if (searchResults.Results.Count == 0) + { + var normalisedSeriesName = NormaliseSeriesSearchName(seriesName); + if (normalisedSeriesName != seriesName) + { + searchResults = await tmdb.SearchTvShowAsync(normalisedSeriesName); + } + } var seriesResults = searchResults.Results .OrderByDescending(searchResult => SeriesNameSimilarity(seriesName, searchResult.Name)) @@ -329,13 +345,16 @@ public async Task FindPosterAsync(TVFileMetadata metadata) } } - private static double SeriesNameSimilarity(string parsedName, string seriesName) - { - if (seriesName.ToLower().Contains(parsedName.ToLower())) - { - return parsedName.Length / (double) seriesName.Length; - } - - return 0; - } -} \ No newline at end of file + private static double SeriesNameSimilarity(string parsedName, string seriesName) + { + if (seriesName.ToLower().Contains(parsedName.ToLower())) + { + return parsedName.Length / (double) seriesName.Length; + } + + return 0; + } + + internal static string NormaliseSeriesSearchName(string seriesName) + => SeriesYearSuffixRegex.Replace(seriesName.Trim(), ""); +} diff --git a/README.md b/README.md index 476a96b..e925cea 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ The `--extended-tagging` option adds additional information to Matroska video fi ### 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. +Movie searching can optionally use additional fallback languages via the `searchLanguages` config setting. For example, with `"language": "pt-BR"` and `"searchLanguages": ["en-US"]`, AutoTag will still write metadata in Brazilian Portuguese but will retry movie 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`. @@ -103,7 +105,7 @@ Enabling this option will prompt you to select the episode ordering for each sho ## 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: ``` -"configVer": 9, // Internal use +"configVer": 11, // Internal use "mode": 0, // Default tagging mode, 0 = TV, 1 = Movie "manualMode": false, // Manual tagging mode "verbose": false, // Verbose output @@ -118,6 +120,7 @@ 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 "episodeGroup": false, // Enable alternate episode ordering selection "fileNameReplaces": [] // File name character replacements. Array of objects of the form { "replace": "", "replacement": "" } ``` From ba64025433948667215e2f3c3965fa7b42db1d35 Mon Sep 17 00:00:00 2001 From: mozartsempiano <mozartraulmt@gmail.com> Date: Tue, 17 Mar 2026 23:21:14 -0300 Subject: [PATCH 02/35] Support absolute TV episode filenames and more video formats --- .../Files/FileFinder/FindFilesToProcess.cs | 38 ++++++ .../TV/TVProcessor/FindEpisodeAsync.cs | 73 ++++++++++- .../TV/TVProcessor/ParseFileName.cs | 26 +++- AutoTag.Core/Files/FileFinder.cs | 46 ++++++- AutoTag.Core/Movie/MovieNameNormalizer.cs | 21 +++- AutoTag.Core/TV/EpisodeParser.cs | 79 +++++++++++- AutoTag.Core/TV/TVFileMetadata.cs | 16 ++- AutoTag.Core/TV/TVProcessor.cs | 115 ++++++++++++------ 8 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs diff --git a/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs new file mode 100644 index 0000000..edad1f4 --- /dev/null +++ b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs @@ -0,0 +1,38 @@ +using AutoTag.Core.Config; +using AutoTag.Core.Files; + +namespace AutoTag.Core.Test.Files.FileFinder; + +public class FindFilesToProcess +{ + [Fact] + public void Should_FindCommonVideoContainers_AndOnlyTagKnownSafeFormats() + { + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + File.WriteAllText(Path.Combine(tempDirectory.FullName, "episode.AVI"), string.Empty); + File.WriteAllText(Path.Combine(tempDirectory.FullName, "movie.mkv"), string.Empty); + File.WriteAllText(Path.Combine(tempDirectory.FullName, "clip.mov"), string.Empty); + File.WriteAllText(Path.Combine(tempDirectory.FullName, "notes.txt"), string.Empty); + + var finder = new AutoTag.Core.Files.FileFinder( + new AutoTagConfig { RenameSubtitles = false }, + new AutoTag.Core.Files.FileSystem(), + new Mock<IUserInterface>().Object + ); + + var result = finder.FindFilesToProcess([tempDirectory]); + + 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().NotContain(file => file.Path.EndsWith("notes.txt")); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } +} diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs index a78e370..bab9247 100644 --- a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs +++ b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeAsync.cs @@ -231,6 +231,77 @@ public async Task Should_AddSeasonToCache_When_Found() mockCache.Verify(c => c.AddSeason(1, 1, seasonResult)); } + [Fact] + public async Task Should_MapAbsoluteEpisodeToSeasonAndEpisode() + { + var mockCache = new Mock<ITVCache>(); + var cachedSeasons = new Dictionary<(int ShowId, int SeasonNumber), TvSeason>(); + mockCache.Setup(c => c.TryGetSeason(It.IsAny<int>(), It.IsAny<int>(), out It.Ref<TvSeason?>.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<int>(), It.IsAny<int>(), It.IsAny<TvSeason>())) + .Callback<int, int, TvSeason>((showId, seasonNumber, season) => + { + cachedSeasons[(showId, seasonNumber)] = season; + }); + + var mockTmdb = new Mock<ITMDBService>(); + mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(1, 1)) + .ReturnsAsync(new TvSeason + { + Episodes = + [ + new TvSeasonEpisode { EpisodeNumber = 1 }, + new TvSeasonEpisode { EpisodeNumber = 2 }, + new TvSeasonEpisode { EpisodeNumber = 3 } + ] + }); + mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(1, 2)) + .ReturnsAsync(new TvSeason + { + Episodes = + [ + new TvSeasonEpisode + { + EpisodeNumber = 1, + Name = "Episode 4", + Overview = "Fourth episode" + }, + new TvSeasonEpisode { EpisodeNumber = 2 } + ] + }); + mockTmdb.Setup(tmdb => tmdb.GetTvGenreNamesAsync(It.IsAny<IEnumerable<int>>())) + .ReturnsAsync([]); + + var metadata = new TVFileMetadata + { + SeriesName = "Series", + Season = 0, + Episode = 4, + AbsoluteEpisode = 4 + }; + var show = new ShowResults(new SearchTv + { + Id = 1, + Name = "Series" + }); + + var instance = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object); + + var (result, _) = await instance.FindEpisodeAsync(metadata, show, true); + + result.Should().Be(FindResult.Success); + metadata.Season.Should().Be(2); + metadata.Episode.Should().Be(1); + metadata.Title.Should().Be("Episode 4"); + mockTmdb.Verify(tmdb => tmdb.GetTvSeasonAsync(1, 1), Times.Once); + mockTmdb.Verify(tmdb => tmdb.GetTvSeasonAsync(1, 2), Times.Once); + } + [Fact] public async Task Should_ReturnSkipWithErrorMessage_When_SeasonNotFound() { @@ -390,4 +461,4 @@ out _ mockUi.Verify(ui => ui.SetStatus($"Error: Cannot find {metadata} in episode group on TheMovieDB", MessageType.Error)); } -} \ No newline at end of file +} diff --git a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs b/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs index d3b8209..4faf877 100644 --- a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs +++ b/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs @@ -35,7 +35,31 @@ public void Should_ParseCommonNamingFormats(string fileName, string seriesName, o => o.Using<string>(StringComparer.OrdinalIgnoreCase) ); } - + + [Fact] + public void Should_ParseAbsoluteEpisodeNamingFormat() + { + var tv = GetInstance(); + + var file = new TaggingFile + { + Path = "[Group] Series Dublado (35).AVI" + }; + + var result = tv.ParseFileName(file); + + result.Should().BeEquivalentTo( + new TVFileMetadata + { + SeriesName = "Series", + Season = 0, + Episode = 35, + AbsoluteEpisode = 35 + }, + o => o.Using<string>(StringComparer.OrdinalIgnoreCase) + ); + } + [Fact] public void Should_ParseFromFullPath_When_ParsePatternProvided() { diff --git a/AutoTag.Core/Files/FileFinder.cs b/AutoTag.Core/Files/FileFinder.cs index a3755b8..696bbc5 100644 --- a/AutoTag.Core/Files/FileFinder.cs +++ b/AutoTag.Core/Files/FileFinder.cs @@ -9,8 +9,39 @@ public interface IFileFinder public class FileFinder(AutoTagConfig config, IFileSystem fs, IUserInterface ui) : IFileFinder { - private static readonly string[] VideoExtensions = [".mp4", ".m4v", ".mkv"]; - private static readonly string[] SubtitleExtensions = [".srt", ".vtt", ".sub", ".ssa"]; + private static readonly HashSet<string> 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<string> TaggableVideoExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp4", + ".m4v", + ".mkv" + }; + private static readonly HashSet<string> SubtitleExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".srt", + ".vtt", + ".sub", + ".ssa" + }; public List<TaggingFile> FindFilesToProcess(IEnumerable<FileSystemInfo> entries) { @@ -52,7 +83,7 @@ private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo yield return new TaggingFile { Path = file.FullName, - Taggable = VideoExtensions.Contains(file.Extension) + Taggable = IsTaggableVideoFile(file.Extension) }; } else @@ -96,7 +127,8 @@ private IEnumerable<TaggingFile> GroupSubtitles(IGrouping<string, TaggingFile> f yield return new TaggingFile { Path = videoPath, - SubtitlePath = subPath + SubtitlePath = subPath, + Taggable = IsTaggableVideoFile(Path.GetExtension(videoPath)) }; } else if (subPath != null) @@ -114,7 +146,9 @@ private bool IsSupportedFile(FileInfo info) => IsVideoFile(info.Extension) || config.RenameSubtitles && IsSubtitleFile(info.Extension); - private bool IsVideoFile(string extension) => VideoExtensions.Contains(extension); + private bool IsVideoFile(string extension) => ProcessableVideoExtensions.Contains(extension); + + private bool IsTaggableVideoFile(string extension) => TaggableVideoExtensions.Contains(extension); private bool IsSubtitleFile(string extension) => SubtitleExtensions.Contains(extension); -} \ No newline at end of file +} diff --git a/AutoTag.Core/Movie/MovieNameNormalizer.cs b/AutoTag.Core/Movie/MovieNameNormalizer.cs index ba7d8f3..a26af17 100644 --- a/AutoTag.Core/Movie/MovieNameNormalizer.cs +++ b/AutoTag.Core/Movie/MovieNameNormalizer.cs @@ -6,7 +6,11 @@ namespace AutoTag.Core.Movie; public static class MovieNameNormalizer { public static bool LooksLikeTvEpisode(string fileName) - => TVEpisodePatternRegex.IsMatch(Path.GetFileNameWithoutExtension(fileName)); + { + var name = Path.GetFileNameWithoutExtension(fileName); + return TVEpisodePatternRegex.IsMatch(name) + || LooksLikeAbsoluteEpisodeCandidate(name); + } public static bool LooksLikeMovieCandidate(string fileName) { @@ -161,6 +165,20 @@ private static bool TryExtractTrailingYear(string title, out int year, [NotNullW private static bool HasUsefulTitle(string title) => !string.IsNullOrWhiteSpace(MultiSpaceRegex.Replace(title, " ").Trim(TrimCharacters)); + private static bool LooksLikeAbsoluteEpisodeCandidate(string fileNameWithoutExtension) + { + var match = AbsoluteEpisodePatternRegex.Match(fileNameWithoutExtension); + if (!match.Success + || !int.TryParse(match.Groups["Episode"].Value, out var episode) + || episode is >= 1900 and <= 2099) + { + return false; + } + + return SquareBracketGroupRegex.IsMatch(fileNameWithoutExtension) + || LanguageTermPatterns.Any(pattern => Regex.IsMatch(fileNameWithoutExtension, pattern, SharedRegexOptions)); + } + 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); @@ -170,6 +188,7 @@ private static bool HasUsefulTitle(string title) private static readonly Regex ParenthesisedYearRegex = new(@"\((?:[^,)]*,\s*)?(?<Year>(19|20)\d{2})\)", SharedRegexOptions); private static readonly Regex BareYearRegex = new(@"\b(?<Year>(19|20)\d{2})\b", SharedRegexOptions); private static readonly Regex TVEpisodePatternRegex = new(@"\b(?:s\d{1,2}\s*e\d{1,3}|\d{1,2}x\d{1,3}|episode\s*\d{1,3}|e\d{1,3})\b", SharedRegexOptions); + private static readonly Regex AbsoluteEpisodePatternRegex = new(@"\((?<Episode>[1-9]\d{0,3})\)\s*$", SharedRegexOptions); private static readonly Regex SeparatorRegex = new(@"[._-]+", SharedRegexOptions); private static readonly Regex BracketCharacterRegex = new(@"[(){}\[\]]", SharedRegexOptions); private static readonly Regex MultiSpaceRegex = new(@"\s+", SharedRegexOptions); diff --git a/AutoTag.Core/TV/EpisodeParser.cs b/AutoTag.Core/TV/EpisodeParser.cs index b8895fd..92174c8 100644 --- a/AutoTag.Core/TV/EpisodeParser.cs +++ b/AutoTag.Core/TV/EpisodeParser.cs @@ -8,6 +8,9 @@ public class EpisodeParser // https://github.com/pheiberg/SubtitleFetcher static RegexOptions RegexOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + private static readonly Regex AbsoluteEpisodePattern = new(@"^(?<SeriesName>.+?)[. _-]*\((?<AbsoluteEpisode>[1-9]\d{0,3})\)[. _-]*(?<ExtraInfo>.*)?$", RegexOptions); + private static readonly Regex SquareBracketGroupRegex = new(@"\[[^\]]+\]", RegexOptions); + private static readonly Regex MultiSpaceRegex = new(@"\s+", RegexOptions); static readonly Regex[] Patterns = [ new(@"^((?<SeriesName>.+?)[\[. _-]+)?(?<Season>\d+)x(?<Episode>\d+)(([. _-]*x|-)(?<EndEpisode>(?!(1080|720)[pi])(?!(?<=x)264)\d+))*[\]. _-]*((?<ExtraInfo>.+?)((?<![. _-])-(?<ReleaseGroup>[^-]+))?)?$", RegexOptions), @@ -27,7 +30,7 @@ public static bool TryParseEpisodeInfo(string fileName, if (!match.Success) continue; - var seriesName = match.Groups["SeriesName"].Value.Replace('.', ' ').Replace('_', ' ').Trim(); + var seriesName = NormaliseSeriesName(match.Groups["SeriesName"].Value); var season = match.Groups["Season"].Value; var episode = match.Groups["Episode"].Value; @@ -53,7 +56,81 @@ public static bool TryParseEpisodeInfo(string fileName, return true; } + if (TryParseAbsoluteEpisodeInfo(fileName, out metadata, out failureReason)) + { + return true; + } + + if (!string.IsNullOrEmpty(failureReason)) + { + return false; + } + failureReason = "Unable to parse required information from filename"; return false; } + + private static bool TryParseAbsoluteEpisodeInfo(string fileName, + [NotNullWhen(true)] out TVFileMetadata? metadata, + out string? failureReason) + { + metadata = null; + failureReason = null; + + var match = AbsoluteEpisodePattern.Match(Path.GetFileNameWithoutExtension(fileName)); + if (!match.Success) + { + return false; + } + + if (!int.TryParse(match.Groups["AbsoluteEpisode"].Value, out var absoluteEpisode) + || IsLikelyYear(absoluteEpisode)) + { + return false; + } + + var seriesName = NormaliseSeriesName(match.Groups["SeriesName"].Value); + if (string.IsNullOrWhiteSpace(seriesName)) + { + failureReason = "Unable to parse series name from filename"; + return false; + } + + metadata = new TVFileMetadata + { + SeriesName = seriesName, + Season = 0, + Episode = absoluteEpisode, + AbsoluteEpisode = absoluteEpisode + }; + + return true; + } + + private static string NormaliseSeriesName(string seriesName) + { + seriesName = SquareBracketGroupRegex.Replace(seriesName, " "); + seriesName = seriesName.Replace('.', ' ').Replace('_', ' '); + + foreach (var pattern in LanguageTermPatterns) + { + seriesName = Regex.Replace(seriesName, pattern, " ", RegexOptions); + } + + return MultiSpaceRegex.Replace(seriesName, " ").Trim(' ', '.', '-', '_'); + } + + private static bool IsLikelyYear(int value) => value is >= 1900 and <= 2099; + + private static readonly string[] LanguageTermPatterns = + [ + @"\bDublado\b", + @"\bLegendado\b", + @"\bDubbed\b", + @"\bSubbed\b", + @"\bSubtitles?\b", + @"\bSubtitled\b", + @"\bPT-BR\b", + @"\bENG\b" + ]; } diff --git a/AutoTag.Core/TV/TVFileMetadata.cs b/AutoTag.Core/TV/TVFileMetadata.cs index dedcce9..1cb64fb 100644 --- a/AutoTag.Core/TV/TVFileMetadata.cs +++ b/AutoTag.Core/TV/TVFileMetadata.cs @@ -9,6 +9,8 @@ public class TVFileMetadata : FileMetadata public int Season { get; set; } public int Episode { get; set; } + + public int? AbsoluteEpisode { get; set; } public int SeasonEpisodes { get; set; } @@ -94,6 +96,18 @@ public override string GetFileName(AutoTagConfig config) public override string ToString() { + if (AbsoluteEpisode.HasValue && Season == 0) + { + if (!string.IsNullOrEmpty(Title)) + { + return $"{SeriesName} E{AbsoluteEpisode.Value:000} ({Title})"; + } + else + { + return $"{SeriesName} E{AbsoluteEpisode.Value:000}"; + } + } + if (!string.IsNullOrEmpty(Title)) { return $"{SeriesName} S{Season:00}E{Episode:00} ({Title})"; @@ -103,4 +117,4 @@ public override string ToString() return $"{SeriesName} S{Season:00}E{Episode:00}"; } } -} \ No newline at end of file +} diff --git a/AutoTag.Core/TV/TVProcessor.cs b/AutoTag.Core/TV/TVProcessor.cs index 71143b3..22cdce9 100644 --- a/AutoTag.Core/TV/TVProcessor.cs +++ b/AutoTag.Core/TV/TVProcessor.cs @@ -253,22 +253,42 @@ public async Task<FindResult> FindShowAsync(string seriesName) return (null, null); } - public async Task<(FindResult Result, string? LastResultErrorMessage)> FindEpisodeAsync(TVFileMetadata metadata, 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; - - if (show.HasEpisodeGroupMapping) - { - if (show.TryGetMapping(metadata.Season, metadata.Episode, out var groupNumbering)) - { - lookupSeason = groupNumbering.Value.Season; - lookupEpisode = groupNumbering.Value.Episode; + public async Task<(FindResult Result, string? LastResultErrorMessage)> FindEpisodeAsync(TVFileMetadata metadata, ShowResults show, bool fileIsTaggable) + { + var showData = show.TvSearchResult; + metadata.Id = showData.Id; + metadata.SeriesName = showData.Name; + + // 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 resolvedAbsoluteEpisode = false; + + if (metadata.AbsoluteEpisode.HasValue) + { + var resolvedEpisode = await TryResolveAbsoluteEpisodeAsync(showData.Id, metadata.AbsoluteEpisode.Value); + if (!resolvedEpisode.HasValue) + { + return (FindResult.Skip, + $"Error: Cannot map absolute episode {metadata.AbsoluteEpisode.Value} for {metadata.SeriesName} on TheMovieDB"); + } + + metadata.Season = resolvedEpisode.Value.Season; + metadata.Episode = resolvedEpisode.Value.Episode; + + lookupSeason = resolvedEpisode.Value.Season; + lookupEpisode = resolvedEpisode.Value.Episode; + resolvedAbsoluteEpisode = true; + } + + if (show.HasEpisodeGroupMapping && !resolvedAbsoluteEpisode) + { + if (show.TryGetMapping(metadata.Season, metadata.Episode, out var groupNumbering)) + { + lookupSeason = groupNumbering.Value.Season; + lookupEpisode = groupNumbering.Value.Episode; } else { @@ -277,21 +297,10 @@ public async Task<FindResult> FindShowAsync(string seriesName) } } - metadata.Id = showData.Id; - metadata.SeriesName = showData.Name; - - if (!cache.TryGetSeason(showData.Id, lookupSeason, out var seasonResult)) - { - seasonResult = await tmdb.GetTvSeasonAsync(showData.Id, lookupSeason); - - if (seasonResult != null) - { - cache.AddSeason(showData.Id, lookupSeason, seasonResult); - } - } - - if (seasonResult == null || - !seasonResult.Episodes.TryFind(e => e.EpisodeNumber == lookupEpisode, out var episodeResult)) + var seasonResult = await GetSeasonAsync(showData.Id, lookupSeason); + + if (seasonResult == null || + !seasonResult.Episodes.TryFind(e => e.EpisodeNumber == lookupEpisode, out var episodeResult)) { return (FindResult.Skip, $"Error: Cannot find {metadata} on TheMovieDB"); } @@ -316,9 +325,47 @@ public async Task<FindResult> FindShowAsync(string seriesName) metadata.Actors = credits.Cast.Select(c => c.Name).ToArray(); metadata.Characters = credits.Cast.Select(c => c.Character).ToArray(); } - - return (FindResult.Success, null); - } + + return (FindResult.Success, null); + } + + private async Task<TvSeason?> GetSeasonAsync(int showId, int seasonNumber) + { + if (!cache.TryGetSeason(showId, seasonNumber, out var seasonResult)) + { + seasonResult = await tmdb.GetTvSeasonAsync(showId, seasonNumber); + + if (seasonResult != null) + { + cache.AddSeason(showId, seasonNumber, seasonResult); + } + } + + return seasonResult; + } + + private async Task<(int Season, int Episode)?> TryResolveAbsoluteEpisodeAsync(int showId, int absoluteEpisode) + { + var remainingEpisode = absoluteEpisode; + + for (int seasonNumber = 1; seasonNumber <= 100; seasonNumber++) + { + var seasonResult = await GetSeasonAsync(showId, seasonNumber); + if (seasonResult == null || seasonResult.Episodes.Count == 0) + { + break; + } + + if (remainingEpisode <= seasonResult.Episodes.Count) + { + return (seasonNumber, remainingEpisode); + } + + remainingEpisode -= seasonResult.Episodes.Count; + } + + return null; + } public async Task FindPosterAsync(TVFileMetadata metadata) { From 4543360438a4ee752f188163b60b69f8a5a882e9 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:14:14 +0000 Subject: [PATCH 03/35] ci: add build workflow --- .github/workflows/build.yml | 24 ++ AutoTag.CLI/AutoTag.CLI.csproj | 15 +- AutoTag.CLI/Keys.cs.template | 9 - AutoTag.CLI/RootCommand.cs | 2 +- AutoTag.CLI/packages.lock.json | 383 +++++++++++++++++++++ AutoTag.Core.Test/AutoTag.Core.Test.csproj | 3 +- AutoTag.Core.Test/packages.lock.json | 290 ++++++++++++++++ AutoTag.Core/AutoTag.Core.csproj | 2 +- AutoTag.Core/packages.lock.json | 165 +++++++++ 9 files changed, 875 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 AutoTag.CLI/Keys.cs.template create mode 100644 AutoTag.CLI/packages.lock.json create mode 100644 AutoTag.Core.Test/packages.lock.json create mode 100644 AutoTag.Core/packages.lock.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c196017 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +name: Build AutoTag + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore dependencies + run: dotnet restore --locked-mode + + - name: Build + run: dotnet build --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal \ No newline at end of file diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 1f98609..90f5ad1 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -6,6 +6,7 @@ <Version>4.0.3</Version> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> + <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> @@ -13,7 +14,7 @@ <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <SelfContained>true</SelfContained> <PublishTrimmed>true</PublishTrimmed> - <!-- needed to serialze config. see https://devblogs.microsoft.com/dotnet/system-text-json-in-dotnet-8/#disabling-reflection-defaults --> + <!-- needed to serialize config. see https://devblogs.microsoft.com/dotnet/system-text-json-in-dotnet-8/#disabling-reflection-defaults --> <JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <TrimMode>partial</TrimMode> </PropertyGroup> @@ -23,13 +24,17 @@ <PackageReference Include="Spectre.Console" Version="0.54.0" /> <PackageReference Include="Spectre.Console.Cli" Version="0.53.1" /> <PackageReference Include="Spectre.Console.Json" Version="0.54.0" /> + <PackageReference Include="ThisAssembly.Constants" Version="2.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> </ItemGroup> <ItemGroup> <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj" /> </ItemGroup> - - <Target Name="KeyCheck" BeforeTargets="PrepareForBuild"> - <Error Condition="!Exists('$(MSBuildProjectDirectory)/Keys.cs')" Text="API keys file missing. Copy 'Keys.cs.template' to 'Keys.cs' and add your API keys before building." /> - </Target> + + <ItemGroup> + <Constant Include="TMDBApiKey" Value="$(TMDB_API_KEY)" /> + </ItemGroup> </Project> 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..1014987 100644 --- a/AutoTag.CLI/RootCommand.cs +++ b/AutoTag.CLI/RootCommand.cs @@ -18,7 +18,7 @@ public override async Task<int> ExecuteAsync(CommandContext context, RootCommand } var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { DisableDefaults = true }); - builder.Services.AddCoreServices(Keys.TMDBKey); + builder.Services.AddCoreServices(ThisAssembly.Constants.TMDBApiKey); builder.Services.AddScoped<IUserInterface, CLIInterface>(); using var host = builder.Build(); diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json new file mode 100644 index 0000000..c524935 --- /dev/null +++ b/AutoTag.CLI/packages.lock.json @@ -0,0 +1,383 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "0jjfjQSOFZlHhwOoIQw0WyzxtkDMYdnPY3iFrOLasxYq/5J4FDt1HWT8TktBclOVjFY1FOOkoOc99X7AhbqSIw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.1", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.1", + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Logging.Console": "10.0.1", + "Microsoft.Extensions.Logging.Debug": "10.0.1", + "Microsoft.Extensions.Logging.EventLog": "10.0.1", + "Microsoft.Extensions.Logging.EventSource": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Spectre.Console": { + "type": "Direct", + "requested": "[0.54.0, )", + "resolved": "0.54.0", + "contentHash": "StDXCFayfy0yB1xzUHT2tgEpV1/HFTiS4JgsAQS49EYTfMixSwwucaQs/bIOCwXjWwIQTMuxjUIxcB5XsJkFJA==" + }, + "Spectre.Console.Cli": { + "type": "Direct", + "requested": "[0.53.1, )", + "resolved": "0.53.1", + "contentHash": "y//7ZZ0shhvgXzoJXJzMaLGA4dPem8qCbkyv9khfE8NQDlH4hPVsHOHYoz6uzAwiTX9Yd7OnX3wNOX/wRfPUOg==", + "dependencies": { + "Spectre.Console": "0.53.1" + } + }, + "Spectre.Console.Json": { + "type": "Direct", + "requested": "[0.54.0, )", + "resolved": "0.54.0", + "contentHash": "ulIDhznzFiG848XQvno15pIVzunr/OjSPaGCfrXw00oR8OdNWJPW9y5uglhVqjmcDDTsaGtfJgyuYBLK5H8TFw==", + "dependencies": { + "Spectre.Console": "0.54.0" + } + }, + "ThisAssembly.Constants": { + "type": "Direct", + "requested": "[2.1.2, )", + "resolved": "2.1.2", + "contentHash": "rq7HoR45a4H1NM8KPG+rOPhv6z36wpB088+tB6KCbltBsnx1uwCpS3IvLmMZh3EOnZarRjXE9oiVgGMFCCJ1Wg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "s5cxcdtIig66YT3J+7iHflMuorznK8kXuwBBPHMp4KImx5ZGE3FRa1Nj9fI/xMwFV+KzUMjqZ2MhOedPH8LiBQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "csD8Eps3HQ3yc0x6NhgTV+aIFKSs3qVlVCtFnMHz/JOjnv7eEj/qSXKXiK9LzBzB1qSfAVqFnB5iaX2nUmagIQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "N/6GiwiZFCBFZDk3vg1PhHW3zMqqu5WWpmeZAA9VTXv7Q8pr8NZR/EQsH0DjzqydDksJtY6EQBsu81d5okQOlA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "0zW3eYBJlRctmgqk5s0kFIi5o5y2g80mvGCD8bkYxREPQlKUnr0ndU/Sop+UDIhyWN0fIi4RW63vo7BKTi7ncA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "ULEJ0nkaW90JYJGkFujPcJtADXcJpXiSOLbokPcWJZ8iDbtDINifEYAUVqZVr81IDNTrRFul6O8RolOKOsgFPg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "+b3DligYSZuoWltU5YdbMpIEUHNZPgPrzWfNiIuDkMdqOl93UxYB5KzS3lgpRfTXJhTNpo/CZ8w/sTkDTPDdxQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4bxzGXIzZnz0Bf7czQ72jGvpOqJsRW/44PS7YLFXTTnu6cNcPvmSREDvBoH0ZVP2hAbMfL4sUoCUn54k70jPWw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "49dFvGJjLSwGn76eHnP1gBvCJkL8HRYpCrG0DCvsP6wRpEQRLN2Fq8rTxbP+6jS7jmYKCnSVO5C65v4mT3rzeA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "qmoQkVZcbm4/gFpted3W3Y+1kTATZTcUhV3mRkbtpfBXlxWCHwh/2oMffVcCruaGOfJuEnyAsGyaSUouSdECOw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Zg8LLnfZs5o2RCHD/+9NfDtJ40swauemwCa7sI8gQoAye/UJHRZNpCtC7a5XE7l9Z7mdI8iMWnLZ6m7Q6S3jLg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "38Q8sEHwQ/+wVO/mwQBa0fcdHbezFpusHE+vBw/dSr6Fq/kzZm3H/NQX511Jki/R3FHd64IY559gdlHZQtYeEA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "VqfTvbX9C6BA0VeIlpzPlljnNsXxiI5CdUHb9ksWERH94WQ6ft3oLGUAa4xKcDGu4xF+rIZ8wj7IOAd6/q7vGw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Zp9MM+jFCa7oktIug62V9eNygpkf+6kFVatF+UC/ODeUwIr5givYKy8fYSSI9sWdxqDqv63y1x0mm2VjOl8GOw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "System.Diagnostics.EventLog": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "WnFvZP+Y+lfeNFKPK/+mBpaCC7EeBDlobrQOqnP7rrw/+vE7yu8Rjczum1xbC0F/8cAHafog84DMp9200akMNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + }, + "TagLibSharp": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + }, + "TMDbLib": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "kapjC4/ao8mxJ/G5xcZHYNVFvmAzxwWEN2PDkGbbUzAQVfbf85DsA9AUDTrXahTHYq0R7jwGyARs/y6243EPLg==", + "dependencies": { + "Newtonsoft.Json": "13.0.3" + } + }, + "autotag.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "[10.0.1, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.1, )", + "Microsoft.Extensions.Http": "[10.0.1, )", + "TMDbLib": "[2.3.0, )", + "TagLibSharp": "[2.3.0, )" + } + } + } + } +} \ 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..05b9e6a 100644 --- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj +++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj @@ -1,5 +1,4 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> @@ -7,6 +6,7 @@ <IsPackable>false</IsPackable> <IsTestProject>true</IsTestProject> + <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> <ItemGroup> @@ -31,5 +31,4 @@ <ItemGroup> <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj" /> </ItemGroup> - </Project> diff --git a/AutoTag.Core.Test/packages.lock.json b/AutoTag.Core.Test/packages.lock.json new file mode 100644 index 0000000..fbe17eb --- /dev/null +++ b/AutoTag.Core.Test/packages.lock.json @@ -0,0 +1,290 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[8.8.0, )", + "resolved": "8.8.0", + "contentHash": "m0kwcqBwvVel03FuMa7Ozo/oTaxYbjeNlcOhQFkyQpwX/8wks6RNl/Jnn58DCZVs6c2oG1RsCZw7HfKSaxLm3w==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.72, )", + "resolved": "4.20.72", + "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "TagLibSharp": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + }, + "TMDbLib": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "kapjC4/ao8mxJ/G5xcZHYNVFvmAzxwWEN2PDkGbbUzAQVfbf85DsA9AUDTrXahTHYq0R7jwGyARs/y6243EPLg==", + "dependencies": { + "Newtonsoft.Json": "13.0.3" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "autotag.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "[10.0.1, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.1, )", + "Microsoft.Extensions.Http": "[10.0.1, )", + "TMDbLib": "[2.3.0, )", + "TagLibSharp": "[2.3.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/AutoTag.Core/AutoTag.Core.csproj b/AutoTag.Core/AutoTag.Core.csproj index 0147bd8..ba0c37a 100644 --- a/AutoTag.Core/AutoTag.Core.csproj +++ b/AutoTag.Core/AutoTag.Core.csproj @@ -1,9 +1,9 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> + <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> <ItemGroup> diff --git a/AutoTag.Core/packages.lock.json b/AutoTag.Core/packages.lock.json new file mode 100644 index 0000000..6ebb13c --- /dev/null +++ b/AutoTag.Core/packages.lock.json @@ -0,0 +1,165 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "TagLibSharp": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + }, + "TMDbLib": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "kapjC4/ao8mxJ/G5xcZHYNVFvmAzxwWEN2PDkGbbUzAQVfbf85DsA9AUDTrXahTHYq0R7jwGyARs/y6243EPLg==", + "dependencies": { + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + } + } + } +} \ No newline at end of file From e589227a42d569bad5711997b29125844ac35b01 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:15:46 +0000 Subject: [PATCH 04/35] ci: update action versions --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c196017..b906545 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,10 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x From a56282892252d08a3f7afb77915ee89ee9da18d7 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:41:19 +0000 Subject: [PATCH 05/35] ci: add publish action --- .github/workflows/build.yml | 2 +- .github/workflows/publish.yml | 29 +++++++++++++++++++++++++++++ AutoTag.CLI/AutoTag.CLI.csproj | 6 +++++- AutoTag.CLI/packages.lock.json | 13 +++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b906545..c0909bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build AutoTag +name: build on: [push, pull_request] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4990274 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: publish +on: + release: + push: + branches: + - 'github-actions' + +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 + run: dotnet restore --locked-mode + + - name: Publish + env: + TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} + run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --no-restore \ No newline at end of file diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 90f5ad1..721f4bc 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -9,7 +9,7 @@ <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> + <PropertyGroup Condition="'$(Configuration)' == 'Release'"> <PublishSingleFile>true</PublishSingleFile> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <SelfContained>true</SelfContained> @@ -37,4 +37,8 @@ <ItemGroup> <Constant Include="TMDBApiKey" Value="$(TMDB_API_KEY)" /> </ItemGroup> + + <Target Name="KeyCheck" BeforeTargets="PrepareForBuild" Condition="'$(Configuration)' == 'Release'"> + <Error Condition="$(TMDB_API_KEY) == ''" Text="TMDB API key missing. Set TMDB_API_KEY environment variable before building." /> + </Target> </Project> diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json index c524935..9f94235 100644 --- a/AutoTag.CLI/packages.lock.json +++ b/AutoTag.CLI/packages.lock.json @@ -32,6 +32,12 @@ "Microsoft.Extensions.Options": "10.0.1" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "i8eWi2ThxC0/kLkgckJe3zScNARB3x2P2AUTg7Mc6IpnZJAGSg6se7pmW6sKEhgxd9J20rUYwYNCQbN4fLwDvg==" + }, "Spectre.Console": { "type": "Direct", "requested": "[0.54.0, )", @@ -378,6 +384,13 @@ "TagLibSharp": "[2.3.0, )" } } + }, + "net10.0/linux-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + } } } } \ No newline at end of file From b9941c0de8c2a08be839313328a6fc5206f9c881 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:42:59 +0000 Subject: [PATCH 06/35] fix: lock file --- AutoTag.CLI/packages.lock.json | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json index 9f94235..c524935 100644 --- a/AutoTag.CLI/packages.lock.json +++ b/AutoTag.CLI/packages.lock.json @@ -32,12 +32,6 @@ "Microsoft.Extensions.Options": "10.0.1" } }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "i8eWi2ThxC0/kLkgckJe3zScNARB3x2P2AUTg7Mc6IpnZJAGSg6se7pmW6sKEhgxd9J20rUYwYNCQbN4fLwDvg==" - }, "Spectre.Console": { "type": "Direct", "requested": "[0.54.0, )", @@ -384,13 +378,6 @@ "TagLibSharp": "[2.3.0, )" } } - }, - "net10.0/linux-x64": { - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" - } } } } \ No newline at end of file From 8ef64a972058a838036cb95c2db0ce6bd58dea65 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:46:36 +0000 Subject: [PATCH 07/35] ci: set environment for publish job --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4990274..8a4e4b5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,7 @@ on: jobs: publish: runs-on: ubuntu-latest + environment: publish strategy: matrix: dotnet-runtime: [ 'linux-x64', 'osx-arm64', 'osx-x64', 'win-x64' ] From 60a7076da62d582849dfa6383c14c08b77ad1e11 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:48:30 +0000 Subject: [PATCH 08/35] fix: set RuntimeIdentifiers for CLI project --- AutoTag.CLI/AutoTag.CLI.csproj | 1 + AutoTag.CLI/packages.lock.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 721f4bc..3be4235 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -7,6 +7,7 @@ <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> + <RuntimeIdentifiers>linux-x64;osx-arm64;osx-x64;win-x64</RuntimeIdentifiers> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'Release'"> diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json index c524935..d755c27 100644 --- a/AutoTag.CLI/packages.lock.json +++ b/AutoTag.CLI/packages.lock.json @@ -378,6 +378,34 @@ "TagLibSharp": "[2.3.0, )" } } + }, + "net10.0/linux-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + } + }, + "net10.0/osx-arm64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + } + }, + "net10.0/osx-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + } + }, + "net10.0/win-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + } } } } \ No newline at end of file From c0b990ba921c12bcc5fac35a1b59fae54c419e1e Mon Sep 17 00:00:00 2001 From: mozartsempiano <mozartraulmt@gmail.com> Date: Sun, 12 Apr 2026 11:45:13 -0300 Subject: [PATCH 09/35] Update README --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index e925cea..c95588e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,36 @@ This is because building cross-platform user interfaces with .NET Core is still - Supports mp4 and mkv containers - Subtitle file renaming +## Requirements and running locally +To run AutoTag from source, install the .NET 10 SDK and run commands from the repository root. + +AutoTag fetches metadata from TheMovieDB, so a TMDB API key is required before processing files. Set it 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" +``` + +Check that the CLI starts: +```sh +dotnet run --project AutoTag.CLI -- --help +``` + +Process TV episodes: +```sh +dotnet run --project AutoTag.CLI -- -t "path/to/tv/files" +``` + +Process movies: +```sh +dotnet run --project AutoTag.CLI -- -m "path/to/movie/files" +``` + ## Usage ``` USAGE: From cbce382e5dd320601d301a6ae3df954359d3a2ce Mon Sep 17 00:00:00 2001 From: mozartsempiano <mozartraulmt@gmail.com> Date: Sun, 12 Apr 2026 12:14:21 -0300 Subject: [PATCH 10/35] Add --include-adult tag --- AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs | 11 ++++++++++- AutoTag.Core/Config/AutoTagConfig.cs | 4 +++- AutoTag.Core/TMDB/TMDBService.cs | 4 ++-- README.md | 9 ++++++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs index 49a47bc..a519a04 100644 --- a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs +++ b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs @@ -40,6 +40,10 @@ public partial class RootCommandSettings [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; } + private void SetTaggingOptions(AutoTagConfig config) { if (TVMode) @@ -86,5 +90,10 @@ private void SetTaggingOptions(AutoTagConfig config) { config.EpisodeGroup = EpisodeGroup.Value; } + + if (IncludeAdult.HasValue) + { + config.IncludeAdult = IncludeAdult.Value; + } } -} \ No newline at end of file +} diff --git a/AutoTag.Core/Config/AutoTagConfig.cs b/AutoTag.Core/Config/AutoTagConfig.cs index 1f8ff1f..b0bfccb 100644 --- a/AutoTag.Core/Config/AutoTagConfig.cs +++ b/AutoTag.Core/Config/AutoTagConfig.cs @@ -2,7 +2,7 @@ namespace AutoTag.Core.Config; public class AutoTagConfig { - public const int CurrentVer = 11; + public const int CurrentVer = 12; public int ConfigVer { get; set; } = CurrentVer; @@ -36,6 +36,8 @@ public class AutoTagConfig public List<string> SearchLanguages { get; set; } = []; + public bool IncludeAdult { get; set; } = false; + public bool EpisodeGroup { get; set; } public IEnumerable<FileNameReplace> FileNameReplaces { get; set; } = []; diff --git a/AutoTag.Core/TMDB/TMDBService.cs b/AutoTag.Core/TMDB/TMDBService.cs index 6eeea7a..c891518 100644 --- a/AutoTag.Core/TMDB/TMDBService.cs +++ b/AutoTag.Core/TMDB/TMDBService.cs @@ -39,7 +39,7 @@ public class TMDBService(TMDbClient client, AutoTagConfig config) : ITMDBService private Dictionary<int, string> MovieGenres = []; public Task<SearchContainer<SearchTv>> SearchTvShowAsync(string query) - => client.SearchTvShowAsync(query, config.Language); + => client.SearchTvShowAsync(query, config.Language, includeAdult: config.IncludeAdult); public Task<TvShow> GetTvShowWithEpisodeGroupsAsync(int id) => client.GetTvShowAsync(id, TvShowMethods.EpisodeGroups, config.Language); @@ -68,7 +68,7 @@ public Task<ImagesWithId> GetTvShowImagesAsync(int id) => client.GetTvShowImagesAsync(id, $"{config.Language},null"); public Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0, string? language = null) - => client.SearchMovieAsync(query, language ?? config.Language, year: year); + => client.SearchMovieAsync(query, language ?? config.Language, includeAdult: config.IncludeAdult, year: year); public Task<TMDbMovie> GetMovieAsync(int movieId) => client.GetMovieAsync(movieId, language: config.Language); diff --git a/README.md b/README.md index c95588e..19cf710 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Process TV episodes: dotnet run --project AutoTag.CLI -- -t "path/to/tv/files" ``` +Include adult titles in TMDB searches: +```sh +dotnet run --project AutoTag.CLI -- -t --include-adult "path/to/tv/files" +``` + Process movies: ```sh dotnet run --project AutoTag.CLI -- -m "path/to/movie/files" @@ -79,6 +84,7 @@ OPTIONS: --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 + --include-adult Include adult titles in TMDB searches ``` @@ -135,7 +141,7 @@ Enabling this option will prompt you to select the episode ordering for each sho ## 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: ``` -"configVer": 11, // Internal use +"configVer": 12, // Internal use "mode": 0, // Default tagging mode, 0 = TV, 1 = Movie "manualMode": false, // Manual tagging mode "verbose": false, // Verbose output @@ -151,6 +157,7 @@ AutoTag creates a config file to store default preferences at `~/.config/autotag "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": "" } ``` From 23d814392821a169de7b684d156dd0cbe4746c24 Mon Sep 17 00:00:00 2001 From: mozartsempiano <mozartraulmt@gmail.com> Date: Sun, 12 Apr 2026 12:38:21 -0300 Subject: [PATCH 11/35] Improve subtitle renaming and moving --- .../Settings/RootCommandSettings.Tagging.cs | 18 ++ .../Files/FileFinder/FindFilesToProcess.cs | 68 ++++++ .../Files/FileWriter/WriteAsync.cs | 209 ++++++++++++++++++ AutoTag.Core/Config/AutoTagConfig.cs | 6 +- AutoTag.Core/Files/FileFinder.cs | 174 ++++++++++++++- AutoTag.Core/Files/FileSystem.cs | 14 +- AutoTag.Core/Files/FileWriter.cs | 110 ++++++++- AutoTag.Core/Files/TaggingFile.cs | 4 +- README.md | 25 ++- 9 files changed, 607 insertions(+), 21 deletions(-) diff --git a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs index a519a04..5a15ed9 100644 --- a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs +++ b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs @@ -44,6 +44,14 @@ public partial class RootCommandSettings [Description("Include adult titles in TMDB searches")] public bool? IncludeAdult { get; init; } + [CommandOption("--organize-folders")] + [Description("Move files into media folders after tagging")] + public bool? OrganizeFolders { 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 (TVMode) @@ -95,5 +103,15 @@ private void SetTaggingOptions(AutoTagConfig config) { config.IncludeAdult = IncludeAdult.Value; } + + if (OrganizeFolders.HasValue) + { + config.OrganizeFolders = OrganizeFolders.Value; + } + + if (RemoveEmptyFolders.HasValue) + { + config.RemoveEmptyFolders = RemoveEmptyFolders.Value; + } } } diff --git a/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs index edad1f4..90d27af 100644 --- a/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs +++ b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs @@ -16,6 +16,8 @@ public void Should_FindCommonVideoContainers_AndOnlyTagKnownSafeFormats() File.WriteAllText(Path.Combine(tempDirectory.FullName, "movie.mkv"), string.Empty); File.WriteAllText(Path.Combine(tempDirectory.FullName, "clip.mov"), string.Empty); File.WriteAllText(Path.Combine(tempDirectory.FullName, "notes.txt"), string.Empty); + var nestedDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "Nested")); + File.WriteAllText(Path.Combine(nestedDirectory.FullName, "nested.mp4"), string.Empty); var finder = new AutoTag.Core.Files.FileFinder( new AutoTagConfig { RenameSubtitles = false }, @@ -28,7 +30,73 @@ public void Should_FindCommonVideoContainers_AndOnlyTagKnownSafeFormats() 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.RootPath == tempDirectory.FullName); result.Should().NotContain(file => file.Path.EndsWith("notes.txt")); + result.Should().OnlyContain(file => file.RootPath == tempDirectory.FullName); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public void Should_Associate_Ass_Subtitle_With_Matching_Video_When_RenameSubtitles_Enabled() + { + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + var videoPath = Path.Combine(tempDirectory.FullName, "show.mkv"); + var subtitlePath = Path.Combine(tempDirectory.FullName, "show.ass"); + File.WriteAllText(videoPath, string.Empty); + File.WriteAllText(subtitlePath, string.Empty); + + var finder = new AutoTag.Core.Files.FileFinder( + new AutoTagConfig { RenameSubtitles = true }, + new AutoTag.Core.Files.FileSystem(), + new Mock<IUserInterface>().Object + ); + + var result = finder.FindFilesToProcess([tempDirectory]); + + result.Should().ContainSingle(file => file.Path == videoPath && file.SubtitlePath == subtitlePath); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public void Should_Associate_Loose_Subtitles_With_Matching_TV_Episodes_When_RenameSubtitles_Enabled() + { + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + var seriesDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "Koakuma Kanojo the Animation")); + var seasonDirectory = Directory.CreateDirectory(Path.Combine(seriesDirectory.FullName, "Season 01")); + var subtitleDirectory = Directory.CreateDirectory(Path.Combine(seriesDirectory.FullName, "Eng")); + + var videoPath = Path.Combine(seasonDirectory.FullName, "Koakuma Kanojo the Animation - 1x01 - So Sticky and Covered in Juice.mkv"); + var subtitlePath1 = Path.Combine(subtitleDirectory.FullName, "[Shinkiro-raw] Koakuma Kanojo The Animation - 01 [7AD743D9].eng [EROBEAT_LQ].ass"); + var subtitlePath2 = Path.Combine(subtitleDirectory.FullName, "[Shinkiro-raw] Koakuma Kanojo The Animation - 01 [7AD743D9].eng [SubDESU-H].ass"); + File.WriteAllText(videoPath, string.Empty); + File.WriteAllText(subtitlePath1, string.Empty); + File.WriteAllText(subtitlePath2, string.Empty); + + var finder = new AutoTag.Core.Files.FileFinder( + new AutoTagConfig { RenameSubtitles = true }, + new AutoTag.Core.Files.FileSystem(), + new Mock<IUserInterface>().Object + ); + + var result = finder.FindFilesToProcess([tempDirectory]); + + var video = result.Should().ContainSingle(file => file.Path == videoPath).Subject; + video.SubtitlePaths.Should().BeEquivalentTo([subtitlePath1, subtitlePath2]); + result.Should().NotContain(file => file.Path == subtitlePath1 || file.Path == subtitlePath2); } finally { diff --git a/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs index 83d824f..00532cc 100644 --- a/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs +++ b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs @@ -1,6 +1,7 @@ using AutoTag.Core.Config; using AutoTag.Core.Files; using AutoTag.Core.Movie; +using AutoTag.Core.TV; namespace AutoTag.Core.Test.Files.FileWriter; @@ -74,4 +75,212 @@ public async Task Should_Not_Skip_When_Subtitle_Name_Is_Still_Wrong() mockFs.Verify(fs => fs.Move(@"C:\Media\subtitle.srt", @"C:\Media\Movie (2020).srt"), Times.Once); mockUi.Verify(ui => ui.SetStatus("File skipped - already named correctly", MessageType.Information), Times.Never); } + + [Fact] + public async Task Should_Move_Movie_Into_Named_Folder_When_OrganizeFolders_Enabled() + { + var config = new AutoTagConfig + { + OrganizeFolders = true, + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock<IFileSystem>(); + mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020)\Movie (2020).mkv")) + .Returns(false); + + var mockUi = new Mock<IUserInterface>(); + var mockCoverArtFetcher = new Mock<ICoverArtFetcher>(); + + var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Downloads\raw.mkv", + RootPath = @"C:\Media" + }; + + 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.CreateDirectory(It.Is<DirectoryInfo>(d => d.FullName == @"C:\Media\Movie (2020)")), Times.Once); + mockFs.Verify(fs => fs.Move(@"C:\Media\Downloads\raw.mkv", @"C:\Media\Movie (2020)\Movie (2020).mkv"), Times.Once); + } + + [Fact] + public async Task Should_Move_TV_Special_Into_Specials_Folder_When_OrganizeFolders_Enabled() + { + var config = new AutoTagConfig + { + OrganizeFolders = true, + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock<IFileSystem>(); + mockFs.Setup(fs => fs.Exists(@"C:\Media\Series\Specials\Series - 0x01 - Pilot.mkv")) + .Returns(false); + + var mockUi = new Mock<IUserInterface>(); + var mockCoverArtFetcher = new Mock<ICoverArtFetcher>(); + + var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Downloads\raw.mkv", + RootPath = @"C:\Media" + }; + + var metadata = new TVFileMetadata + { + SeriesName = "Series", + Season = 0, + Episode = 1, + Title = "Pilot" + }; + + var result = await writer.WriteAsync(taggingFile, metadata); + + result.Should().BeTrue(); + mockFs.Verify(fs => fs.CreateDirectory(It.Is<DirectoryInfo>(d => d.FullName == @"C:\Media\Series\Specials")), Times.Once); + mockFs.Verify(fs => fs.Move(@"C:\Media\Downloads\raw.mkv", @"C:\Media\Series\Specials\Series - 0x01 - Pilot.mkv"), Times.Once); + } + + [Fact] + public async Task Should_Remove_Source_Folder_When_Organized_And_Source_Folder_Is_Empty() + { + var config = new AutoTagConfig + { + OrganizeFolders = true, + RemoveEmptyFolders = true, + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock<IFileSystem>(); + mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020)\Movie (2020).mkv")) + .Returns(false); + mockFs.Setup(fs => fs.DirectoryExists(@"C:\Media\Downloads")) + .Returns(true); + mockFs.Setup(fs => fs.DirectoryIsEmpty(@"C:\Media\Downloads")) + .Returns(true); + + var writer = new Core.Files.FileWriter( + new Mock<ICoverArtFetcher>().Object, + config, + mockFs.Object, + new Mock<IUserInterface>().Object + ); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Downloads\raw.mkv", + RootPath = @"C:\Media" + }; + 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(@"C:\Media\Downloads"), Times.Once); + } + + [Fact] + public async Task Should_Not_Remove_Source_Folder_When_It_Is_Not_Empty() + { + var config = new AutoTagConfig + { + OrganizeFolders = true, + RemoveEmptyFolders = true, + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock<IFileSystem>(); + mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020)\Movie (2020).mkv")) + .Returns(false); + mockFs.Setup(fs => fs.DirectoryExists(@"C:\Media\Downloads")) + .Returns(true); + mockFs.Setup(fs => fs.DirectoryIsEmpty(@"C:\Media\Downloads")) + .Returns(false); + + var writer = new Core.Files.FileWriter( + new Mock<ICoverArtFetcher>().Object, + config, + mockFs.Object, + new Mock<IUserInterface>().Object + ); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Downloads\raw.mkv", + RootPath = @"C:\Media" + }; + 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<string>()), Times.Never); + } + + [Fact] + public async Task Should_Rename_Multiple_Subtitles_With_Numbered_Suffixes() + { + var config = new AutoTagConfig + { + OrganizeFolders = true, + RenameFiles = true, + TagFiles = false + }; + + var mockFs = new Mock<IFileSystem>(); + var writer = new Core.Files.FileWriter( + new Mock<ICoverArtFetcher>().Object, + config, + mockFs.Object, + new Mock<IUserInterface>().Object + ); + var taggingFile = new TaggingFile + { + Path = @"C:\Media\Downloads\raw.mkv", + RootPath = @"C:\Media", + SubtitlePaths = + [ + @"C:\Media\Downloads\sub-one.ass", + @"C:\Media\Downloads\sub-two.ass" + ] + }; + var metadata = new TVFileMetadata + { + SeriesName = "Series", + Season = 1, + Episode = 1, + Title = "Pilot" + }; + + var result = await writer.WriteAsync(taggingFile, metadata); + + result.Should().BeTrue(); + mockFs.Verify(fs => fs.Move( + @"C:\Media\Downloads\sub-one.ass", + @"C:\Media\Series\Season 01\Series - 1x01 - Pilot.1.ass" + ), Times.Once); + mockFs.Verify(fs => fs.Move( + @"C:\Media\Downloads\sub-two.ass", + @"C:\Media\Series\Season 01\Series - 1x01 - Pilot.2.ass" + ), Times.Once); + } } diff --git a/AutoTag.Core/Config/AutoTagConfig.cs b/AutoTag.Core/Config/AutoTagConfig.cs index b0bfccb..c759e3a 100644 --- a/AutoTag.Core/Config/AutoTagConfig.cs +++ b/AutoTag.Core/Config/AutoTagConfig.cs @@ -2,7 +2,7 @@ namespace AutoTag.Core.Config; public class AutoTagConfig { - public const int CurrentVer = 12; + public const int CurrentVer = 14; public int ConfigVer { get; set; } = CurrentVer; @@ -18,6 +18,10 @@ public class AutoTagConfig public bool RenameFiles { get; set; } = true; + public bool OrganizeFolders { get; set; } = false; + + public bool RemoveEmptyFolders { get; set; } = false; + public string TVRenamePattern { get; set; } = "%1 - %2x%3:00 - %4"; public string MovieRenamePattern { get; set; } = "%1 (%2)"; diff --git a/AutoTag.Core/Files/FileFinder.cs b/AutoTag.Core/Files/FileFinder.cs index 696bbc5..fbe476c 100644 --- a/AutoTag.Core/Files/FileFinder.cs +++ b/AutoTag.Core/Files/FileFinder.cs @@ -1,4 +1,6 @@ using AutoTag.Core.Config; +using AutoTag.Core.TV; +using System.Text.RegularExpressions; namespace AutoTag.Core.Files; @@ -40,19 +42,24 @@ public class FileFinder(AutoTagConfig config, IFileSystem fs, IUserInterface ui) ".srt", ".vtt", ".sub", - ".ssa" + ".ssa", + ".ass" }; public List<TaggingFile> FindFilesToProcess(IEnumerable<FileSystemInfo> entries) { var files = FindFilesInDirectory(entries) - .DistinctBy(f => f.Path); + .DistinctBy(f => f.Path) + .ToList(); if (config.RenameSubtitles && string.IsNullOrEmpty(config.ParsePattern)) { files = files .GroupBy(f => Path.GetFileNameWithoutExtension(f.Path)) - .SelectMany(g => GroupSubtitles(g)); + .SelectMany(g => GroupSubtitles(g)) + .ToList(); + + files = AttachLooseSubtitles(files).ToList(); } return files @@ -70,7 +77,7 @@ private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo { ui.DisplayMessage($"Adding all files in directory '{directory}'", MessageType.Log); - foreach (var file in FindFilesInDirectory(fs.GetDirectoryContents(directory))) + foreach (var file in FindFilesInDirectory(fs.GetDirectoryContents(directory), directory.FullName)) { yield return file; } @@ -83,6 +90,7 @@ private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo yield return new TaggingFile { Path = file.FullName, + RootPath = file.DirectoryName, Taggable = IsTaggableVideoFile(file.Extension) }; } @@ -97,6 +105,15 @@ private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo } } } + + private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo> entries, string rootPath) + { + foreach (var file in FindFilesInDirectory(entries)) + { + file.RootPath = rootPath; + yield return file; + } + } private IEnumerable<TaggingFile> GroupSubtitles(IGrouping<string, TaggingFile> files) { @@ -127,7 +144,9 @@ private IEnumerable<TaggingFile> GroupSubtitles(IGrouping<string, TaggingFile> f yield return new TaggingFile { Path = videoPath, + RootPath = files.FirstOrDefault(f => f.Path == videoPath)?.RootPath, SubtitlePath = subPath, + SubtitlePaths = subPath != null ? [subPath] : [], Taggable = IsTaggableVideoFile(Path.GetExtension(videoPath)) }; } @@ -136,19 +155,166 @@ private IEnumerable<TaggingFile> GroupSubtitles(IGrouping<string, TaggingFile> f yield return new TaggingFile { Path = subPath, + RootPath = files.FirstOrDefault(f => f.Path == subPath)?.RootPath, Taggable = false }; } } } + + private IEnumerable<TaggingFile> AttachLooseSubtitles(List<TaggingFile> files) + { + var handledSubtitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var videosByEpisode = files + .Where(IsVideoFile) + .Select(file => new { File = file, Key = GetVideoEpisodeKey(file) }) + .Where(item => item.Key.HasValue) + .GroupBy(item => item.Key!.Value) + .ToDictionary(group => group.Key, group => group.Select(item => item.File).ToList()); + + foreach (var subtitle in files.Where(IsSubtitleFile)) + { + foreach (var key in GetSubtitleEpisodeKeys(subtitle)) + { + if (!videosByEpisode.TryGetValue(key, out var videos) || videos.Count != 1) + { + continue; + } + + AddSubtitlePath(videos[0], subtitle.Path); + handledSubtitles.Add(subtitle.Path); + break; + } + } + + return files.Where(file => !handledSubtitles.Contains(file.Path)); + } + + private EpisodeKey? GetVideoEpisodeKey(TaggingFile file) + { + if (!EpisodeParser.TryParseEpisodeInfo(Path.GetFileName(file.Path), out var metadata, out _)) + { + return null; + } + + return new EpisodeKey(NormaliseSeriesName(metadata.SeriesName), metadata.Season, metadata.Episode); + } + + private IEnumerable<EpisodeKey> GetSubtitleEpisodeKeys(TaggingFile file) + { + if (EpisodeParser.TryParseEpisodeInfo(Path.GetFileName(file.Path), out var metadata, out _)) + { + yield return new EpisodeKey(NormaliseSeriesName(metadata.SeriesName), metadata.Season, metadata.Episode); + yield break; + } + + var cleanedName = BracketGroupRegex.Replace(Path.GetFileNameWithoutExtension(file.Path), " "); + var matches = LooseEpisodeNumberRegex.Matches(cleanedName) + .Where(match => int.TryParse(match.Groups["Episode"].Value, out var episode) && episode != 720) + .ToList(); + var episodeMatch = matches.LastOrDefault(); + if (episodeMatch == null || !int.TryParse(episodeMatch.Groups["Episode"].Value, out var episodeNumber)) + { + yield break; + } + + var season = GetSeasonFromPath(file.Path); + var seriesNames = new List<string>(); + var parsedSeriesName = cleanedName[..episodeMatch.Groups["Episode"].Index].Trim(' ', '.', '-', '_'); + if (!string.IsNullOrWhiteSpace(parsedSeriesName)) + { + seriesNames.Add(parsedSeriesName); + } + + var directorySeriesName = GetSeriesNameFromPath(file); + if (!string.IsNullOrWhiteSpace(directorySeriesName)) + { + seriesNames.Add(directorySeriesName); + } + + foreach (var seriesName in seriesNames + .Select(NormaliseSeriesName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + yield return new EpisodeKey(seriesName, season, episodeNumber); + } + } + + private static void AddSubtitlePath(TaggingFile video, string subtitlePath) + { + if (!string.IsNullOrEmpty(video.SubtitlePath) && !video.SubtitlePaths.Contains(video.SubtitlePath)) + { + video.SubtitlePaths.Add(video.SubtitlePath); + } + + if (!video.SubtitlePaths.Contains(subtitlePath)) + { + video.SubtitlePaths.Add(subtitlePath); + } + + video.SubtitlePath ??= subtitlePath; + } + + private static string? GetSeriesNameFromPath(TaggingFile file) + { + if (string.IsNullOrEmpty(file.RootPath)) + { + return null; + } + + var relativePath = Path.GetRelativePath(file.RootPath, file.Path); + return relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).FirstOrDefault(); + } + + private static int GetSeasonFromPath(string path) + { + var directory = Path.GetDirectoryName(path); + if (directory == null) + { + return 1; + } + + foreach (var segment in directory.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Reverse()) + { + if (segment.Equals("Specials", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var match = SeasonFolderRegex.Match(segment); + if (match.Success && int.TryParse(match.Groups["Season"].Value, out var season)) + { + return season; + } + } + + return 1; + } + + private static string NormaliseSeriesName(string seriesName) + => MultiSpaceRegex.Replace(seriesName.Replace('.', ' ').Replace('_', ' '), " ") + .Trim(' ', '.', '-', '_') + .ToUpperInvariant(); private bool IsSupportedFile(FileInfo info) => IsVideoFile(info.Extension) || config.RenameSubtitles && IsSubtitleFile(info.Extension); + private bool IsVideoFile(TaggingFile file) => IsVideoFile(Path.GetExtension(file.Path)); + private bool IsVideoFile(string extension) => ProcessableVideoExtensions.Contains(extension); + private bool IsSubtitleFile(TaggingFile file) => IsSubtitleFile(Path.GetExtension(file.Path)); + private bool IsTaggableVideoFile(string extension) => TaggableVideoExtensions.Contains(extension); private bool IsSubtitleFile(string extension) => SubtitleExtensions.Contains(extension); + + private readonly record struct EpisodeKey(string SeriesName, int Season, int Episode); + + private static readonly Regex BracketGroupRegex = new(@"\[[^\]]+\]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex LooseEpisodeNumberRegex = new(@"(?:^|[._\s-])(?<Episode>\d{1,3})(?=$|[._\s-])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex MultiSpaceRegex = new(@"\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SeasonFolderRegex = new(@"^Season\s*(?<Season>\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } diff --git a/AutoTag.Core/Files/FileSystem.cs b/AutoTag.Core/Files/FileSystem.cs index df87110..85eff2f 100644 --- a/AutoTag.Core/Files/FileSystem.cs +++ b/AutoTag.Core/Files/FileSystem.cs @@ -11,6 +11,12 @@ public interface IFileSystem void Move(string sourceFileName, string destFileName); void CreateDirectory(DirectoryInfo directoryInfo); + + bool DirectoryExists(string path); + + bool DirectoryIsEmpty(string path); + + void DeleteDirectory(string path); Stream OpenReadStream(string path); @@ -27,8 +33,14 @@ public IEnumerable<FileSystemInfo> GetDirectoryContents(DirectoryInfo directoryI public void Move(string sourceFileName, string destFileName) => File.Move(sourceFileName, destFileName); public void CreateDirectory(DirectoryInfo directoryInfo) => directoryInfo.Create(); + + 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 Stream OpenReadStream(string path) => new FileStream(path, FileMode.Open, FileAccess.Read); public Stream OpenWriteStream(string path) => new FileStream(path, FileMode.Create, FileAccess.Write); -} \ No newline at end of file +} diff --git a/AutoTag.Core/Files/FileWriter.cs b/AutoTag.Core/Files/FileWriter.cs index c7581a6..89442f6 100644 --- a/AutoTag.Core/Files/FileWriter.cs +++ b/AutoTag.Core/Files/FileWriter.cs @@ -1,4 +1,6 @@ using AutoTag.Core.Config; +using AutoTag.Core.Movie; +using AutoTag.Core.TV; namespace AutoTag.Core.Files; @@ -13,8 +15,9 @@ public async Task<bool> WriteAsync(TaggingFile taggingFile, FileMetadata metadat { bool fileSuccess = true; var targetFileName = GetFileName(metadata.GetFileName(config), Path.GetFileNameWithoutExtension(taggingFile.Path)); + var targetDirectory = GetTargetDirectory(taggingFile, metadata, targetFileName); - if (config.RenameFiles && IsAlreadyNamedCorrectly(taggingFile, targetFileName)) + if (config.RenameFiles && IsAlreadyNamedCorrectly(taggingFile, targetFileName, targetDirectory)) { ui.SetStatus("File skipped - already named correctly", MessageType.Information); return true; @@ -27,11 +30,17 @@ public async Task<bool> WriteAsync(TaggingFile taggingFile, FileMetadata metadat if (config.RenameFiles) { - fileSuccess &= RenameFile(taggingFile.Path, targetFileName, null); + fileSuccess &= RenameFile(taggingFile.Path, targetFileName, targetDirectory, null); - if (!string.IsNullOrEmpty(taggingFile.SubtitlePath)) + var subtitlePaths = GetSubtitlePaths(taggingFile); + for (var i = 0; i < subtitlePaths.Count; i++) { - fileSuccess &= RenameFile(taggingFile.SubtitlePath, targetFileName, "subtitle "); + fileSuccess &= RenameFile( + subtitlePaths[i], + GetSubtitleTargetFileName(targetFileName, i, subtitlePaths.Count), + targetDirectory, + "subtitle " + ); } } @@ -97,13 +106,14 @@ private async Task<bool> TagFileAsync(TaggingFile taggingFile, FileMetadata meta return fileSuccess; } - private bool RenameFile(string path, string newName, string? msgPrefix) + private bool RenameFile(string path, string newName, string targetDirectory, string? msgPrefix) { bool fileSuccess = true; - string newPath = GetTargetPath(path, newName); + string newPath = GetTargetPath(path, newName, targetDirectory); if (path != newPath) { + var sourceDirectory = Path.GetDirectoryName(path); try { if (fs.Exists(newPath)) @@ -113,9 +123,11 @@ private bool RenameFile(string path, string newName, string? msgPrefix) } else { + fs.CreateDirectory(new DirectoryInfo(targetDirectory)); fs.Move(path, newPath); ui.SetFilePath(newPath); ui.SetStatus($"Successfully renamed {msgPrefix}file to '{Path.GetFileName(newPath)}'", MessageType.Information); + RemoveSourceDirectoryIfEmpty(sourceDirectory, targetDirectory); } } catch (Exception ex) @@ -128,19 +140,93 @@ private bool RenameFile(string path, string newName, string? msgPrefix) return fileSuccess; } - private bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string targetFileName) + private void RemoveSourceDirectoryIfEmpty(string? sourceDirectory, string targetDirectory) + { + if (!config.OrganizeFolders + || !config.RemoveEmptyFolders + || string.IsNullOrEmpty(sourceDirectory) + || Path.GetFullPath(sourceDirectory) == Path.GetFullPath(targetDirectory) + || !fs.DirectoryExists(sourceDirectory) + || !fs.DirectoryIsEmpty(sourceDirectory)) + { + return; + } + + fs.DeleteDirectory(sourceDirectory); + ui.SetStatus($"Removed empty folder '{sourceDirectory}'", MessageType.Information); + } + + private bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string targetFileName, string targetDirectory) { - if (taggingFile.Path != GetTargetPath(taggingFile.Path, targetFileName)) + if (taggingFile.Path != GetTargetPath(taggingFile.Path, targetFileName, targetDirectory)) { return false; } - return string.IsNullOrEmpty(taggingFile.SubtitlePath) - || taggingFile.SubtitlePath == GetTargetPath(taggingFile.SubtitlePath, targetFileName); + var subtitlePaths = GetSubtitlePaths(taggingFile); + for (var i = 0; i < subtitlePaths.Count; i++) + { + if (subtitlePaths[i] != GetTargetPath( + subtitlePaths[i], + GetSubtitleTargetFileName(targetFileName, i, subtitlePaths.Count), + targetDirectory + )) + { + return false; + } + } + + return true; + } + + private string GetTargetPath(string path, string targetFileName, string targetDirectory) + => Path.Combine(targetDirectory, targetFileName + Path.GetExtension(path)); + + private static string GetSubtitleTargetFileName(string targetFileName, int index, int subtitleCount) + => subtitleCount == 1 + ? targetFileName + : $"{targetFileName}.{index + 1}"; + + private static List<string> GetSubtitlePaths(TaggingFile taggingFile) + { + var paths = new List<string>(); + if (!string.IsNullOrEmpty(taggingFile.SubtitlePath)) + { + paths.Add(taggingFile.SubtitlePath); + } + + foreach (var subtitlePath in taggingFile.SubtitlePaths) + { + if (!string.IsNullOrEmpty(subtitlePath) && !paths.Contains(subtitlePath)) + { + paths.Add(subtitlePath); + } + } + + return paths; + } + + private string GetTargetDirectory(TaggingFile taggingFile, FileMetadata metadata, string targetFileName) + { + var currentDirectory = Path.GetDirectoryName(taggingFile.Path)!; + if (!config.OrganizeFolders) + { + return currentDirectory; + } + + var rootPath = taggingFile.RootPath ?? currentDirectory; + return metadata switch + { + MovieFileMetadata => Path.Combine(rootPath, targetFileName), + TVFileMetadata tv => Path.Combine(rootPath, GetFileName(tv.SeriesName, tv.SeriesName), GetSeasonFolderName(tv.Season)), + _ => currentDirectory + }; } - private string GetTargetPath(string path, string targetFileName) - => Path.Combine(Path.GetDirectoryName(path)!, targetFileName + Path.GetExtension(path)); + private static string GetSeasonFolderName(int season) + => season == 0 + ? "Specials" + : $"Season {season:00}"; private string GetFileName(string fileName, string oldFileName) { diff --git a/AutoTag.Core/Files/TaggingFile.cs b/AutoTag.Core/Files/TaggingFile.cs index e6a7c67..bc21dd4 100644 --- a/AutoTag.Core/Files/TaggingFile.cs +++ b/AutoTag.Core/Files/TaggingFile.cs @@ -2,7 +2,9 @@ namespace AutoTag.Core.Files; public class TaggingFile { public string Path { get; set; } = null!; + public string? RootPath { get; set; } public string? SubtitlePath { get; set; } + public List<string> SubtitlePaths { get; set; } = []; public bool Taggable { get; set; } = true; public string Status { get; set; } = ""; public bool Success { get; set; } = true; @@ -11,4 +13,4 @@ public override string ToString() { return $"{System.IO.Path.GetFileName(Path)}: {Status}"; } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 19cf710..67338f9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This is because building cross-platform user interfaces with .NET Core is still - Manual tagging mode - Full Linux support (and presumably macOS?) - Supports mp4 and mkv containers -- Subtitle file renaming +- Subtitle file renaming for .srt, .vtt, .sub, .ssa and .ass files ## Requirements and running locally To run AutoTag from source, install the .NET 10 SDK and run commands from the repository root. @@ -48,6 +48,21 @@ Include adult titles in TMDB searches: dotnet run --project AutoTag.CLI -- -t --include-adult "path/to/tv/files" ``` +Move files into TV season folders or movie folders: +```sh +dotnet run --project AutoTag.CLI -- -t --organize-folders "path/to/tv/files" +``` + +Remove source folders after moving files if they are empty: +```sh +dotnet run --project AutoTag.CLI -- -t --organize-folders --remove-empty-folders "path/to/tv/files" +``` + +Rename and move subtitle files with matching videos: +```sh +dotnet run --project AutoTag.CLI -- -t --rename-subs --organize-folders "path/to/tv/files" +``` + Process movies: ```sh dotnet run --project AutoTag.CLI -- -m "path/to/movie/files" @@ -85,6 +100,8 @@ OPTIONS: -l, --language <LANGUAGE> Metadata language (default: en) -g, --episode-group Manually choose alternate episode orderings for a TV show --include-adult Include adult titles in TMDB searches + --organize-folders Move files into media folders after tagging + --remove-empty-folders Remove source folders after moving files if they are empty ``` @@ -141,13 +158,15 @@ Enabling this option will prompt you to select the episode ordering for each sho ## 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: ``` -"configVer": 12, // Internal use +"configVer": 14, // Internal use "mode": 0, // Default tagging mode, 0 = TV, 1 = Movie "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 @@ -162,6 +181,8 @@ AutoTag creates a config file to store default preferences at `~/.config/autotag "fileNameReplaces": [] // File name character replacements. Array of objects of the form { "replace": "", "replacement": "" } ``` +When `renameSubtitles` is enabled, AutoTag renames supported subtitle files along with matching videos. If multiple loose subtitle files match the same TV episode, AutoTag keeps them all and adds numbered suffixes such as `.1.ass` and `.2.ass`. + ## 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. From f2d846ff1fb6dc5815538a21ed40f7d792ad5697 Mon Sep 17 00:00:00 2001 From: mozartsempiano <mozartraulmt@gmail.com> Date: Sun, 12 Apr 2026 13:18:09 -0300 Subject: [PATCH 12/35] IImprove poster tagging --- .../Files/FileWriter/WriteAsync.cs | 31 +++++++++++++++++-- AutoTag.Core/Files/FileWriter.cs | 12 +++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs index 00532cc..f2f9ce1 100644 --- a/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs +++ b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs @@ -8,7 +8,7 @@ namespace AutoTag.Core.Test.Files.FileWriter; public class WriteAsync { [Fact] - public async Task Should_Skip_When_Video_And_Subtitle_Are_Already_Correctly_Named() + public async Task Should_Skip_Rename_When_Video_And_Subtitle_Are_Already_Correctly_Named() { var config = new AutoTagConfig { @@ -37,7 +37,7 @@ public async Task Should_Skip_When_Video_And_Subtitle_Are_Already_Correctly_Name result.Should().BeTrue(); mockFs.Verify(fs => fs.Move(It.IsAny<string>(), It.IsAny<string>()), Times.Never); - mockUi.Verify(ui => ui.SetStatus("File skipped - already named correctly", MessageType.Information), Times.Once); + mockUi.Verify(ui => ui.SetStatus("Rename skipped - already named correctly", MessageType.Information), Times.Once); } [Fact] @@ -76,6 +76,33 @@ public async Task Should_Not_Skip_When_Subtitle_Name_Is_Still_Wrong() mockUi.Verify(ui => ui.SetStatus("File skipped - already named correctly", MessageType.Information), Times.Never); } + [Fact] + public async Task Should_Tag_File_When_Rename_Is_Skipped_Because_Name_Is_Already_Correct() + { + var config = new AutoTagConfig + { + RenameFiles = true, + TagFiles = true + }; + var mockUi = new Mock<IUserInterface>(); + var writer = new Core.Files.FileWriter( + new Mock<ICoverArtFetcher>().Object, + config, + new Mock<IFileSystem>().Object, + mockUi.Object + ); + var metadata = new MovieFileMetadata + { + Title = "Movie", + Date = new DateTime(2020, 1, 1) + }; + + var result = await writer.WriteAsync(new TaggingFile { Path = @"C:\Media\Movie (2020).mkv" }, metadata); + + result.Should().BeFalse(); + mockUi.Verify(ui => ui.SetStatus("Error: Failed to write tags to file", MessageType.Error, It.IsAny<Exception>()), Times.Once); + } + [Fact] public async Task Should_Move_Movie_Into_Named_Folder_When_OrganizeFolders_Enabled() { diff --git a/AutoTag.Core/Files/FileWriter.cs b/AutoTag.Core/Files/FileWriter.cs index 89442f6..fa4825e 100644 --- a/AutoTag.Core/Files/FileWriter.cs +++ b/AutoTag.Core/Files/FileWriter.cs @@ -17,18 +17,18 @@ public async Task<bool> WriteAsync(TaggingFile taggingFile, FileMetadata metadat var targetFileName = GetFileName(metadata.GetFileName(config), Path.GetFileNameWithoutExtension(taggingFile.Path)); var targetDirectory = GetTargetDirectory(taggingFile, metadata, targetFileName); - if (config.RenameFiles && IsAlreadyNamedCorrectly(taggingFile, targetFileName, targetDirectory)) - { - ui.SetStatus("File skipped - already named correctly", MessageType.Information); - return true; - } + var alreadyNamedCorrectly = config.RenameFiles && IsAlreadyNamedCorrectly(taggingFile, targetFileName, targetDirectory); if (config.TagFiles && taggingFile.Taggable) { fileSuccess = await TagFileAsync(taggingFile, metadata); } - if (config.RenameFiles) + if (alreadyNamedCorrectly) + { + ui.SetStatus("Rename skipped - already named correctly", MessageType.Information); + } + else if (config.RenameFiles) { fileSuccess &= RenameFile(taggingFile.Path, targetFileName, targetDirectory, null); From e969314cae308afa290df0133bb9f843de23626c Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:34:18 +0100 Subject: [PATCH 13/35] ci: upload artifacts to release --- .github/workflows/publish.yml | 15 ++++++++++----- AutoTag.CLI/AutoTag.CLI.csproj | 2 ++ AutoTag.CLI/packages.lock.json | 6 ++++++ AutoTag.Core.Test/AutoTag.Core.Test.csproj | 5 +++++ AutoTag.Core/packages.lock.json | 3 ++- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8a4e4b5..6589a2e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,14 +1,12 @@ name: publish on: release: - push: - branches: - - 'github-actions' + types: + - created jobs: publish: runs-on: ubuntu-latest - environment: publish strategy: matrix: dotnet-runtime: [ 'linux-x64', 'osx-arm64', 'osx-x64', 'win-x64' ] @@ -27,4 +25,11 @@ jobs: - name: Publish env: TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} - run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --no-restore \ No newline at end of file + run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --no-restore + + - name: Upload + 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/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 3be4235..98332a3 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -18,6 +18,8 @@ <!-- needed to serialize config. see https://devblogs.microsoft.com/dotnet/system-text-json-in-dotnet-8/#disabling-reflection-defaults --> <JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <TrimMode>partial</TrimMode> + <DebugSymbols>False</DebugSymbols> + <DebugType>None</DebugType> </PropertyGroup> <ItemGroup> diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json index d755c27..7e2ce63 100644 --- a/AutoTag.CLI/packages.lock.json +++ b/AutoTag.CLI/packages.lock.json @@ -32,6 +32,12 @@ "Microsoft.Extensions.Options": "10.0.1" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "i8eWi2ThxC0/kLkgckJe3zScNARB3x2P2AUTg7Mc6IpnZJAGSg6se7pmW6sKEhgxd9J20rUYwYNCQbN4fLwDvg==" + }, "Spectre.Console": { "type": "Direct", "requested": "[0.54.0, )", diff --git a/AutoTag.Core.Test/AutoTag.Core.Test.csproj b/AutoTag.Core.Test/AutoTag.Core.Test.csproj index 05b9e6a..c560cd2 100644 --- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj +++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj @@ -24,6 +24,11 @@ </PackageReference> </ItemGroup> + <PropertyGroup Condition="'$(Configuration)' == 'Release'"> + <DebugSymbols>False</DebugSymbols> + <DebugType>None</DebugType> + </PropertyGroup> + <ItemGroup> <Using Include="Xunit"/> </ItemGroup> diff --git a/AutoTag.Core/packages.lock.json b/AutoTag.Core/packages.lock.json index 6ebb13c..97264ac 100644 --- a/AutoTag.Core/packages.lock.json +++ b/AutoTag.Core/packages.lock.json @@ -160,6 +160,7 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" } - } + }, + "net10.0/win-x64": {} } } \ No newline at end of file From 0b44cbb17fda224fbab707bd7865cd0c3679d1e2 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:36:22 +0100 Subject: [PATCH 14/35] fix: lock file --- AutoTag.CLI/packages.lock.json | 6 ------ AutoTag.Core/packages.lock.json | 3 +-- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json index 7e2ce63..d755c27 100644 --- a/AutoTag.CLI/packages.lock.json +++ b/AutoTag.CLI/packages.lock.json @@ -32,12 +32,6 @@ "Microsoft.Extensions.Options": "10.0.1" } }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "i8eWi2ThxC0/kLkgckJe3zScNARB3x2P2AUTg7Mc6IpnZJAGSg6se7pmW6sKEhgxd9J20rUYwYNCQbN4fLwDvg==" - }, "Spectre.Console": { "type": "Direct", "requested": "[0.54.0, )", diff --git a/AutoTag.Core/packages.lock.json b/AutoTag.Core/packages.lock.json index 97264ac..6ebb13c 100644 --- a/AutoTag.Core/packages.lock.json +++ b/AutoTag.Core/packages.lock.json @@ -160,7 +160,6 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" } - }, - "net10.0/win-x64": {} + } } } \ No newline at end of file From 9f78c8cc47e5f5a93d9dd03156dcfb53bd857a01 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:42:23 +0100 Subject: [PATCH 15/35] fix: set GH_TOKEN variable in upload step --- .github/workflows/publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6589a2e..17a2ccb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,6 +28,8 @@ jobs: 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}" From b9f42dbf2cb41d6f6c13df3e9af50c431753e827 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:56:03 +0100 Subject: [PATCH 16/35] ci: remove explicit restore step to fix trimming --- .github/workflows/build.yml | 5 +---- .github/workflows/publish.yml | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0909bb..032edae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,9 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - - - name: Restore dependencies - run: dotnet restore --locked-mode - name: Build - run: dotnet build --no-restore + run: dotnet build --locked-mode - name: Test run: dotnet test --no-build --verbosity normal \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 17a2ccb..878d161 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,13 +19,10 @@ jobs: with: dotnet-version: 10.0.x - - name: Restore dependencies - run: dotnet restore --locked-mode - - name: Publish env: TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} - run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --no-restore + run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --locked-mode - name: Upload env: From decd4af407186e5823d8c7821d71c1820370cbb4 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:58:34 +0100 Subject: [PATCH 17/35] fix: restore build workflow restore step --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 032edae..c0909bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,12 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x + + - name: Restore dependencies + run: dotnet restore --locked-mode - name: Build - run: dotnet build --locked-mode + run: dotnet build --no-restore - name: Test run: dotnet test --no-build --verbosity normal \ No newline at end of file From f1e3aa88971b646a4485c5baa5fb21bdef81fc82 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:06:51 +0100 Subject: [PATCH 18/35] fix: restore publish workflow restore step and set PublishTrimmed at restore time --- .github/workflows/publish.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 878d161..980d2ca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,10 +19,15 @@ jobs: 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 --locked-mode /p:PublishTrimmed=true + - name: Publish env: TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} - run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --locked-mode + run: dotnet publish AutoTag.CLI -r ${{ matrix.dotnet-runtime }} -c Release --no-restore - name: Upload env: From 2fc27c10cb19132ea95e84d8f5d2365dd662808f Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:11:40 +0100 Subject: [PATCH 19/35] ci: disable lockfile (doesn't work well with multiple runtimes and trimming) --- .github/workflows/build.yml | 2 +- .github/workflows/publish.yml | 2 +- AutoTag.CLI/AutoTag.CLI.csproj | 1 - AutoTag.CLI/packages.lock.json | 411 --------------------- AutoTag.Core.Test/AutoTag.Core.Test.csproj | 1 - AutoTag.Core/AutoTag.Core.csproj | 1 - AutoTag.Core/packages.lock.json | 165 --------- 7 files changed, 2 insertions(+), 581 deletions(-) delete mode 100644 AutoTag.CLI/packages.lock.json delete mode 100644 AutoTag.Core/packages.lock.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0909bb..eb66ae8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore --locked-mode + run: dotnet restore - name: Build run: dotnet build --no-restore diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 980d2ca..5191df6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,7 +22,7 @@ jobs: - 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 --locked-mode /p:PublishTrimmed=true + run: dotnet restore -p:PublishTrimmed=true - name: Publish env: diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 98332a3..ac4e23e 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -6,7 +6,6 @@ <Version>4.0.3</Version> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> - <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> <RuntimeIdentifiers>linux-x64;osx-arm64;osx-x64;win-x64</RuntimeIdentifiers> </PropertyGroup> diff --git a/AutoTag.CLI/packages.lock.json b/AutoTag.CLI/packages.lock.json deleted file mode 100644 index d755c27..0000000 --- a/AutoTag.CLI/packages.lock.json +++ /dev/null @@ -1,411 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net10.0": { - "Microsoft.Extensions.Hosting": { - "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "0jjfjQSOFZlHhwOoIQw0WyzxtkDMYdnPY3iFrOLasxYq/5J4FDt1HWT8TktBclOVjFY1FOOkoOc99X7AhbqSIw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Binder": "10.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", - "Microsoft.Extensions.Configuration.Json": "10.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.1", - "Microsoft.Extensions.DependencyInjection": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Diagnostics": "10.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", - "Microsoft.Extensions.FileProviders.Physical": "10.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Configuration": "10.0.1", - "Microsoft.Extensions.Logging.Console": "10.0.1", - "Microsoft.Extensions.Logging.Debug": "10.0.1", - "Microsoft.Extensions.Logging.EventLog": "10.0.1", - "Microsoft.Extensions.Logging.EventSource": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Spectre.Console": { - "type": "Direct", - "requested": "[0.54.0, )", - "resolved": "0.54.0", - "contentHash": "StDXCFayfy0yB1xzUHT2tgEpV1/HFTiS4JgsAQS49EYTfMixSwwucaQs/bIOCwXjWwIQTMuxjUIxcB5XsJkFJA==" - }, - "Spectre.Console.Cli": { - "type": "Direct", - "requested": "[0.53.1, )", - "resolved": "0.53.1", - "contentHash": "y//7ZZ0shhvgXzoJXJzMaLGA4dPem8qCbkyv9khfE8NQDlH4hPVsHOHYoz6uzAwiTX9Yd7OnX3wNOX/wRfPUOg==", - "dependencies": { - "Spectre.Console": "0.53.1" - } - }, - "Spectre.Console.Json": { - "type": "Direct", - "requested": "[0.54.0, )", - "resolved": "0.54.0", - "contentHash": "ulIDhznzFiG848XQvno15pIVzunr/OjSPaGCfrXw00oR8OdNWJPW9y5uglhVqjmcDDTsaGtfJgyuYBLK5H8TFw==", - "dependencies": { - "Spectre.Console": "0.54.0" - } - }, - "ThisAssembly.Constants": { - "type": "Direct", - "requested": "[2.1.2, )", - "resolved": "2.1.2", - "contentHash": "rq7HoR45a4H1NM8KPG+rOPhv6z36wpB088+tB6KCbltBsnx1uwCpS3IvLmMZh3EOnZarRjXE9oiVgGMFCCJ1Wg==" - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.CommandLine": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "s5cxcdtIig66YT3J+7iHflMuorznK8kXuwBBPHMp4KImx5ZGE3FRa1Nj9fI/xMwFV+KzUMjqZ2MhOedPH8LiBQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "csD8Eps3HQ3yc0x6NhgTV+aIFKSs3qVlVCtFnMHz/JOjnv7eEj/qSXKXiK9LzBzB1qSfAVqFnB5iaX2nUmagIQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "N/6GiwiZFCBFZDk3vg1PhHW3zMqqu5WWpmeZAA9VTXv7Q8pr8NZR/EQsH0DjzqydDksJtY6EQBsu81d5okQOlA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", - "Microsoft.Extensions.FileProviders.Physical": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "0zW3eYBJlRctmgqk5s0kFIi5o5y2g80mvGCD8bkYxREPQlKUnr0ndU/Sop+UDIhyWN0fIi4RW63vo7BKTi7ncA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.UserSecrets": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "ULEJ0nkaW90JYJGkFujPcJtADXcJpXiSOLbokPcWJZ8iDbtDINifEYAUVqZVr81IDNTrRFul6O8RolOKOsgFPg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Json": "10.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", - "Microsoft.Extensions.FileProviders.Physical": "10.0.1" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "+b3DligYSZuoWltU5YdbMpIEUHNZPgPrzWfNiIuDkMdqOl93UxYB5KzS3lgpRfTXJhTNpo/CZ8w/sTkDTPDdxQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "4bxzGXIzZnz0Bf7czQ72jGvpOqJsRW/44PS7YLFXTTnu6cNcPvmSREDvBoH0ZVP2hAbMfL4sUoCUn54k70jPWw==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "49dFvGJjLSwGn76eHnP1gBvCJkL8HRYpCrG0DCvsP6wRpEQRLN2Fq8rTxbP+6jS7jmYKCnSVO5C65v4mT3rzeA==" - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "qmoQkVZcbm4/gFpted3W3Y+1kTATZTcUhV3mRkbtpfBXlxWCHwh/2oMffVcCruaGOfJuEnyAsGyaSUouSdECOw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Diagnostics": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Zg8LLnfZs5o2RCHD/+9NfDtJ40swauemwCa7sI8gQoAye/UJHRZNpCtC7a5XE7l9Z7mdI8iMWnLZ6m7Q6S3jLg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Binder": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.Console": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "38Q8sEHwQ/+wVO/mwQBa0fcdHbezFpusHE+vBw/dSr6Fq/kzZm3H/NQX511Jki/R3FHd64IY559gdlHZQtYeEA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Configuration": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.Debug": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "VqfTvbX9C6BA0VeIlpzPlljnNsXxiI5CdUHb9ksWERH94WQ6ft3oLGUAa4xKcDGu4xF+rIZ8wj7IOAd6/q7vGw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Zp9MM+jFCa7oktIug62V9eNygpkf+6kFVatF+UC/ODeUwIr5givYKy8fYSSI9sWdxqDqv63y1x0mm2VjOl8GOw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "System.Diagnostics.EventLog": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.EventSource": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "WnFvZP+Y+lfeNFKPK/+mBpaCC7EeBDlobrQOqnP7rrw/+vE7yu8Rjczum1xbC0F/8cAHafog84DMp9200akMNQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Binder": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" - }, - "TagLibSharp": { - "type": "Transitive", - "resolved": "2.3.0", - "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" - }, - "TMDbLib": { - "type": "Transitive", - "resolved": "2.3.0", - "contentHash": "kapjC4/ao8mxJ/G5xcZHYNVFvmAzxwWEN2PDkGbbUzAQVfbf85DsA9AUDTrXahTHYq0R7jwGyARs/y6243EPLg==", - "dependencies": { - "Newtonsoft.Json": "13.0.3" - } - }, - "autotag.core": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Caching.Memory": "[10.0.1, )", - "Microsoft.Extensions.DependencyInjection": "[10.0.1, )", - "Microsoft.Extensions.Http": "[10.0.1, )", - "TMDbLib": "[2.3.0, )", - "TagLibSharp": "[2.3.0, )" - } - } - }, - "net10.0/linux-x64": { - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" - } - }, - "net10.0/osx-arm64": { - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" - } - }, - "net10.0/osx-x64": { - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" - } - }, - "net10.0/win-x64": { - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" - } - } - } -} \ 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 c560cd2..c25eef0 100644 --- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj +++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj @@ -6,7 +6,6 @@ <IsPackable>false</IsPackable> <IsTestProject>true</IsTestProject> - <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> <ItemGroup> diff --git a/AutoTag.Core/AutoTag.Core.csproj b/AutoTag.Core/AutoTag.Core.csproj index ba0c37a..2a613e0 100644 --- a/AutoTag.Core/AutoTag.Core.csproj +++ b/AutoTag.Core/AutoTag.Core.csproj @@ -3,7 +3,6 @@ <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> - <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> <ItemGroup> diff --git a/AutoTag.Core/packages.lock.json b/AutoTag.Core/packages.lock.json deleted file mode 100644 index 6ebb13c..0000000 --- a/AutoTag.Core/packages.lock.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net10.0": { - "Microsoft.Extensions.Caching.Memory": { - "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Http": { - "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Diagnostics": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "TagLibSharp": { - "type": "Direct", - "requested": "[2.3.0, )", - "resolved": "2.3.0", - "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" - }, - "TMDbLib": { - "type": "Direct", - "requested": "[2.3.0, )", - "resolved": "2.3.0", - "contentHash": "kapjC4/ao8mxJ/G5xcZHYNVFvmAzxwWEN2PDkGbbUzAQVfbf85DsA9AUDTrXahTHYq0R7jwGyARs/y6243EPLg==", - "dependencies": { - "Newtonsoft.Json": "13.0.3" - } - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Binder": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - } - } - } -} \ No newline at end of file From fdf6aac5b24f0e46515723a79207d1e02785d03e Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sat, 9 May 2026 20:33:22 +0100 Subject: [PATCH 20/35] refactor: return enum from ProcessAsync to give more detail on reason for failure --- AutoTag.CLI/ApiKeys.cs | 31 -- AutoTag.CLI/CLIInterface.cs | 32 +- .../TV/TVProcessor/ProcessAsync.cs | 26 +- AutoTag.Core/Extensions.cs | 2 + AutoTag.Core/IProcessor.cs | 2 +- AutoTag.Core/Movie/MovieProcessor.cs | 14 +- AutoTag.Core/ProcessResult.cs | 10 + AutoTag.Core/TV/TVProcessor.cs | 278 +++++++++--------- 8 files changed, 187 insertions(+), 208 deletions(-) delete mode 100644 AutoTag.CLI/ApiKeys.cs create mode 100644 AutoTag.Core/ProcessResult.cs diff --git a/AutoTag.CLI/ApiKeys.cs b/AutoTag.CLI/ApiKeys.cs deleted file mode 100644 index 145ac5d..0000000 --- a/AutoTag.CLI/ApiKeys.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Reflection; - -namespace AutoTag.CLI; - -public static class ApiKeys -{ - public static string? TMDBKey - { - get - { - var envKey = Normalise(Environment.GetEnvironmentVariable("TMDB_API_KEY")); - if (envKey != null) - { - return envKey; - } - - // Support the repo's older optional Keys.cs override without requiring it for builds. - var legacyKeysType = Assembly.GetExecutingAssembly().GetType("AutoTag.CLI.Keys"); - var legacyKey = legacyKeysType? - .GetProperty("TMDBKey", BindingFlags.Public | BindingFlags.Static)? - .GetValue(null) as string; - - return Normalise(legacyKey); - } - } - - private static string? Normalise(string? value) - => string.IsNullOrWhiteSpace(value) || value == "TMDB_API_KEY" - ? null - : value; -} diff --git a/AutoTag.CLI/CLIInterface.cs b/AutoTag.CLI/CLIInterface.cs index 85662c7..6297b4d 100644 --- a/AutoTag.CLI/CLIInterface.cs +++ b/AutoTag.CLI/CLIInterface.cs @@ -39,7 +39,7 @@ public async Task<int> RunAsync(IEnumerable<FileSystemInfo> entries) CurrentFile = file; AnsiConsole.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]"); - Success &= await ProcessWithFallbackAsync(file, movieProcessor, tvProcessor); + Success &= (await ProcessWithFallbackAsync(file, movieProcessor, tvProcessor)).IsSuccess(); } return ReportResults(Files.Count); @@ -180,20 +180,20 @@ public void SetFilePath(string path) { } - private async Task<bool> ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor, IProcessor tvProcessor) + private async Task<ProcessResult> ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor, IProcessor tvProcessor) { var (primaryMode, secondaryMode) = GetProcessorOrder(file.Path); bool successBeforeAttempt = Success; var primaryResult = await GetProcessor(primaryMode, movieProcessor, tvProcessor).ProcessAsync(file); - if (primaryResult) + if (primaryResult.IsSuccess()) { - return true; + return primaryResult; } - if (!ShouldTryAlternateProcessor(file.Path, file.Status, secondaryMode)) + if (!ShouldTryAlternateProcessor(file.Path, primaryResult, secondaryMode)) { - return false; + return ProcessResult.Fail; } DisplayMessage($"Retrying as {(secondaryMode == Mode.Movie ? "movie" : "TV")}", MessageType.Warning); @@ -228,22 +228,16 @@ private static IProcessor GetProcessor(Mode mode, IProcessor movieProcessor, IPr ? movieProcessor : tvProcessor; - private static bool ShouldTryAlternateProcessor(string path, string status, Mode secondaryMode) + private static bool ShouldTryAlternateProcessor(string path, ProcessResult result, Mode secondaryMode) { - if (!IsAlternateProcessorRetryableStatus(status)) + if (result is ProcessResult.ParseFailure or ProcessResult.NotFound) { - return false; + var fileName = Path.GetFileName(path); + return secondaryMode == Mode.TV + ? MovieNameNormalizer.LooksLikeTvEpisode(fileName) + : MovieNameNormalizer.LooksLikeMovieCandidate(fileName); } - var fileName = Path.GetFileName(path); - return secondaryMode == Mode.TV - ? MovieNameNormalizer.LooksLikeTvEpisode(fileName) - : MovieNameNormalizer.LooksLikeMovieCandidate(fileName); + return false; } - - private static bool IsAlternateProcessorRetryableStatus(string status) - => status.StartsWith("Error: Failed to parse required information from filename", StringComparison.OrdinalIgnoreCase) - || status.StartsWith("Error: failed to find title ", StringComparison.OrdinalIgnoreCase) - || status.StartsWith("Error: Cannot find series ", StringComparison.OrdinalIgnoreCase) - || status.StartsWith("Error: Unable to parse ", StringComparison.OrdinalIgnoreCase); } diff --git a/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs index fd711d2..03f5754 100644 --- a/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs +++ b/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs @@ -11,7 +11,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,11 +20,11 @@ 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<ITMDBService>(); mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>())) @@ -37,12 +37,12 @@ public async Task Should_ReturnFalse_When_UnableToFindShow() Path = "/Show/Show S01E02.mp4" }); - result.Should().BeFalse(); + result.Should().Be(ProcessResult.NotFound); mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>()), Times.Once); } [Fact] - public async Task Should_ReturnTrueAndShowWarning_When_FileSkipped() + public async Task Should_ReturnSkippedAndShowWarning_When_FileSkipped() { var config = new AutoTagConfig { ManualMode = true }; @@ -61,12 +61,12 @@ public async Task Should_ReturnTrueAndShowWarning_When_FileSkipped() Path = "/Show/Show S01E02.mp4" }); - 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<ITVCache>(); @@ -113,11 +113,11 @@ out _ Path = "/Show/Show S01E02.mp4" }); - 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<ITVCache>(); @@ -140,7 +140,7 @@ public async Task Should_ReturnFalseAndShowError_When_ReachedEndOfSearchResultsW Path = "/Show/Show S01E02.mp4" }); - result.Should().BeFalse(); + result.Should().Be(ProcessResult.NotFound); mockUi.Verify(ui => ui.SetStatus("Error: Cannot find Show S01E02 on TheMovieDB", MessageType.Error)); } @@ -178,12 +178,12 @@ public async Task Should_FindCoverArtFromSeason_When_NoCoverFromEpisode() Taggable = true }); - result.Should().BeTrue(); + result.Should().Be(ProcessResult.Success); mockCache.Verify(c => c.TryGetSeasonPoster(It.IsAny<int>(), It.IsAny<int>(), out poster)); } [Fact] - public async Task Should_WriteFileAndReturnTrue_When_Succeeds() + public async Task Should_WriteFileAndReturnSuccess_When_Succeeds() { var config = new AutoTagConfig { AddCoverArt = false }; @@ -213,7 +213,7 @@ public async Task Should_WriteFileAndReturnTrue_When_Succeeds() Path = "/Show/Show S01E02.mp4" }); - result.Should().BeTrue(); + result.Should().Be(ProcessResult.Success); mockWriter.Verify(w => w.WriteAsync(It.IsAny<TaggingFile>(), It.IsAny<FileMetadata>())); } } \ No newline at end of file diff --git a/AutoTag.Core/Extensions.cs b/AutoTag.Core/Extensions.cs index daafd86..abcda2c 100644 --- a/AutoTag.Core/Extensions.cs +++ b/AutoTag.Core/Extensions.cs @@ -68,4 +68,6 @@ public static bool TryFind<T>(this List<T> list, Predicate<T> match, [NotNullWhe return item != null; } + + public static bool IsSuccess(this ProcessResult result) => result is ProcessResult.Success or ProcessResult.Skipped; } \ 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<bool> ProcessAsync( + Task<ProcessResult> ProcessAsync( TaggingFile file ); } \ No newline at end of file diff --git a/AutoTag.Core/Movie/MovieProcessor.cs b/AutoTag.Core/Movie/MovieProcessor.cs index 774f7b2..834a52c 100644 --- a/AutoTag.Core/Movie/MovieProcessor.cs +++ b/AutoTag.Core/Movie/MovieProcessor.cs @@ -7,18 +7,18 @@ namespace AutoTag.Core.Movie; public class MovieProcessor(ITMDBService tmdb, IFileWriter writer, IUserInterface ui, AutoTagConfig config) : IProcessor { - public async Task<bool> ProcessAsync(TaggingFile file) + public async Task<ProcessResult> ProcessAsync(TaggingFile file) { if (MovieNameNormalizer.LooksLikeTvEpisode(Path.GetFileName(file.Path))) { ui.SetStatus("File skipped - filename looks like a TV episode", MessageType.Warning); - return true; + return ProcessResult.ParseFailure; } if (!MovieNameNormalizer.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; + return ProcessResult.ParseFailure; } ui.SetStatus($"Parsed file as {title}", MessageType.Log); @@ -27,9 +27,9 @@ public async Task<bool> ProcessAsync(TaggingFile file) switch (findMovieResult) { case FindResult.Fail: - return false; + return ProcessResult.NotFound; case FindResult.Skip: - return true; + return ProcessResult.Skipped; } ui.SetStatus($"Found {selectedResult!.Title} ({selectedResult.ReleaseDate?.Year.ToString() ?? "unknown year"}) on TheMovieDB", MessageType.Information); @@ -38,7 +38,9 @@ public async Task<bool> ProcessAsync(TaggingFile file) bool taggingSuccess = await writer.WriteAsync(file, result); - return taggingSuccess && result.Success && result.Complete; + return taggingSuccess && result.Success && result.Complete + ? ProcessResult.Success + : ProcessResult.Fail; } private async Task<(FindResult, SearchMovie?)> FindMovieAsync(string title, int? year) 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/TV/TVProcessor.cs b/AutoTag.Core/TV/TVProcessor.cs index 22cdce9..29c11f5 100644 --- a/AutoTag.Core/TV/TVProcessor.cs +++ b/AutoTag.Core/TV/TVProcessor.cs @@ -6,16 +6,16 @@ using TMDbLib.Objects.TvShows; namespace AutoTag.Core.TV; -public class TVProcessor(ITMDBService tmdb, IFileWriter writer, ITVCache cache, IUserInterface ui, AutoTagConfig config) : IProcessor -{ - private static readonly Regex SeriesYearSuffixRegex = new(@"\s+\((19|20)\d{2}\)$", RegexOptions.CultureInvariant); - - public async Task<bool> ProcessAsync(TaggingFile file) +public class TVProcessor(ITMDBService tmdb, IFileWriter writer, ITVCache cache, IUserInterface ui, AutoTagConfig config) : IProcessor +{ + private static readonly Regex SeriesYearSuffixRegex = new(@"\s+\((19|20)\d{2}\)$", RegexOptions.CultureInvariant); + + public async Task<ProcessResult> ProcessAsync(TaggingFile file) { var metadata = ParseFileName(file); if (metadata == null) { - return false; + return ProcessResult.ParseFailure; } ui.SetStatus($"Parsed file as {metadata}", MessageType.Log); @@ -24,10 +24,10 @@ public async Task<bool> ProcessAsync(TaggingFile file) switch (findShowResult) { case FindResult.Fail: - return false; + return ProcessResult.NotFound; case FindResult.Skip: ui.SetStatus("File skipped", MessageType.Warning); - return true; + return ProcessResult.Skipped; } string? lastResultMessage = null; @@ -40,7 +40,7 @@ public async Task<bool> ProcessAsync(TaggingFile file) if (findEpisodeResult == FindResult.Fail) { - return false; + return ProcessResult.NotFound; } else if (findEpisodeResult == FindResult.Success) { @@ -54,7 +54,7 @@ public async Task<bool> ProcessAsync(TaggingFile file) { ui.SetStatus(lastResultMessage, MessageType.Error); - return false; + return ProcessResult.NotFound; } ui.SetStatus($"Found {metadata} ({metadata.Title}) on TheMovieDB", MessageType.Information); @@ -66,7 +66,9 @@ public async Task<bool> ProcessAsync(TaggingFile file) var taggingSuccess = await writer.WriteAsync(file, metadata); - return taggingSuccess && metadata.Success && metadata.Complete; + return taggingSuccess && metadata.Success && metadata.Complete + ? ProcessResult.Success + : ProcessResult.Fail; } public TVFileMetadata? ParseFileName(TaggingFile file) @@ -84,22 +86,22 @@ public async Task<bool> ProcessAsync(TaggingFile file) return null; } } - else - { - try - { - var fullPath = Path.GetFullPath(file.Path); - var match = Regex.Match(fullPath, config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (!match.Success && fullPath.Contains(Path.DirectorySeparatorChar)) - { - var normalisedPath = fullPath.Replace(Path.DirectorySeparatorChar, '/'); - match = Regex.Match(normalisedPath, config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } - - return new TVFileMetadata - { - SeriesName = match.Groups["SeriesName"].Value, - Season = int.Parse(match.Groups["Season"].Value), + else + { + try + { + var fullPath = Path.GetFullPath(file.Path); + var match = Regex.Match(fullPath, config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success && fullPath.Contains(Path.DirectorySeparatorChar)) + { + var normalisedPath = fullPath.Replace(Path.DirectorySeparatorChar, '/'); + match = Regex.Match(normalisedPath, 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) }; } @@ -111,23 +113,23 @@ public async Task<bool> ProcessAsync(TaggingFile file) } } - 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); - if (searchResults.Results.Count == 0) - { - var normalisedSeriesName = NormaliseSeriesSearchName(seriesName); - if (normalisedSeriesName != seriesName) - { - searchResults = await tmdb.SearchTvShowAsync(normalisedSeriesName); - } - } + 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); + if (searchResults.Results.Count == 0) + { + var normalisedSeriesName = NormaliseSeriesSearchName(seriesName); + if (normalisedSeriesName != seriesName) + { + searchResults = await tmdb.SearchTvShowAsync(normalisedSeriesName); + } + } var seriesResults = searchResults.Results .OrderByDescending(searchResult => SeriesNameSimilarity(seriesName, searchResult.Name)) @@ -253,42 +255,42 @@ public async Task<FindResult> FindShowAsync(string seriesName) return (null, null); } - public async Task<(FindResult Result, string? LastResultErrorMessage)> FindEpisodeAsync(TVFileMetadata metadata, ShowResults show, bool fileIsTaggable) - { - var showData = show.TvSearchResult; - metadata.Id = showData.Id; - metadata.SeriesName = showData.Name; - - // 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 resolvedAbsoluteEpisode = false; - - if (metadata.AbsoluteEpisode.HasValue) - { - var resolvedEpisode = await TryResolveAbsoluteEpisodeAsync(showData.Id, metadata.AbsoluteEpisode.Value); - if (!resolvedEpisode.HasValue) - { - return (FindResult.Skip, - $"Error: Cannot map absolute episode {metadata.AbsoluteEpisode.Value} for {metadata.SeriesName} on TheMovieDB"); - } - - metadata.Season = resolvedEpisode.Value.Season; - metadata.Episode = resolvedEpisode.Value.Episode; - - lookupSeason = resolvedEpisode.Value.Season; - lookupEpisode = resolvedEpisode.Value.Episode; - resolvedAbsoluteEpisode = true; - } - - if (show.HasEpisodeGroupMapping && !resolvedAbsoluteEpisode) - { - if (show.TryGetMapping(metadata.Season, metadata.Episode, out var groupNumbering)) - { - lookupSeason = groupNumbering.Value.Season; - lookupEpisode = groupNumbering.Value.Episode; + public async Task<(FindResult Result, string? LastResultErrorMessage)> FindEpisodeAsync(TVFileMetadata metadata, ShowResults show, bool fileIsTaggable) + { + var showData = show.TvSearchResult; + metadata.Id = showData.Id; + metadata.SeriesName = showData.Name; + + // 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 resolvedAbsoluteEpisode = false; + + if (metadata.AbsoluteEpisode.HasValue) + { + var resolvedEpisode = await TryResolveAbsoluteEpisodeAsync(showData.Id, metadata.AbsoluteEpisode.Value); + if (!resolvedEpisode.HasValue) + { + return (FindResult.Skip, + $"Error: Cannot map absolute episode {metadata.AbsoluteEpisode.Value} for {metadata.SeriesName} on TheMovieDB"); + } + + metadata.Season = resolvedEpisode.Value.Season; + metadata.Episode = resolvedEpisode.Value.Episode; + + lookupSeason = resolvedEpisode.Value.Season; + lookupEpisode = resolvedEpisode.Value.Episode; + resolvedAbsoluteEpisode = true; + } + + if (show.HasEpisodeGroupMapping && !resolvedAbsoluteEpisode) + { + if (show.TryGetMapping(metadata.Season, metadata.Episode, out var groupNumbering)) + { + lookupSeason = groupNumbering.Value.Season; + lookupEpisode = groupNumbering.Value.Episode; } else { @@ -297,10 +299,10 @@ public async Task<FindResult> FindShowAsync(string seriesName) } } - var seasonResult = await GetSeasonAsync(showData.Id, lookupSeason); - - if (seasonResult == null || - !seasonResult.Episodes.TryFind(e => e.EpisodeNumber == lookupEpisode, out var episodeResult)) + var seasonResult = await GetSeasonAsync(showData.Id, lookupSeason); + + if (seasonResult == null || + !seasonResult.Episodes.TryFind(e => e.EpisodeNumber == lookupEpisode, out var episodeResult)) { return (FindResult.Skip, $"Error: Cannot find {metadata} on TheMovieDB"); } @@ -325,47 +327,47 @@ public async Task<FindResult> FindShowAsync(string seriesName) metadata.Actors = credits.Cast.Select(c => c.Name).ToArray(); metadata.Characters = credits.Cast.Select(c => c.Character).ToArray(); } - - return (FindResult.Success, null); - } - - private async Task<TvSeason?> GetSeasonAsync(int showId, int seasonNumber) - { - if (!cache.TryGetSeason(showId, seasonNumber, out var seasonResult)) - { - seasonResult = await tmdb.GetTvSeasonAsync(showId, seasonNumber); - - if (seasonResult != null) - { - cache.AddSeason(showId, seasonNumber, seasonResult); - } - } - - return seasonResult; - } - - private async Task<(int Season, int Episode)?> TryResolveAbsoluteEpisodeAsync(int showId, int absoluteEpisode) - { - var remainingEpisode = absoluteEpisode; - - for (int seasonNumber = 1; seasonNumber <= 100; seasonNumber++) - { - var seasonResult = await GetSeasonAsync(showId, seasonNumber); - if (seasonResult == null || seasonResult.Episodes.Count == 0) - { - break; - } - - if (remainingEpisode <= seasonResult.Episodes.Count) - { - return (seasonNumber, remainingEpisode); - } - - remainingEpisode -= seasonResult.Episodes.Count; - } - - return null; - } + + return (FindResult.Success, null); + } + + private async Task<TvSeason?> GetSeasonAsync(int showId, int seasonNumber) + { + if (!cache.TryGetSeason(showId, seasonNumber, out var seasonResult)) + { + seasonResult = await tmdb.GetTvSeasonAsync(showId, seasonNumber); + + if (seasonResult != null) + { + cache.AddSeason(showId, seasonNumber, seasonResult); + } + } + + return seasonResult; + } + + private async Task<(int Season, int Episode)?> TryResolveAbsoluteEpisodeAsync(int showId, int absoluteEpisode) + { + var remainingEpisode = absoluteEpisode; + + for (int seasonNumber = 1; seasonNumber <= 100; seasonNumber++) + { + var seasonResult = await GetSeasonAsync(showId, seasonNumber); + if (seasonResult == null || seasonResult.Episodes.Count == 0) + { + break; + } + + if (remainingEpisode <= seasonResult.Episodes.Count) + { + return (seasonNumber, remainingEpisode); + } + + remainingEpisode -= seasonResult.Episodes.Count; + } + + return null; + } public async Task FindPosterAsync(TVFileMetadata metadata) { @@ -392,16 +394,16 @@ public async Task FindPosterAsync(TVFileMetadata metadata) } } - private static double SeriesNameSimilarity(string parsedName, string seriesName) - { - if (seriesName.ToLower().Contains(parsedName.ToLower())) - { - return parsedName.Length / (double) seriesName.Length; - } - - return 0; - } - - internal static string NormaliseSeriesSearchName(string seriesName) - => SeriesYearSuffixRegex.Replace(seriesName.Trim(), ""); -} + private static double SeriesNameSimilarity(string parsedName, string seriesName) + { + if (seriesName.ToLower().Contains(parsedName.ToLower())) + { + return parsedName.Length / (double) seriesName.Length; + } + + return 0; + } + + internal static string NormaliseSeriesSearchName(string seriesName) + => SeriesYearSuffixRegex.Replace(seriesName.Trim(), ""); +} From 36f8cad5443cbcff1eba2dd7cfd2941818ded831 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sat, 9 May 2026 21:38:52 +0100 Subject: [PATCH 21/35] refactor: avoid extra API call for movies where possible feat: add CLI option for search language --- .../Settings/RootCommandSettings.Tagging.cs | 9 ++++ AutoTag.Core/Movie/MovieProcessor.cs | 46 +++++++++---------- AutoTag.Core/TMDB/TMDBMovie.cs | 44 ++++++++++++++++++ AutoTag.Core/TMDB/TMDBService.cs | 30 +++++++----- 4 files changed, 93 insertions(+), 36 deletions(-) create mode 100644 AutoTag.Core/TMDB/TMDBMovie.cs diff --git a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs index 5a15ed9..1ee2890 100644 --- a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs +++ b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs @@ -35,6 +35,10 @@ public partial class RootCommandSettings [CommandOption("-l|--language <language>")] [Description("Metadata language (default: en)")] public string? Language { get; init; } + + [CommandOption("-sl|--search-language <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")] @@ -94,6 +98,11 @@ private void SetTaggingOptions(AutoTagConfig config) config.Language = Language; } + if (SearchLanguages.Length > 0) + { + config.SearchLanguages = SearchLanguages.ToList(); + } + if (EpisodeGroup.HasValue) { config.EpisodeGroup = EpisodeGroup.Value; diff --git a/AutoTag.Core/Movie/MovieProcessor.cs b/AutoTag.Core/Movie/MovieProcessor.cs index 834a52c..18e0db3 100644 --- a/AutoTag.Core/Movie/MovieProcessor.cs +++ b/AutoTag.Core/Movie/MovieProcessor.cs @@ -1,8 +1,6 @@ using AutoTag.Core.Config; using AutoTag.Core.Files; using AutoTag.Core.TMDB; -using TMDbLib.Objects.Search; -using TMDbMovie = TMDbLib.Objects.Movies.Movie; namespace AutoTag.Core.Movie; public class MovieProcessor(ITMDBService tmdb, IFileWriter writer, IUserInterface ui, AutoTagConfig config) : IProcessor @@ -43,33 +41,30 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) : ProcessResult.Fail; } - private async Task<(FindResult, SearchMovie?)> FindMovieAsync(string title, int? year) + private async Task<(FindResult, TMDBMovie?)> FindMovieAsync(string title, int? year) { - var manualResults = new List<SearchMovie>(); + var manualResults = new List<TMDBMovie>(); var seenResultIds = new HashSet<int>(); foreach (var attempt in GetSearchAttempts(title, year)) { ui.DisplayMessage( - $@"Searching TMDB for movie ""{attempt.Query}""{(attempt.Year.HasValue ? $" ({attempt.Year.Value})" : "")}{(string.Equals(attempt.Language, config.Language, StringComparison.OrdinalIgnoreCase) ? "" : $" [{attempt.Language}]")}", + $"Searching TheMovieDB for movie {attempt.ToString(config)}", MessageType.Log ); - var searchResults = attempt.Year.HasValue - ? await tmdb.SearchMovieAsync(attempt.Query, attempt.Year.Value, attempt.Language) - : await tmdb.SearchMovieAsync(attempt.Query, language: attempt.Language); - - if (searchResults.Results.Count == 0) + var searchResults = await tmdb.SearchMovieAsync(attempt.Query, attempt.Language, attempt.Year); + if (searchResults.Count == 0) { continue; } if (!config.ManualMode) { - return (FindResult.Success, searchResults.Results[0]); + return (FindResult.Success, searchResults[0]); } - foreach (var result in searchResults.Results.Where(result => seenResultIds.Add(result.Id))) + foreach (var result in searchResults.Where(result => seenResultIds.Add(result.Id))) { manualResults.Add(result); } @@ -100,9 +95,13 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) return (FindResult.Skip, null); } - private async Task<MovieFileMetadata> GetMovieMetadataAsync(SearchMovie selectedResult, bool fileIsTaggable) + private async Task<MovieFileMetadata> GetMovieMetadataAsync(TMDBMovie selectedResult, bool fileIsTaggable) { - TMDbMovie movie = await tmdb.GetMovieAsync(selectedResult.Id); + // 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, @@ -114,7 +113,7 @@ private async Task<MovieFileMetadata> GetMovieMetadataAsync(SearchMovie selected Date = movie.ReleaseDate }; - result.Genres = movie.Genres.Select(g => g.Name).ToList(); + result.Genres = movie.Genres; if (config.ExtendedTagging && fileIsTaggable) { @@ -152,17 +151,16 @@ private IEnumerable<MovieSearchAttempt> GetSearchAttempts(string title, int? yea private IEnumerable<string> GetSearchLanguages() { - var languages = new List<string>(); - - if (!string.IsNullOrWhiteSpace(config.Language)) - { - languages.Add(config.Language); - } - - languages.AddRange(config.SearchLanguages.Where(language => !string.IsNullOrWhiteSpace(language))); + IEnumerable<string> languages = [config.Language, ..config.SearchLanguages]; return languages.Distinct(StringComparer.OrdinalIgnoreCase); } - private readonly record struct MovieSearchAttempt(string Query, int? Year, string Language); + 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}]" : "")} + """; + } } diff --git a/AutoTag.Core/TMDB/TMDBMovie.cs b/AutoTag.Core/TMDB/TMDBMovie.cs new file mode 100644 index 0000000..9272ff0 --- /dev/null +++ b/AutoTag.Core/TMDB/TMDBMovie.cs @@ -0,0 +1,44 @@ +using TMDbLib.Objects.Search; + +namespace AutoTag.Core.TMDB; + +public class TMDBMovie +{ + public int Id { get; set; } + + public required string Title { get; set; } + + public required string Overview { get; set; } + + public string? PosterPath { get; set; } + + public DateTime? ReleaseDate { get; set; } + + public required List<string> Genres { get; set; } + + public required string Language { get; set; } + + private TMDBMovie() {} + + 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 c891518..41e2f7e 100644 --- a/AutoTag.Core/TMDB/TMDBService.cs +++ b/AutoTag.Core/TMDB/TMDBService.cs @@ -4,7 +4,6 @@ using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; using Credits = TMDbLib.Objects.Movies.Credits; -using TMDbMovie = TMDbLib.Objects.Movies.Movie; namespace AutoTag.Core.TMDB; @@ -24,11 +23,9 @@ public interface ITMDBService Task<ImagesWithId> GetTvShowImagesAsync(int id); - Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0, string? language = null); + Task<List<TMDBMovie>> SearchMovieAsync(string query, string language, int? year); - Task<TMDbMovie> GetMovieAsync(int movieId); - - Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreIds); + Task<TMDBMovie> GetMovieAsync(int movieId); Task<Credits> GetMovieCreditsAsync(int movieId); } @@ -67,22 +64,31 @@ public Task<CreditsWithGuestStars> GetTvEpisodeCreditsAsync(int tvShowId, int se public Task<ImagesWithId> GetTvShowImagesAsync(int id) => client.GetTvShowImagesAsync(id, $"{config.Language},null"); - public Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int year = 0, string? language = null) - => client.SearchMovieAsync(query, language ?? config.Language, includeAdult: config.IncludeAdult, year: year); + public async Task<List<TMDBMovie>> SearchMovieAsync(string query, string language, int? year) + { + var results = await client.SearchMovieAsync(query, language, includeAdult: config.IncludeAdult, year: year ?? 0); + + if (results.Results.Count == 0) + { + return []; + } - public Task<TMDbMovie> GetMovieAsync(int movieId) - => client.GetMovieAsync(movieId, language: config.Language); + 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, language: config.Language), config.Language); - public async Task<List<string>> GetMovieGenreNamesAsync(IEnumerable<int> genreIds) + private async Task GetMovieGenreNamesAsync() { if (MovieGenres.Count == 0) { MovieGenres = (await client.GetMovieGenresAsync(config.Language)) .ToDictionary(g => g.Id, g => g.Name); } - - return genreIds.Select(g => MovieGenres[g]).ToList(); } public Task<Credits> GetMovieCreditsAsync(int movieId) From 1579d83c5dfcc45ff40ad1bb0e03f707dfa5e1ba Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Mon, 18 May 2026 22:02:02 +0100 Subject: [PATCH 22/35] feat: add absolute path rename pattern support refactor: parse TV and movie details during file discovery to allow grouping multiple files refactor: simplify TV filename parsing and support year, end episode and part feat: add new filename field specifier format with fallback support --- AutoTag.CLI/CLIInterface.cs | 215 +++++------- .../Settings/RootCommandSettings.Rename.cs | 1 + .../Settings/RootCommandSettings.Tagging.cs | 24 +- .../Files/FileFinder/FindFilesToProcess.cs | 155 ++++----- .../Files/FileNamer/GetNewFileName.cs | 250 ++++++++++++++ .../Files/FileWriter/WriteAsync.cs | 234 +++++-------- .../Files/TVFileNameParser/TryParse.cs | 106 ++++++ .../Helpers/MockFileSystemBuilder.cs | 73 ++++ .../TV/TVProcessor/FindEpisodeAsync.cs | 168 ++++----- .../TV/TVProcessor/FindEpisodeGroupAsync.cs | 64 ++-- .../TV/TVProcessor/FindPosterAsync.cs | 14 +- .../TV/TVProcessor/FindShowAsync.cs | 111 +++--- .../TV/TVProcessor/ParseFileName.cs | 130 ------- .../TV/TVProcessor/ProcessAsync.cs | 70 ++-- AutoTag.Core.Test/packages.lock.json | 290 ---------------- AutoTag.Core/AutoTag.Core.csproj | 24 +- AutoTag.Core/Config/AutoTagConfig.cs | 53 +-- AutoTag.Core/Config/Mode.cs | 3 +- AutoTag.Core/Extensions.cs | 12 + AutoTag.Core/FileMetadata.cs | 44 +-- AutoTag.Core/FileNameReplace.cs | 39 --- AutoTag.Core/Files/FileFinder.cs | 275 ++++----------- AutoTag.Core/Files/FileNameField.cs | 46 +++ AutoTag.Core/Files/FileNameReplace.cs | 12 + AutoTag.Core/Files/FileNamer.cs | 80 +++++ AutoTag.Core/Files/FileSystem.cs | 28 +- AutoTag.Core/Files/FileWriter.cs | 249 +++++-------- AutoTag.Core/Files/Parsing/FileNameParser.cs | 28 ++ .../Parsing/MovieFileNameParser.cs} | 267 ++++++-------- .../Files/Parsing/TVFileNameParser.cs | 89 +++++ AutoTag.Core/Files/TaggingFile.cs | 15 +- AutoTag.Core/GlobalUsing.cs | 1 + AutoTag.Core/Movie/MovieFileMetadata.cs | 44 +-- AutoTag.Core/Movie/MovieProcessor.cs | 61 ++-- AutoTag.Core/TMDB/TMDBService.cs | 26 +- AutoTag.Core/TV/EpisodeNumberMapping.cs | 23 ++ AutoTag.Core/TV/EpisodeParser.cs | 136 -------- AutoTag.Core/TV/TVCache.cs | 34 +- AutoTag.Core/TV/TVFileMetadata.cs | 102 +++--- AutoTag.Core/TV/TVProcessor.cs | 309 ++++++++--------- README.md | 326 ++++++++++++------ 41 files changed, 2033 insertions(+), 2198 deletions(-) create mode 100644 AutoTag.Core.Test/Files/FileNamer/GetNewFileName.cs create mode 100644 AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs create mode 100644 AutoTag.Core.Test/Helpers/MockFileSystemBuilder.cs delete mode 100644 AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs delete mode 100644 AutoTag.Core.Test/packages.lock.json delete mode 100644 AutoTag.Core/FileNameReplace.cs create mode 100644 AutoTag.Core/Files/FileNameField.cs create mode 100644 AutoTag.Core/Files/FileNameReplace.cs create mode 100644 AutoTag.Core/Files/FileNamer.cs create mode 100644 AutoTag.Core/Files/Parsing/FileNameParser.cs rename AutoTag.Core/{Movie/MovieNameNormalizer.cs => Files/Parsing/MovieFileNameParser.cs} (52%) create mode 100644 AutoTag.Core/Files/Parsing/TVFileNameParser.cs create mode 100644 AutoTag.Core/GlobalUsing.cs create mode 100644 AutoTag.Core/TV/EpisodeNumberMapping.cs delete mode 100644 AutoTag.Core/TV/EpisodeParser.cs diff --git a/AutoTag.CLI/CLIInterface.cs b/AutoTag.CLI/CLIInterface.cs index 6297b4d..4be4d3b 100644 --- a/AutoTag.CLI/CLIInterface.cs +++ b/AutoTag.CLI/CLIInterface.cs @@ -1,102 +1,19 @@ using System.Reflection; using AutoTag.Core.Config; using AutoTag.Core.Files; -using AutoTag.Core.Movie; using Microsoft.Extensions.DependencyInjection; namespace AutoTag.CLI; public class CLIInterface(IServiceProvider serviceProvider) : IUserInterface { - private List<TaggingFile> Files = null!; + private AutoTagConfig Config = null!; private TaggingFile CurrentFile = null!; + private List<TaggingFile> Files = null!; private bool Success = true; private int Warnings; - private AutoTagConfig Config = null!; - - public async Task<int> RunAsync(IEnumerable<FileSystemInfo> entries) - { - Config = serviceProvider.GetRequiredService<AutoTagConfig>(); - var movieProcessor = serviceProvider.GetRequiredKeyedService<IProcessor>(Mode.Movie); - var tvProcessor = serviceProvider.GetRequiredKeyedService<IProcessor>(Mode.TV); - var fileFinder = serviceProvider.GetRequiredService<IFileFinder>(); - - 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 ProcessWithFallbackAsync(file, movieProcessor, tvProcessor)).IsSuccess(); - } - - 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) @@ -114,7 +31,7 @@ public void DisplayMessage(string message, MessageType type) colour = Color.Yellow; } - AnsiConsole.Write(new Text($"{message}\n", new Style(foreground: colour))); + AnsiConsole.Write(new Text($"{message}\n", new Style(colour))); } public void SetStatus(string status, MessageType type) @@ -168,76 +85,124 @@ 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 static string GetVersion() => Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(3)!; - public void SetFilePath(string path) { } - private async Task<ProcessResult> ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor, IProcessor tvProcessor) + public async Task<int> RunAsync(IEnumerable<FileSystemInfo> entries) { - var (primaryMode, secondaryMode) = GetProcessorOrder(file.Path); + Config = serviceProvider.GetRequiredService<AutoTagConfig>(); + var movieProcessor = serviceProvider.GetRequiredKeyedService<IProcessor>(Mode.Movie); + var tvProcessor = serviceProvider.GetRequiredKeyedService<IProcessor>(Mode.TV); + var fileFinder = serviceProvider.GetRequiredService<IFileFinder>(); - bool successBeforeAttempt = Success; - var primaryResult = await GetProcessor(primaryMode, movieProcessor, tvProcessor).ProcessAsync(file); - if (primaryResult.IsSuccess()) - { - return primaryResult; - } + AnsiConsole.WriteLine($"AutoTag v{GetVersion()}"); + AnsiConsole.MarkupLine("[link]https://jtattersall.net[/]"); + + Files = fileFinder.FindFilesToProcess(entries); - if (!ShouldTryAlternateProcessor(file.Path, primaryResult, secondaryMode)) + if (Files.Count == 0) { - return ProcessResult.Fail; + DisplayMessage("No files found", MessageType.Error); + return 1; } - DisplayMessage($"Retrying as {(secondaryMode == Mode.Movie ? "movie" : "TV")}", MessageType.Warning); + foreach (var file in Files) + { + CurrentFile = file; + AnsiConsole.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]"); - Success = successBeforeAttempt; - file.Success = true; - file.Status = ""; + Success &= (await ProcessWithFallbackAsync(file, movieProcessor, tvProcessor)).IsSuccess(); + } - return await GetProcessor(secondaryMode, movieProcessor, tvProcessor).ProcessAsync(file); + return ReportResults(Files.Count); } - private (Mode Primary, Mode Secondary) GetProcessorOrder(string path) + private int ReportResults(int fileCount) { - var fileName = Path.GetFileName(path); - if (MovieNameNormalizer.LooksLikeTvEpisode(fileName)) + 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; + } + + var 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 { - return (Mode.TV, Mode.Movie); + AnsiConsole.MarkupLine("[maroon]\n\nErrors encountered for all files:[/]"); } - if (MovieNameNormalizer.LooksLikeMovieCandidate(fileName)) + foreach (var file in Files.Where(f => !f.Success)) { - return (Mode.Movie, Mode.TV); + AnsiConsole.MarkupLineInterpolated($"[magenta]{file.Path}:[/]"); + AnsiConsole.MarkupLineInterpolated($"[red] {file.Status}\n[/]"); } - return Config.Mode == Mode.Movie - ? (Mode.Movie, Mode.TV) - : (Mode.TV, Mode.Movie); + return 1; } - private static IProcessor GetProcessor(Mode mode, IProcessor movieProcessor, IProcessor tvProcessor) - => mode == Mode.Movie - ? movieProcessor - : tvProcessor; + public static string GetVersion() + { + return Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(3)!; + } - private static bool ShouldTryAlternateProcessor(string path, ProcessResult result, Mode secondaryMode) + private async Task<ProcessResult> ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor, + IProcessor tvProcessor) { - if (result is ProcessResult.ParseFailure or ProcessResult.NotFound) + 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) { - var fileName = Path.GetFileName(path); - return secondaryMode == Mode.TV - ? MovieNameNormalizer.LooksLikeTvEpisode(fileName) - : MovieNameNormalizer.LooksLikeMovieCandidate(fileName); + return await movieProcessor.ProcessAsync(file); } - return false; + return ProcessResult.Fail; } -} +} \ 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 1ee2890..4b3cff5 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; } @@ -35,8 +39,8 @@ public partial class RootCommandSettings [CommandOption("-l|--language <language>")] [Description("Metadata language (default: en)")] public string? Language { get; init; } - - [CommandOption("-sl|--search-language <language>")] + + [CommandOption("--search-language <language>")] [Description("Additional languages to use when searching TMDB")] public string[] SearchLanguages { get; init; } @@ -48,16 +52,17 @@ public partial class RootCommandSettings [Description("Include adult titles in TMDB searches")] public bool? IncludeAdult { get; init; } - [CommandOption("--organize-folders")] - [Description("Move files into media folders after tagging")] - public bool? OrganizeFolders { 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; @@ -113,14 +118,9 @@ private void SetTaggingOptions(AutoTagConfig config) config.IncludeAdult = IncludeAdult.Value; } - if (OrganizeFolders.HasValue) - { - config.OrganizeFolders = OrganizeFolders.Value; - } - if (RemoveEmptyFolders.HasValue) { config.RemoveEmptyFolders = RemoveEmptyFolders.Value; } } -} +} \ No newline at end of file diff --git a/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs index 90d27af..7c50985 100644 --- a/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs +++ b/AutoTag.Core.Test/Files/FileFinder/FindFilesToProcess.cs @@ -1,5 +1,6 @@ using AutoTag.Core.Config; -using AutoTag.Core.Files; +using AutoTag.Core.Files.Parsing; +using AutoTag.Core.Test.Helpers; namespace AutoTag.Core.Test.Files.FileFinder; @@ -8,99 +9,69 @@ public class FindFilesToProcess [Fact] public void Should_FindCommonVideoContainers_AndOnlyTagKnownSafeFormats() { - var tempDirectory = Directory.CreateTempSubdirectory(); - - try - { - File.WriteAllText(Path.Combine(tempDirectory.FullName, "episode.AVI"), string.Empty); - File.WriteAllText(Path.Combine(tempDirectory.FullName, "movie.mkv"), string.Empty); - File.WriteAllText(Path.Combine(tempDirectory.FullName, "clip.mov"), string.Empty); - File.WriteAllText(Path.Combine(tempDirectory.FullName, "notes.txt"), string.Empty); - var nestedDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "Nested")); - File.WriteAllText(Path.Combine(nestedDirectory.FullName, "nested.mp4"), string.Empty); - - var finder = new AutoTag.Core.Files.FileFinder( - new AutoTagConfig { RenameSubtitles = false }, - new AutoTag.Core.Files.FileSystem(), - new Mock<IUserInterface>().Object - ); - - var result = finder.FindFilesToProcess([tempDirectory]); - - 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.RootPath == tempDirectory.FullName); - result.Should().NotContain(file => file.Path.EndsWith("notes.txt")); - result.Should().OnlyContain(file => file.RootPath == tempDirectory.FullName); - } - finally - { - tempDirectory.Delete(recursive: true); - } + 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<IUserInterface>().Object, + new Mock<IFileNameParser>().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_Associate_Ass_Subtitle_With_Matching_Video_When_RenameSubtitles_Enabled() + public void Should_GroupVideoAndSubtitlesWithSameParsedDetails() { - var tempDirectory = Directory.CreateTempSubdirectory(); - - try - { - var videoPath = Path.Combine(tempDirectory.FullName, "show.mkv"); - var subtitlePath = Path.Combine(tempDirectory.FullName, "show.ass"); - File.WriteAllText(videoPath, string.Empty); - File.WriteAllText(subtitlePath, string.Empty); - - var finder = new AutoTag.Core.Files.FileFinder( - new AutoTagConfig { RenameSubtitles = true }, - new AutoTag.Core.Files.FileSystem(), - new Mock<IUserInterface>().Object - ); - - var result = finder.FindFilesToProcess([tempDirectory]); - - result.Should().ContainSingle(file => file.Path == videoPath && file.SubtitlePath == subtitlePath); - } - finally - { - tempDirectory.Delete(recursive: true); - } - } - - [Fact] - public void Should_Associate_Loose_Subtitles_With_Matching_TV_Episodes_When_RenameSubtitles_Enabled() - { - var tempDirectory = Directory.CreateTempSubdirectory(); - - try - { - var seriesDirectory = Directory.CreateDirectory(Path.Combine(tempDirectory.FullName, "Koakuma Kanojo the Animation")); - var seasonDirectory = Directory.CreateDirectory(Path.Combine(seriesDirectory.FullName, "Season 01")); - var subtitleDirectory = Directory.CreateDirectory(Path.Combine(seriesDirectory.FullName, "Eng")); - - var videoPath = Path.Combine(seasonDirectory.FullName, "Koakuma Kanojo the Animation - 1x01 - So Sticky and Covered in Juice.mkv"); - var subtitlePath1 = Path.Combine(subtitleDirectory.FullName, "[Shinkiro-raw] Koakuma Kanojo The Animation - 01 [7AD743D9].eng [EROBEAT_LQ].ass"); - var subtitlePath2 = Path.Combine(subtitleDirectory.FullName, "[Shinkiro-raw] Koakuma Kanojo The Animation - 01 [7AD743D9].eng [SubDESU-H].ass"); - File.WriteAllText(videoPath, string.Empty); - File.WriteAllText(subtitlePath1, string.Empty); - File.WriteAllText(subtitlePath2, string.Empty); - - var finder = new AutoTag.Core.Files.FileFinder( - new AutoTagConfig { RenameSubtitles = true }, - new AutoTag.Core.Files.FileSystem(), - new Mock<IUserInterface>().Object - ); - - var result = finder.FindFilesToProcess([tempDirectory]); - - var video = result.Should().ContainSingle(file => file.Path == videoPath).Subject; - video.SubtitlePaths.Should().BeEquivalentTo([subtitlePath1, subtitlePath2]); - result.Should().NotContain(file => file.Path == subtitlePath1 || file.Path == subtitlePath2); - } - finally - { - tempDirectory.Delete(recursive: true); - } + 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<IFileNameParser>(); + mockParser.Setup(m => m.ParseFileName(It.Is<string>(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<string>(s => s.Contains("Title.")))) + .Returns((null, new ParsedMovieFileName("Title", null))); + + mockParser.Setup(m => m.ParseFileName(It.Is<string>(s => s.Contains("Unknown")))) + .Returns((null, null)); + + var finder = new Core.Files.FileFinder( + new AutoTagConfig { RenameSubtitles = true }, + fs, + new Mock<IUserInterface>().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 index f2f9ce1..8b3b5da 100644 --- a/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs +++ b/AutoTag.Core.Test/Files/FileWriter/WriteAsync.cs @@ -1,14 +1,28 @@ 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_Skip_Rename_When_Video_And_Subtitle_Are_Already_Correctly_Named() + public async Task Should_SkipRename_WhenVideoAndSubtitleAreAlreadyCorrectlyNamed() { var config = new AutoTagConfig { @@ -17,14 +31,16 @@ public async Task Should_Skip_Rename_When_Video_And_Subtitle_Are_Already_Correct }; var mockFs = new Mock<IFileSystem>(); + mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny<string>())) + .Returns(GetPath()); + var mockUi = new Mock<IUserInterface>(); - var mockCoverArtFetcher = new Mock<ICoverArtFetcher>(); - var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); + var writer = GetInstance(config: config, fs: mockFs.Object, ui: mockUi.Object); var taggingFile = new TaggingFile { - Path = @"C:\Media\Movie (2020).mkv", - SubtitlePath = @"C:\Media\Movie (2020).srt" + Path = GetPath("Movie (2020).mkv"), + SubtitlePaths = [GetPath("Movie (2020).srt")] }; var metadata = new MovieFileMetadata @@ -37,11 +53,12 @@ public async Task Should_Skip_Rename_When_Video_And_Subtitle_Are_Already_Correct result.Should().BeTrue(); mockFs.Verify(fs => fs.Move(It.IsAny<string>(), It.IsAny<string>()), Times.Never); - mockUi.Verify(ui => ui.SetStatus("Rename skipped - already named correctly", MessageType.Information), Times.Once); + mockUi.Verify(ui => ui.SetStatus("Rename skipped - already named correctly", MessageType.Information), + Times.Once); } [Fact] - public async Task Should_Not_Skip_When_Subtitle_Name_Is_Still_Wrong() + public async Task Should_NotSkip_WhenSubtitleNameIsWrong() { var config = new AutoTagConfig { @@ -50,17 +67,22 @@ public async Task Should_Not_Skip_When_Subtitle_Name_Is_Still_Wrong() }; var mockFs = new Mock<IFileSystem>(); - mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020).srt")) + + 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<string>())) + .Returns(GetPath()); var mockUi = new Mock<IUserInterface>(); - var mockCoverArtFetcher = new Mock<ICoverArtFetcher>(); - var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); + var writer = GetInstance(config: config, fs: mockFs.Object, ui: mockUi.Object); var taggingFile = new TaggingFile { - Path = @"C:\Media\Movie (2020).mkv", - SubtitlePath = @"C:\Media\subtitle.srt" + Path = GetPath("Movie (2020).mkv"), + SubtitlePaths = [GetPath(inPath)] }; var metadata = new MovieFileMetadata @@ -72,142 +94,65 @@ public async Task Should_Not_Skip_When_Subtitle_Name_Is_Still_Wrong() var result = await writer.WriteAsync(taggingFile, metadata); result.Should().BeTrue(); - mockFs.Verify(fs => fs.Move(@"C:\Media\subtitle.srt", @"C:\Media\Movie (2020).srt"), Times.Once); - mockUi.Verify(ui => ui.SetStatus("File skipped - already named correctly", MessageType.Information), Times.Never); + 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_Tag_File_When_Rename_Is_Skipped_Because_Name_Is_Already_Correct() + public async Task Should_TagFile_WhenRenameIsSkipped() { var config = new AutoTagConfig { - RenameFiles = true, + RenameFiles = false, TagFiles = true }; var mockUi = new Mock<IUserInterface>(); - var writer = new Core.Files.FileWriter( - new Mock<ICoverArtFetcher>().Object, - config, - new Mock<IFileSystem>().Object, - mockUi.Object - ); + 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 = @"C:\Media\Movie (2020).mkv" }, metadata); + 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<Exception>()), Times.Once); - } - - [Fact] - public async Task Should_Move_Movie_Into_Named_Folder_When_OrganizeFolders_Enabled() - { - var config = new AutoTagConfig - { - OrganizeFolders = true, - RenameFiles = true, - TagFiles = false - }; - - var mockFs = new Mock<IFileSystem>(); - mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020)\Movie (2020).mkv")) - .Returns(false); - - var mockUi = new Mock<IUserInterface>(); - var mockCoverArtFetcher = new Mock<ICoverArtFetcher>(); - - var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); - var taggingFile = new TaggingFile - { - Path = @"C:\Media\Downloads\raw.mkv", - RootPath = @"C:\Media" - }; - - 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.CreateDirectory(It.Is<DirectoryInfo>(d => d.FullName == @"C:\Media\Movie (2020)")), Times.Once); - mockFs.Verify(fs => fs.Move(@"C:\Media\Downloads\raw.mkv", @"C:\Media\Movie (2020)\Movie (2020).mkv"), Times.Once); + mockUi.Verify( + ui => ui.SetStatus("Error: Failed to write tags to file", MessageType.Error, It.IsAny<Exception>()), + Times.Once); } [Fact] - public async Task Should_Move_TV_Special_Into_Specials_Folder_When_OrganizeFolders_Enabled() + public async Task Should_RemoveSourceFolder_WhenSourceFolderIsEmptyAndAbsolutePatternUsed() { var config = new AutoTagConfig { - OrganizeFolders = true, - RenameFiles = true, - TagFiles = false - }; - - var mockFs = new Mock<IFileSystem>(); - mockFs.Setup(fs => fs.Exists(@"C:\Media\Series\Specials\Series - 0x01 - Pilot.mkv")) - .Returns(false); - - var mockUi = new Mock<IUserInterface>(); - var mockCoverArtFetcher = new Mock<ICoverArtFetcher>(); - - var writer = new Core.Files.FileWriter(mockCoverArtFetcher.Object, config, mockFs.Object, mockUi.Object); - var taggingFile = new TaggingFile - { - Path = @"C:\Media\Downloads\raw.mkv", - RootPath = @"C:\Media" - }; - - var metadata = new TVFileMetadata - { - SeriesName = "Series", - Season = 0, - Episode = 1, - Title = "Pilot" - }; - - var result = await writer.WriteAsync(taggingFile, metadata); - - result.Should().BeTrue(); - mockFs.Verify(fs => fs.CreateDirectory(It.Is<DirectoryInfo>(d => d.FullName == @"C:\Media\Series\Specials")), Times.Once); - mockFs.Verify(fs => fs.Move(@"C:\Media\Downloads\raw.mkv", @"C:\Media\Series\Specials\Series - 0x01 - Pilot.mkv"), Times.Once); - } - - [Fact] - public async Task Should_Remove_Source_Folder_When_Organized_And_Source_Folder_Is_Empty() - { - var config = new AutoTagConfig - { - OrganizeFolders = true, + MovieRenamePattern = GetPath("Movies", "{Title} ({Year})"), RemoveEmptyFolders = true, RenameFiles = true, TagFiles = false }; var mockFs = new Mock<IFileSystem>(); - mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020)\Movie (2020).mkv")) + mockFs.Setup(fs => fs.Exists(GetPath("Movies", "Movie (2020).mkv"))) .Returns(false); - mockFs.Setup(fs => fs.DirectoryExists(@"C:\Media\Downloads")) + mockFs.Setup(fs => fs.DirectoryExists(GetPath("Downloads"))) .Returns(true); - mockFs.Setup(fs => fs.DirectoryIsEmpty(@"C:\Media\Downloads")) + 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<string>())).Returns(true); - var writer = new Core.Files.FileWriter( - new Mock<ICoverArtFetcher>().Object, - config, - mockFs.Object, - new Mock<IUserInterface>().Object - ); + var downloads = GetPath("Downloads"); + + var writer = GetInstance(config: config, fs: mockFs.Object); var taggingFile = new TaggingFile { - Path = @"C:\Media\Downloads\raw.mkv", - RootPath = @"C:\Media" + Path = GetPath("Downloads", "raw.mkv") }; var metadata = new MovieFileMetadata { @@ -218,38 +163,35 @@ public async Task Should_Remove_Source_Folder_When_Organized_And_Source_Folder_I var result = await writer.WriteAsync(taggingFile, metadata); result.Should().BeTrue(); - mockFs.Verify(fs => fs.DeleteDirectory(@"C:\Media\Downloads"), Times.Once); + mockFs.Verify(fs => fs.DeleteDirectory(downloads), Times.Once); } [Fact] - public async Task Should_Not_Remove_Source_Folder_When_It_Is_Not_Empty() + public async Task Should_NotRemoveSourceFolder_WhenNotEmpty() { var config = new AutoTagConfig { - OrganizeFolders = true, + MovieRenamePattern = GetPath("Movies", "{Title} ({Year})"), RemoveEmptyFolders = true, RenameFiles = true, TagFiles = false }; var mockFs = new Mock<IFileSystem>(); - mockFs.Setup(fs => fs.Exists(@"C:\Media\Movie (2020)\Movie (2020).mkv")) + mockFs.Setup(fs => fs.Exists(GetPath("Movies", "Movie (2020).mkv"))) .Returns(false); - mockFs.Setup(fs => fs.DirectoryExists(@"C:\Media\Downloads")) + mockFs.Setup(fs => fs.DirectoryExists("Downloads")) .Returns(true); - mockFs.Setup(fs => fs.DirectoryIsEmpty(@"C:\Media\Downloads")) + mockFs.Setup(fs => fs.DirectoryIsEmpty("Downloads")) .Returns(false); + mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny<string>())) + .Returns(GetPath()); + mockFs.Setup(fs => fs.PathContainsDirectory(It.IsAny<string>())).Returns(true); - var writer = new Core.Files.FileWriter( - new Mock<ICoverArtFetcher>().Object, - config, - mockFs.Object, - new Mock<IUserInterface>().Object - ); + var writer = GetInstance(config: config, fs: mockFs.Object); var taggingFile = new TaggingFile { - Path = @"C:\Media\Downloads\raw.mkv", - RootPath = @"C:\Media" + Path = GetPath("Downloads", "raw.mkv") }; var metadata = new MovieFileMetadata { @@ -264,31 +206,26 @@ public async Task Should_Not_Remove_Source_Folder_When_It_Is_Not_Empty() } [Fact] - public async Task Should_Rename_Multiple_Subtitles_With_Numbered_Suffixes() + public async Task Should_RenameMultipleSubtitlesWithNumberedSuffixes() { var config = new AutoTagConfig { - OrganizeFolders = true, RenameFiles = true, TagFiles = false }; var mockFs = new Mock<IFileSystem>(); - var writer = new Core.Files.FileWriter( - new Mock<ICoverArtFetcher>().Object, - config, - mockFs.Object, - new Mock<IUserInterface>().Object - ); + mockFs.Setup(fs => fs.GetDirectoryPath(It.IsAny<string>())) + .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 = @"C:\Media\Downloads\raw.mkv", - RootPath = @"C:\Media", - SubtitlePaths = - [ - @"C:\Media\Downloads\sub-one.ass", - @"C:\Media\Downloads\sub-two.ass" - ] + Path = GetPath("raw.mkv"), + SubtitlePaths = [sub1Path, sub2Path] }; var metadata = new TVFileMetadata { @@ -300,14 +237,17 @@ public async Task Should_Rename_Multiple_Subtitles_With_Numbered_Suffixes() 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( - @"C:\Media\Downloads\sub-one.ass", - @"C:\Media\Series\Season 01\Series - 1x01 - Pilot.1.ass" + sub1Path, + sub1OutPath ), Times.Once); mockFs.Verify(fs => fs.Move( - @"C:\Media\Downloads\sub-two.ass", - @"C:\Media\Series\Season 01\Series - 1x01 - Pilot.2.ass" + 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..25d5e86 --- /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 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<string>(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<string>(StringComparer.OrdinalIgnoreCase) + ); + } + + [Fact] + public void Should_ParseFromFullPath_When_ParsePatternProvided() + { + var config = new AutoTagConfig + { + ParsePattern = @".*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\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 = @".*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\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/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<MockFileSystemBuilder> _directories = []; + private readonly List<string> _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<MockFileSystemBuilder> build) + { + var directory = new MockFileSystemBuilder(IOPath.Combine(Path, name)); + build(directory); + + _directories.Add(directory); + + return this; + } + + private void SetupMock(Mock<IFileSystem> mock) + { + mock.Setup(fs => fs.Exists(It.Is<DirectoryInfo>(f => f.FullName == Path))) + .Returns(true); + + foreach (var file in _files) + { + mock.Setup(fs => fs.Exists(It.Is<FileInfo>(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<DirectoryInfo>(d => d.FullName == Path))) + .Returns(entries); + + + foreach (var directory in _directories) + { + directory.SetupMock(mock); + } + } + + public (IFileSystem FileSystem, FileSystemInfo Root) Build() + { + var mock = new Mock<IFileSystem>(); + + 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 bab9247..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<ITVCache>(); 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<ITVCache>(); 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<int>(), It.IsAny<int>(), 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,27 +201,25 @@ public async Task Should_AddSeasonToCache_When_Found() var mockTmdb = new Mock<ITMDBService>(); var seasonResult = new TvSeason { - Episodes = [new TvSeasonEpisode - { - EpisodeNumber = 1 - }] + Episodes = + [ + new TvSeasonEpisode + { + EpisodeNumber = 1 + } + ] }; mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(It.IsAny<int>(), It.IsAny<int>())) .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)); @@ -253,6 +247,7 @@ public async Task Should_MapAbsoluteEpisodeToSeasonAndEpisode() mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(1, 1)) .ReturnsAsync(new TvSeason { + SeasonNumber = 1, Episodes = [ new TvSeasonEpisode { EpisodeNumber = 1 }, @@ -263,6 +258,7 @@ public async Task Should_MapAbsoluteEpisodeToSeasonAndEpisode() mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(1, 2)) .ReturnsAsync(new TvSeason { + SeasonNumber = 2, Episodes = [ new TvSeasonEpisode @@ -276,30 +272,25 @@ public async Task Should_MapAbsoluteEpisodeToSeasonAndEpisode() }); mockTmdb.Setup(tmdb => tmdb.GetTvGenreNamesAsync(It.IsAny<IEnumerable<int>>())) .ReturnsAsync([]); + mockTmdb.Setup(tmdb => tmdb.GetTvShowAsync(It.IsAny<int>())) + .ReturnsAsync(new TvShow { Id = 1, NumberOfSeasons = 4 }); - var metadata = new TVFileMetadata - { - SeriesName = "Series", - Season = 0, - Episode = 4, - AbsoluteEpisode = 4 - }; var show = new ShowResults(new SearchTv { Id = 1, Name = "Series" }); - 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, 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"); - mockTmdb.Verify(tmdb => tmdb.GetTvSeasonAsync(1, 1), Times.Once); - mockTmdb.Verify(tmdb => tmdb.GetTvSeasonAsync(1, 2), Times.Once); } [Fact] @@ -312,21 +303,18 @@ public async Task Should_ReturnSkipWithErrorMessage_When_SeasonNotFound() var mockTmdb = new Mock<ITMDBService>(); mockTmdb.Setup(tmdb => tmdb.GetTvSeasonAsync(It.IsAny<int>(), It.IsAny<int>())) - .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] @@ -335,29 +323,29 @@ public async Task Should_ReturnSkipWithErrorMessage_When_EpisodeNotFound() var mockCache = new Mock<ITVCache>(); var cachedSeason = new TvSeason { - Episodes = [new TvSeasonEpisode - { - EpisodeNumber = 20 - }] + Episodes = + [ + new TvSeasonEpisode + { + EpisodeNumber = 20 + } + ] }; mockCache.Setup(c => c.TryGetSeason(It.IsAny<int>(), It.IsAny<int>(), 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() { @@ -376,7 +364,7 @@ public async Task Should_UseEpisodeGroupMapping_WhenAvailable() }; mockCache.Setup(c => c.TryGetSeason(It.IsAny<int>(), It.IsAny<int>(), out season)) .Returns(true); - + var show = new ShowResults(new SearchTv()); show.AddEpisodeGroup( new TvGroupCollection @@ -401,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"); @@ -421,7 +405,7 @@ out _ public async Task Should_ReportError_When_EpisodeNotFoundInMapping() { var mockUi = new Mock<IUserInterface>(); - + var show = new ShowResults(new SearchTv()); show.AddEpisodeGroup( new TvGroupCollection @@ -445,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..8acc2c9 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<ITMDBService> mock ] } }); - + [Fact] public async Task Should_AddSkipToNextSearchResultOption_When_MultipleSearchResults() { @@ -36,16 +36,22 @@ public async Task Should_AddSkipToNextSearchResultOption_When_MultipleSearchResu List<List<string>> passedOptions = []; var mockUi = new Mock<IUserInterface>(); mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) - .Returns((string msg, List<string> options) => msg.Contains("Show 1") ? options.Count - 1 : 0) // skip to next search result for first result, first option otherwise + .Returns((string msg, List<string> options) => + msg.Contains("Show 1") + ? options.Count - 1 + : 0) // skip to next search result for first result, first option otherwise .Callback((string _, List<string> 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<IUserInterface>(); mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .Returns((string msg, List<string> 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" }; @@ -100,15 +106,15 @@ public async Task Should_ReportError_When_GetTvEpisodeGroupsAsyncReturnsNull() var mockUi = new Mock<IUserInterface>(); mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .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<string>(), 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<string>())) .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<IUserInterface>(); mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .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<string>(), MessageType.Error)); result.Should().Be(FindResult.Fail); } @@ -168,8 +177,8 @@ public async Task Should_ReturnFindResultSkip_When_NoOptionSelected() var mockUi = new Mock<IUserInterface>(); mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .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<IUserInterface>(); - - 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<IUserInterface>(); - - 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<int>(), It.IsAny<int>(), 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<IUserInterface>(); - 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 c103379..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<ITVCache>(); - mockCache.Setup(c => c.ShowIsCached(It.IsAny<string>())).Returns(true); + List<ShowResults>? showResult = []; + mockCache.Setup(c => c.TryGetShow(It.IsAny<string>(), It.IsAny<int?>(), out showResult)) + .Returns(true); var mockTmdb = new Mock<ITMDBService>(); - 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<string>()), Times.Never); } - + [Fact] public async Task Should_ReportError_When_NoTMDBSearchResults() { @@ -37,9 +39,9 @@ public async Task Should_ReportError_When_NoTMDBSearchResults() var mockUi = new Mock<IUserInterface>(); - 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), @@ -48,12 +50,10 @@ public async Task Should_ReportError_When_NoTMDBSearchResults() } [Fact] - public async Task Should_RetryWithoutTrailingYear_When_InitialSearchHasNoResults() + public async Task Should_OrderResultsWithMatchingYearFirst_When_YearProvided() { var mockTmdb = new Mock<ITMDBService>(); - mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show (2011)")) - .ReturnsAsync(new SearchContainer<SearchTv> { Results = [] }); - mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show")) + mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>())) .ReturnsAsync(new SearchContainer<SearchTv> { Results = @@ -61,26 +61,40 @@ public async Task Should_RetryWithoutTrailingYear_When_InitialSearchHasNoResults new SearchTv { Id = 1, - Name = "The Looney Tunes Show" + 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 mockCache = new Mock<ITVCache>(); - var tv = GetInstance(tmdb: mockTmdb.Object, cache: mockCache.Object); + var mockUi = new Mock<IUserInterface>(); - var result = await tv.FindShowAsync("The Looney Tunes Show (2011)"); + var tv = GetInstance(mockTmdb.Object, ui: mockUi.Object); - result.Should().Be(FindResult.Success); - mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show (2011)"), Times.Once); - mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync("The Looney Tunes Show"), Times.Once); - mockCache.Verify(c => c.AddShow( - "The Looney Tunes Show (2011)", - It.Is<List<ShowResults>>(results => results.Count == 1 && results[0].TvSearchResult.Name == "The Looney Tunes Show")), - Times.Once - ); + 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() { @@ -122,19 +136,20 @@ public async Task Should_OnlyCacheSelectedResult_When_ManualModeEnabled() mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .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<List<string>>()), Times.Once); mockCache.Verify(c => c.AddShow( It.IsAny<string>(), + It.IsAny<int?>(), It.Is<List<ShowResults>>(s => s.Count == 1 && s[0].TvSearchResult.Id == selectedResult.Id)), Times.Once ); } - + [Fact] public async Task Should_SkipFile_When_SelectOptionReturnsNull() { @@ -142,22 +157,22 @@ public async Task Should_SkipFile_When_SelectOptionReturnsNull() { ManualMode = true }; - + var mockTmdb = new Mock<ITMDBService>(); mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>())) .ReturnsAsync(new SearchContainer<SearchTv> { Results = [new SearchTv { Name = "" }] }); - + var mockUi = new Mock<IUserInterface>(); mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .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); } @@ -168,14 +183,14 @@ public async Task Should_ReturnResultFromFindEpisodeGroupAsync_When_NotNull() { EpisodeGroup = true }; - + var mockTmdb = new Mock<ITMDBService>(); mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>())) .ReturnsAsync(new SearchContainer<SearchTv> { Results = [new SearchTv { Name = "" }] }); - + mockTmdb.Setup(tmdb => tmdb.GetTvShowWithEpisodeGroupsAsync(It.IsAny<int>())) .ReturnsAsync(new TvShow { @@ -185,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); } @@ -198,7 +213,7 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected() { EpisodeGroup = true }; - + var mockTmdb = new Mock<ITMDBService>(); var expectedShow = new SearchTv { Name = "Show 1" }; @@ -207,7 +222,7 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected() { Results = [expectedShow, new SearchTv { Name = "Show 2" }] }); - + mockTmdb.Setup(tmdb => tmdb.GetTvShowWithEpisodeGroupsAsync(It.IsAny<int>())) .ReturnsAsync(new TvShow { @@ -220,11 +235,14 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected() mockTmdb.Setup(tmdb => tmdb.GetTvEpisodeGroupsAsync(It.IsAny<string>())) .ReturnsAsync(new TvGroupCollection { - Groups = [new TvGroup - { - Name = "Season 1", - Episodes = [] - }] + Groups = + [ + new TvGroup + { + Name = "Season 1", + Episodes = [] + } + ] }); var mockUi = new Mock<IUserInterface>(); @@ -233,14 +251,15 @@ public async Task Should_OnlyCacheSelectedResult_When_EpisodeGroupSelected() var mockCache = new Mock<ITVCache>(); - 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<string>(), + It.IsAny<int?>(), It.Is<List<ShowResults>>(results => results.Count == 1 && results.Single().TvSearchResult == expectedShow) )); } -} +} \ No newline at end of file diff --git a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs b/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs deleted file mode 100644 index 4faf877..0000000 --- a/AutoTag.Core.Test/TV/TVProcessor/ParseFileName.cs +++ /dev/null @@ -1,130 +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 - [InlineData("Serial Experiments Lain E12 Landscape 1080p BluRay FLAC 2.0 x264-Chotab.mkv", "Serial Experiments Lain", 1, 12)] // episode-only numbering defaults to season 1 - 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<string>(StringComparer.OrdinalIgnoreCase) - ); - } - - [Fact] - public void Should_ParseAbsoluteEpisodeNamingFormat() - { - var tv = GetInstance(); - - var file = new TaggingFile - { - Path = "[Group] Series Dublado (35).AVI" - }; - - var result = tv.ParseFileName(file); - - result.Should().BeEquivalentTo( - new TVFileMetadata - { - SeriesName = "Series", - Season = 0, - Episode = 35, - AbsoluteEpisode = 35 - }, - o => o.Using<string>(StringComparer.OrdinalIgnoreCase) - ); - } - - [Fact] - public void Should_ParseFromFullPath_When_ParsePatternProvided() - { - var config = new AutoTagConfig - { - ParsePattern = @".*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\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("Continuum Episode 03.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<IUserInterface>(); - - 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<IUserInterface>(); - - var config = new AutoTagConfig - { - ParsePattern = @".*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\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<Exception>()), - Times.Once - ); - } -} diff --git a/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/ProcessAsync.cs index 03f5754..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; @@ -30,22 +31,23 @@ public async Task Should_ReturnNotFound_When_UnableToFindShow() mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>())) .ReturnsAsync(new SearchContainer<SearchTv> { 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().Be(ProcessResult.NotFound); mockTmdb.Verify(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>()), Times.Once); } - + [Fact] public async Task Should_ReturnSkippedAndShowWarning_When_FileSkipped() { var config = new AutoTagConfig { ManualMode = true }; - + var mockTmdb = new Mock<ITMDBService>(); mockTmdb.Setup(tmdb => tmdb.SearchTvShowAsync(It.IsAny<string>())) .ReturnsAsync(new SearchContainer<SearchTv> { Results = [new SearchTv { Name = "Show" }] }); @@ -54,24 +56,22 @@ public async Task Should_ReturnSkippedAndShowWarning_When_FileSkipped() mockUi.Setup(ui => ui.SelectOption(It.IsAny<string>(), It.IsAny<List<string>>())) .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().Be(ProcessResult.Skipped); mockUi.Verify(ui => ui.SetStatus("File skipped", MessageType.Warning)); } - + [Fact] public async Task Should_ReturnNotFound_When_FindEpisodeFails() { var mockCache = new Mock<ITVCache>(); - - mockCache.Setup(c => c.ShowIsCached(It.IsAny<string>())) - .Returns(true); var show = new ShowResults(new SearchTv { Name = "Show" }); show.AddEpisodeGroup( @@ -96,10 +96,10 @@ public async Task Should_ReturnNotFound_When_FindEpisodeFails() }, out _ ); - - mockCache.Setup(c => c.GetShow(It.IsAny<string>())) - .Returns([show]); - + List<ShowResults>? showResults = [show]; + mockCache.Setup(c => c.TryGetShow(It.IsAny<string>(), It.IsAny<int?>(), out showResults)) + .Returns(true); + TvSeason? season = null; mockCache.Setup(c => c.TryGetSeason(It.IsAny<int>(), It.IsAny<int>(), out season)) .Returns(false); @@ -110,7 +110,8 @@ 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().Be(ProcessResult.NotFound); @@ -120,13 +121,11 @@ out _ public async Task Should_ReturnNotFoundAndShowError_When_ReachedEndOfSearchResultsWithoutFindingEpisode() { var mockCache = new Mock<ITVCache>(); - - mockCache.Setup(c => c.ShowIsCached(It.IsAny<string>())) + + List<ShowResults>? showResults = [new ShowResults(new SearchTv { Name = "Show" })]; + mockCache.Setup(c => c.TryGetShow(It.IsAny<string>(), It.IsAny<int?>(), out showResults)) .Returns(true); - - mockCache.Setup(c => c.GetShow(It.IsAny<string>())) - .Returns([ new ShowResults(new SearchTv { Name = "Show" }) ]); - + TvSeason? season = null; mockCache.Setup(c => c.TryGetSeason(It.IsAny<int>(), It.IsAny<int>(), out season)) .Returns(false); @@ -137,7 +136,8 @@ public async Task Should_ReturnNotFoundAndShowError_When_ReachedEndOfSearchResul 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().Be(ProcessResult.NotFound); @@ -148,13 +148,11 @@ public async Task Should_ReturnNotFoundAndShowError_When_ReachedEndOfSearchResul public async Task Should_FindCoverArtFromSeason_When_NoCoverFromEpisode() { var mockCache = new Mock<ITVCache>(); - - mockCache.Setup(c => c.ShowIsCached(It.IsAny<string>())) + + List<ShowResults>? showResults = [new ShowResults(new SearchTv { Name = "Show" })]; + mockCache.Setup(c => c.TryGetShow(It.IsAny<string>(), It.IsAny<int?>(), out showResults)) .Returns(true); - - mockCache.Setup(c => c.GetShow(It.IsAny<string>())) - .Returns([ new ShowResults(new SearchTv { Name = "Show" }) ]); - + var season = new TvSeason { Episodes = [new TvSeasonEpisode { EpisodeNumber = 2 }] @@ -175,6 +173,7 @@ 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 }); @@ -186,15 +185,13 @@ public async Task Should_FindCoverArtFromSeason_When_NoCoverFromEpisode() public async Task Should_WriteFileAndReturnSuccess_When_Succeeds() { var config = new AutoTagConfig { AddCoverArt = false }; - + var mockCache = new Mock<ITVCache>(); - - mockCache.Setup(c => c.ShowIsCached(It.IsAny<string>())) + + List<ShowResults>? showResults = [new ShowResults(new SearchTv { Name = "Show" })]; + mockCache.Setup(c => c.TryGetShow(It.IsAny<string>(), It.IsAny<int?>(), out showResults)) .Returns(true); - - mockCache.Setup(c => c.GetShow(It.IsAny<string>())) - .Returns([ new ShowResults(new SearchTv { Name = "Show" }) ]); - + var season = new TvSeason { Episodes = [new TvSeasonEpisode { EpisodeNumber = 2 }] @@ -210,7 +207,8 @@ public async Task Should_WriteFileAndReturnSuccess_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().Be(ProcessResult.Success); diff --git a/AutoTag.Core.Test/packages.lock.json b/AutoTag.Core.Test/packages.lock.json deleted file mode 100644 index fbe17eb..0000000 --- a/AutoTag.Core.Test/packages.lock.json +++ /dev/null @@ -1,290 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net10.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[6.0.4, )", - "resolved": "6.0.4", - "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" - }, - "FluentAssertions": { - "type": "Direct", - "requested": "[8.8.0, )", - "resolved": "8.8.0", - "contentHash": "m0kwcqBwvVel03FuMa7Ozo/oTaxYbjeNlcOhQFkyQpwX/8wks6RNl/Jnn58DCZVs6c2oG1RsCZw7HfKSaxLm3w==" - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[18.0.1, )", - "resolved": "18.0.1", - "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", - "dependencies": { - "Microsoft.CodeCoverage": "18.0.1", - "Microsoft.TestPlatform.TestHost": "18.0.1" - } - }, - "Moq": { - "type": "Direct", - "requested": "[4.20.72, )", - "resolved": "4.20.72", - "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==", - "dependencies": { - "Castle.Core": "5.1.1" - } - }, - "xunit": { - "type": "Direct", - "requested": "[2.9.3, )", - "resolved": "2.9.3", - "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", - "dependencies": { - "xunit.analyzers": "1.18.0", - "xunit.assert": "2.9.3", - "xunit.core": "[2.9.3]" - } - }, - "xunit.runner.visualstudio": { - "type": "Direct", - "requested": "[3.1.5, )", - "resolved": "3.1.5", - "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "5.1.1", - "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", - "dependencies": { - "System.Diagnostics.EventLog": "6.0.0" - } - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "18.0.1", - "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Diagnostics": "10.0.1", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Binder": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Primitives": "10.0.1" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "18.0.1", - "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "18.0.1", - "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "18.0.1", - "Newtonsoft.Json": "13.0.3" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" - }, - "TagLibSharp": { - "type": "Transitive", - "resolved": "2.3.0", - "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" - }, - "TMDbLib": { - "type": "Transitive", - "resolved": "2.3.0", - "contentHash": "kapjC4/ao8mxJ/G5xcZHYNVFvmAzxwWEN2PDkGbbUzAQVfbf85DsA9AUDTrXahTHYq0R7jwGyARs/y6243EPLg==", - "dependencies": { - "Newtonsoft.Json": "13.0.3" - } - }, - "xunit.abstractions": { - "type": "Transitive", - "resolved": "2.0.3", - "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" - }, - "xunit.analyzers": { - "type": "Transitive", - "resolved": "1.18.0", - "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" - }, - "xunit.assert": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" - }, - "xunit.core": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", - "dependencies": { - "xunit.extensibility.core": "[2.9.3]", - "xunit.extensibility.execution": "[2.9.3]" - } - }, - "xunit.extensibility.core": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", - "dependencies": { - "xunit.abstractions": "2.0.3" - } - }, - "xunit.extensibility.execution": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", - "dependencies": { - "xunit.extensibility.core": "[2.9.3]" - } - }, - "autotag.core": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Caching.Memory": "[10.0.1, )", - "Microsoft.Extensions.DependencyInjection": "[10.0.1, )", - "Microsoft.Extensions.Http": "[10.0.1, )", - "TMDbLib": "[2.3.0, )", - "TagLibSharp": "[2.3.0, )" - } - } - } - } -} \ No newline at end of file diff --git a/AutoTag.Core/AutoTag.Core.csproj b/AutoTag.Core/AutoTag.Core.csproj index 2a613e0..1e37204 100644 --- a/AutoTag.Core/AutoTag.Core.csproj +++ b/AutoTag.Core/AutoTag.Core.csproj @@ -1,15 +1,15 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net10.0</TargetFramework> - <Nullable>enable</Nullable> - <ImplicitUsings>enable</ImplicitUsings> - </PropertyGroup> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" /> - <PackageReference Include="TagLibSharp" Version="2.3.0" /> - <PackageReference Include="TMDbLib" Version="2.3.0" /> - </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1"/> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1"/> + <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1"/> + <PackageReference Include="TagLibSharp" Version="2.3.0"/> + <PackageReference Include="TMDbLib" Version="2.3.0"/> + </ItemGroup> </Project> diff --git a/AutoTag.Core/Config/AutoTagConfig.cs b/AutoTag.Core/Config/AutoTagConfig.cs index c759e3a..b80ff0c 100644 --- a/AutoTag.Core/Config/AutoTagConfig.cs +++ b/AutoTag.Core/Config/AutoTagConfig.cs @@ -1,48 +1,49 @@ +using AutoTag.Core.Files; + namespace AutoTag.Core.Config; public class AutoTagConfig { - public const int CurrentVer = 14; - + public const int CurrentVer = 15; + public int ConfigVer { get; set; } = CurrentVer; - - public Mode Mode { get; set; } = Mode.TV; - + + public Mode Mode { get; set; } = Mode.Auto; + public bool ManualMode { get; set; } = false; - + public bool Verbose { get; set; } = false; - + public bool AddCoverArt { get; set; } = true; - + public bool TagFiles { get; set; } = true; - + public bool RenameFiles { get; set; } = true; - - public bool OrganizeFolders { get; set; } = false; - + public bool RemoveEmptyFolders { get; set; } = false; - - public string TVRenamePattern { get; set; } = "%1 - %2x%3:00 - %4"; - - public string MovieRenamePattern { get; set; } = "%1 (%2)"; - + + 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 string Language { get; set; } = "en"; public List<string> SearchLanguages { get; set; } = []; - + public bool IncludeAdult { get; set; } = false; - + public bool EpisodeGroup { get; set; } - + public IEnumerable<FileNameReplace> 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 abcda2c..14d1dc1 100644 --- a/AutoTag.Core/Extensions.cs +++ b/AutoTag.Core/Extensions.cs @@ -1,6 +1,8 @@ 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,8 +23,13 @@ public static void AddCoreServices(this IServiceCollection services, string apiK services.AddSingleton<IFileSystem, FileSystem>(); services.AddSingleton<IFileFinder, FileFinder>(); + + services.AddSingleton<IFileNameParser, FileNameParser>(); + services.AddSingleton<TVFileNameParser>(); + services.AddSingleton<MovieFileNameParser>(); services.AddSingleton<IFileWriter, FileWriter>(); + services.AddSingleton<IFileNamer, FileNamer>(); services.AddScoped<ICoverArtFetcher, CoverArtFetcher>(); @@ -70,4 +77,9 @@ public static bool TryFind<T>(this List<T> list, Predicate<T> match, [NotNullWhe } public static bool IsSuccess(this ProcessResult result) => result is ProcessResult.Success or ProcessResult.Skipped; + + public static int? GetNullableIntValue(this GroupCollection groups, string groupName) + => groups.TryGetValue(groupName, out var match) && int.TryParse(match.Value, out var value) + ? value + : null; } \ 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<string>? Actors { get; set; } public IEnumerable<string>? Characters { get; set; } - public IEnumerable<string>? Genres { get; set; } - - public FileMetadata() - { - Success = true; - Complete = true; - } + public IEnumerable<string>? 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(@"%(?<num>\d+)(?:\:(?<format>[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<IFileNameField> 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<FileNameReplace> FromStrings(IEnumerable<string> 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<FileNameReplace> FromDictionary(IDictionary<string, string> 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 fbe476c..7f5a2be 100644 --- a/AutoTag.Core/Files/FileFinder.cs +++ b/AutoTag.Core/Files/FileFinder.cs @@ -1,6 +1,5 @@ using AutoTag.Core.Config; -using AutoTag.Core.TV; -using System.Text.RegularExpressions; +using AutoTag.Core.Files.Parsing; namespace AutoTag.Core.Files; @@ -9,7 +8,7 @@ public interface IFileFinder List<TaggingFile> FindFilesToProcess(IEnumerable<FileSystemInfo> 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 HashSet<string> ProcessableVideoExtensions = new(StringComparer.OrdinalIgnoreCase) { @@ -31,12 +30,14 @@ public class FileFinder(AutoTagConfig config, IFileSystem fs, IUserInterface ui) ".asf", ".mxf" }; + private static readonly HashSet<string> TaggableVideoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".mp4", ".m4v", ".mkv" }; + private static readonly HashSet<string> SubtitleExtensions = new(StringComparer.OrdinalIgnoreCase) { ".srt", @@ -45,58 +46,63 @@ public class FileFinder(AutoTagConfig config, IFileSystem fs, IUserInterface ui) ".ssa", ".ass" }; - + public List<TaggingFile> FindFilesToProcess(IEnumerable<FileSystemInfo> entries) { var files = FindFilesInDirectory(entries) .DistinctBy(f => f.Path) + .Select(f => + { + var (tvResult, movieResult) = parser.ParseFileName(f.Path); + + return f with { TVDetails = tvResult, MovieDetails = movieResult }; + }) .ToList(); - if (config.RenameSubtitles && string.IsNullOrEmpty(config.ParsePattern)) + 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(); - - files = AttachLooseSubtitles(files).ToList(); } return files .OrderBy(f => f.Path) .ToList(); } - + private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo> entries) { foreach (var entry in entries) { - if (entry.Exists) + if (fs.Exists(entry)) { - if (entry is DirectoryInfo directory) + switch (entry) { - ui.DisplayMessage($"Adding all files in directory '{directory}'", MessageType.Log); - - foreach (var file in FindFilesInDirectory(fs.GetDirectoryContents(directory), directory.FullName)) - { - yield return file; - } - } - else if (entry is FileInfo file && 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, - RootPath = file.DirectoryName, - Taggable = IsTaggableVideoFile(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 @@ -106,29 +112,28 @@ private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo } } - private IEnumerable<TaggingFile> FindFilesInDirectory(IEnumerable<FileSystemInfo> entries, string rootPath) + + private IEnumerable<TaggingFile> GroupSubtitles( + IGrouping<(ParsedTVFileName? TVResult, ParsedMovieFileName? MovieResult), TaggingFile> files) { - foreach (var file in FindFilesInDirectory(entries)) + if (files.Key is { TVResult: null, MovieResult: null }) { - file.RootPath = rootPath; - yield return file; + foreach (var file in files) + { + yield return file; + } } - } - - private IEnumerable<TaggingFile> GroupSubtitles(IGrouping<string, TaggingFile> files) - { - if (files.Count() == 1) + 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; @@ -136,185 +141,35 @@ private IEnumerable<TaggingFile> GroupSubtitles(IGrouping<string, TaggingFile> 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, - RootPath = files.FirstOrDefault(f => f.Path == videoPath)?.RootPath, - SubtitlePath = subPath, - SubtitlePaths = subPath != null ? [subPath] : [], - Taggable = IsTaggableVideoFile(Path.GetExtension(videoPath)) + 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, - RootPath = files.FirstOrDefault(f => f.Path == subPath)?.RootPath, - Taggable = false + SubtitlePaths = subs.Skip(1).Select(s => s.Path).ToList() }; } } } - private IEnumerable<TaggingFile> AttachLooseSubtitles(List<TaggingFile> files) - { - var handledSubtitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var videosByEpisode = files - .Where(IsVideoFile) - .Select(file => new { File = file, Key = GetVideoEpisodeKey(file) }) - .Where(item => item.Key.HasValue) - .GroupBy(item => item.Key!.Value) - .ToDictionary(group => group.Key, group => group.Select(item => item.File).ToList()); - - foreach (var subtitle in files.Where(IsSubtitleFile)) - { - foreach (var key in GetSubtitleEpisodeKeys(subtitle)) - { - if (!videosByEpisode.TryGetValue(key, out var videos) || videos.Count != 1) - { - continue; - } - - AddSubtitlePath(videos[0], subtitle.Path); - handledSubtitles.Add(subtitle.Path); - break; - } - } - - return files.Where(file => !handledSubtitles.Contains(file.Path)); - } - - private EpisodeKey? GetVideoEpisodeKey(TaggingFile file) - { - if (!EpisodeParser.TryParseEpisodeInfo(Path.GetFileName(file.Path), out var metadata, out _)) - { - return null; - } - - return new EpisodeKey(NormaliseSeriesName(metadata.SeriesName), metadata.Season, metadata.Episode); - } - private IEnumerable<EpisodeKey> GetSubtitleEpisodeKeys(TaggingFile file) - { - if (EpisodeParser.TryParseEpisodeInfo(Path.GetFileName(file.Path), out var metadata, out _)) - { - yield return new EpisodeKey(NormaliseSeriesName(metadata.SeriesName), metadata.Season, metadata.Episode); - yield break; - } + private bool IsSupportedFile(FileInfo info) => + IsVideoFile(info.Extension) + || (config.RenameSubtitles && IsSubtitleFile(info.Extension)); - var cleanedName = BracketGroupRegex.Replace(Path.GetFileNameWithoutExtension(file.Path), " "); - var matches = LooseEpisodeNumberRegex.Matches(cleanedName) - .Where(match => int.TryParse(match.Groups["Episode"].Value, out var episode) && episode != 720) - .ToList(); - var episodeMatch = matches.LastOrDefault(); - if (episodeMatch == null || !int.TryParse(episodeMatch.Groups["Episode"].Value, out var episodeNumber)) - { - yield break; - } - - var season = GetSeasonFromPath(file.Path); - var seriesNames = new List<string>(); - var parsedSeriesName = cleanedName[..episodeMatch.Groups["Episode"].Index].Trim(' ', '.', '-', '_'); - if (!string.IsNullOrWhiteSpace(parsedSeriesName)) - { - seriesNames.Add(parsedSeriesName); - } - - var directorySeriesName = GetSeriesNameFromPath(file); - if (!string.IsNullOrWhiteSpace(directorySeriesName)) - { - seriesNames.Add(directorySeriesName); - } - - foreach (var seriesName in seriesNames - .Select(NormaliseSeriesName) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.OrdinalIgnoreCase)) - { - yield return new EpisodeKey(seriesName, season, episodeNumber); - } - } - - private static void AddSubtitlePath(TaggingFile video, string subtitlePath) - { - if (!string.IsNullOrEmpty(video.SubtitlePath) && !video.SubtitlePaths.Contains(video.SubtitlePath)) - { - video.SubtitlePaths.Add(video.SubtitlePath); - } - - if (!video.SubtitlePaths.Contains(subtitlePath)) - { - video.SubtitlePaths.Add(subtitlePath); - } - - video.SubtitlePath ??= subtitlePath; - } - - private static string? GetSeriesNameFromPath(TaggingFile file) - { - if (string.IsNullOrEmpty(file.RootPath)) - { - return null; - } - - var relativePath = Path.GetRelativePath(file.RootPath, file.Path); - return relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).FirstOrDefault(); - } - - private static int GetSeasonFromPath(string path) - { - var directory = Path.GetDirectoryName(path); - if (directory == null) - { - return 1; - } - - foreach (var segment in directory.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Reverse()) - { - if (segment.Equals("Specials", StringComparison.OrdinalIgnoreCase)) - { - return 0; - } - - var match = SeasonFolderRegex.Match(segment); - if (match.Success && int.TryParse(match.Groups["Season"].Value, out var season)) - { - return season; - } - } - - return 1; - } - - private static string NormaliseSeriesName(string seriesName) - => MultiSpaceRegex.Replace(seriesName.Replace('.', ' ').Replace('_', ' '), " ") - .Trim(' ', '.', '-', '_') - .ToUpperInvariant(); - - private bool IsSupportedFile(FileInfo info) - => IsVideoFile(info.Extension) - || config.RenameSubtitles && IsSubtitleFile(info.Extension); - - private bool IsVideoFile(TaggingFile file) => IsVideoFile(Path.GetExtension(file.Path)); private bool IsVideoFile(string extension) => ProcessableVideoExtensions.Contains(extension); - private bool IsSubtitleFile(TaggingFile file) => IsSubtitleFile(Path.GetExtension(file.Path)); - private bool IsTaggableVideoFile(string extension) => TaggableVideoExtensions.Contains(extension); private bool IsSubtitleFile(string extension) => SubtitleExtensions.Contains(extension); - - private readonly record struct EpisodeKey(string SeriesName, int Season, int Episode); - - private static readonly Regex BracketGroupRegex = new(@"\[[^\]]+\]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex LooseEpisodeNumberRegex = new(@"(?:^|[._\s-])(?<Episode>\d{1,3})(?=$|[._\s-])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex MultiSpaceRegex = new(@"\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SeasonFolderRegex = new(@"^Season\s*(?<Season>\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); -} +} \ 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..ced5bf2 --- /dev/null +++ b/AutoTag.Core/Files/FileNameField.cs @@ -0,0 +1,46 @@ +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? _) => Value ?? ""; +} + +public class IntegerFileNameField(string specifier, string? legacySpecifier, int? value) : IFileNameField +{ + private static readonly Regex FormatSpecifierRegex = new("[0#]+"); + + 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..91dad37 --- /dev/null +++ b/AutoTag.Core/Files/FileNameReplace.cs @@ -0,0 +1,12 @@ +namespace AutoTag.Core.Files; + +public class FileNameReplace(string replace, string replacement) +{ + private string Replace { get; } = replace; + private string Replacement { get; } = replacement; + + public string Apply(string str) => str.Replace(Replace, Replacement); + + public static IEnumerable<FileNameReplace> FromDictionary(IDictionary<string, string> dict) + => dict.Select(x => new FileNameReplace(x.Key, x.Value)); +} \ 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..c41983a --- /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 class FileNamer(AutoTagConfig config) : IFileNamer +{ + private static readonly Regex RenameRegex = + new( + @"{(?<specifier>[A-z]+)(?:\:(?<specifierFormat>[^}]+))?}|%(?<legacySpecifier>\d+)(?:\:(?<legacySpecifierFormat>[0#]+))?"); + + private static readonly char[] InvalidNtfsChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + + private readonly char[] _invalidFilenameChars = GetInvalidFileNameChars(config); + + 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 85eff2f..0e54aa8 100644 --- a/AutoTag.Core/Files/FileSystem.cs +++ b/AutoTag.Core/Files/FileSystem.cs @@ -1,23 +1,29 @@ -using System.Diagnostics.CodeAnalysis; - namespace AutoTag.Core.Files; public interface IFileSystem { IEnumerable<FileSystemInfo> 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); Stream OpenWriteStream(string path); @@ -28,19 +34,27 @@ public class FileSystem : IFileSystem public IEnumerable<FileSystemInfo> 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(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); public Stream OpenWriteStream(string path) => new FileStream(path, FileMode.Create, FileAccess.Write); -} +} \ No newline at end of file diff --git a/AutoTag.Core/Files/FileWriter.cs b/AutoTag.Core/Files/FileWriter.cs index fa4825e..220f4a4 100644 --- a/AutoTag.Core/Files/FileWriter.cs +++ b/AutoTag.Core/Files/FileWriter.cs @@ -1,6 +1,6 @@ using AutoTag.Core.Config; -using AutoTag.Core.Movie; -using AutoTag.Core.TV; +using TagLib; +using File = TagLib.File; namespace AutoTag.Core.Files; @@ -9,38 +9,62 @@ public interface IFileWriter Task<bool> 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<bool> WriteAsync(TaggingFile taggingFile, FileMetadata metadata) { - bool fileSuccess = true; - var targetFileName = GetFileName(metadata.GetFileName(config), Path.GetFileNameWithoutExtension(taggingFile.Path)); - var targetDirectory = GetTargetDirectory(taggingFile, metadata, targetFileName); - - var alreadyNamedCorrectly = config.RenameFiles && IsAlreadyNamedCorrectly(taggingFile, targetFileName, targetDirectory); + var fileSuccess = true; if (config.TagFiles && taggingFile.Taggable) { - fileSuccess = await TagFileAsync(taggingFile, metadata); + fileSuccess &= await TagFileAsync(taggingFile, metadata); } - if (alreadyNamedCorrectly) - { - ui.SetStatus("Rename skipped - already named correctly", MessageType.Information); - } - else if (config.RenameFiles) + if (config.RenameFiles) { - fileSuccess &= RenameFile(taggingFile.Path, targetFileName, targetDirectory, null); + var (targetPath, removedInvalid) = namer.GetNewFileName(metadata); - var subtitlePaths = GetSubtitlePaths(taggingFile); - for (var i = 0; i < subtitlePaths.Count; i++) + 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)) { - fileSuccess &= RenameFile( - subtitlePaths[i], - GetSubtitleTargetFileName(targetFileName, i, subtitlePaths.Count), - targetDirectory, - "subtitle " - ); + ui.SetStatus("Rename skipped - already named correctly", MessageType.Information); + } + else + { + 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; } } @@ -49,12 +73,12 @@ public async Task<bool> WriteAsync(TaggingFile taggingFile, FileMetadata metadat private async Task<bool> 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); @@ -68,14 +92,13 @@ private async Task<bool> 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) { @@ -93,59 +116,61 @@ private async Task<bool> 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 targetDirectory, string? msgPrefix) + private bool RenameFile(string path, string newPath, bool isDirectoryPath, string? msgPrefix) { - bool fileSuccess = true; - string newPath = GetTargetPath(path, newName, targetDirectory); - - if (path != newPath) + var fileSuccess = true; + try { - var sourceDirectory = Path.GetDirectoryName(path); - 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.CreateDirectory(new DirectoryInfo(targetDirectory)); - fs.Move(path, newPath); - ui.SetFilePath(newPath); - ui.SetStatus($"Successfully renamed {msgPrefix}file to '{Path.GetFileName(newPath)}'", MessageType.Information); - RemoveSourceDirectoryIfEmpty(sourceDirectory, targetDirectory); - } + 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 void RemoveSourceDirectoryIfEmpty(string? sourceDirectory, string targetDirectory) { - if (!config.OrganizeFolders - || !config.RemoveEmptyFolders - || string.IsNullOrEmpty(sourceDirectory) - || Path.GetFullPath(sourceDirectory) == Path.GetFullPath(targetDirectory) + if (string.IsNullOrEmpty(sourceDirectory) + || sourceDirectory == targetDirectory || !fs.DirectoryExists(sourceDirectory) || !fs.DirectoryIsEmpty(sourceDirectory)) { @@ -156,110 +181,20 @@ private void RemoveSourceDirectoryIfEmpty(string? sourceDirectory, string target ui.SetStatus($"Removed empty folder '{sourceDirectory}'", MessageType.Information); } - private bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string targetFileName, string targetDirectory) + private string GetFullOutputPath(string path, string newPath) { - if (taggingFile.Path != GetTargetPath(taggingFile.Path, targetFileName, targetDirectory)) - { - return false; - } + var isDirectoryPath = fs.PathContainsDirectory(newPath); + var extension = Path.GetExtension(path); - var subtitlePaths = GetSubtitlePaths(taggingFile); - for (var i = 0; i < subtitlePaths.Count; i++) - { - if (subtitlePaths[i] != GetTargetPath( - subtitlePaths[i], - GetSubtitleTargetFileName(targetFileName, i, subtitlePaths.Count), - targetDirectory - )) - { - return false; - } - } - - return true; + return (isDirectoryPath ? newPath : Path.Combine(fs.GetDirectoryPath(path)!, newPath)) + extension; } - private string GetTargetPath(string path, string targetFileName, string targetDirectory) - => Path.Combine(targetDirectory, targetFileName + Path.GetExtension(path)); + private static bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string newPath, + IEnumerable<(string Path, string NewPath)> subtitlePaths) + => taggingFile.Path == newPath && subtitlePaths.All(p => p.Path == p.NewPath); private static string GetSubtitleTargetFileName(string targetFileName, int index, int subtitleCount) => subtitleCount == 1 ? targetFileName : $"{targetFileName}.{index + 1}"; - - private static List<string> GetSubtitlePaths(TaggingFile taggingFile) - { - var paths = new List<string>(); - if (!string.IsNullOrEmpty(taggingFile.SubtitlePath)) - { - paths.Add(taggingFile.SubtitlePath); - } - - foreach (var subtitlePath in taggingFile.SubtitlePaths) - { - if (!string.IsNullOrEmpty(subtitlePath) && !paths.Contains(subtitlePath)) - { - paths.Add(subtitlePath); - } - } - - return paths; - } - - private string GetTargetDirectory(TaggingFile taggingFile, FileMetadata metadata, string targetFileName) - { - var currentDirectory = Path.GetDirectoryName(taggingFile.Path)!; - if (!config.OrganizeFolders) - { - return currentDirectory; - } - - var rootPath = taggingFile.RootPath ?? currentDirectory; - return metadata switch - { - MovieFileMetadata => Path.Combine(rootPath, targetFileName), - TVFileMetadata tv => Path.Combine(rootPath, GetFileName(tv.SeriesName, tv.SeriesName), GetSeasonFolderName(tv.Season)), - _ => currentDirectory - }; - } - - private static string GetSeasonFolderName(int season) - => season == 0 - ? "Specials" - : $"Season {season:00}"; - - private string GetFileName(string fileName, string oldFileName) - { - string result = fileName; - foreach (var replace in config.FileNameReplaces) - { - 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 sanitisedName; - } - - private string RemoveInvalidFileNameChars(string fileName) - { - if (InvalidFilenameChars == null) - { - InvalidFilenameChars = Path.GetInvalidFileNameChars(); - - if (config.WindowsSafe) - { - InvalidFilenameChars = InvalidFilenameChars.Union(InvalidNtfsChars).ToArray(); - } - } - - return string.Concat(fileName.Where(c => !InvalidFilenameChars.Contains(c))); - } - - private static char[]? InvalidFilenameChars { get; set; } - private static readonly char[] InvalidNtfsChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; -} +} \ 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/Movie/MovieNameNormalizer.cs b/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs similarity index 52% rename from AutoTag.Core/Movie/MovieNameNormalizer.cs rename to AutoTag.Core/Files/Parsing/MovieFileNameParser.cs index a26af17..7039e10 100644 --- a/AutoTag.Core/Movie/MovieNameNormalizer.cs +++ b/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs @@ -1,49 +1,104 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; -namespace AutoTag.Core.Movie; +namespace AutoTag.Core.Files.Parsing; -public static class MovieNameNormalizer +public record ParsedMovieFileName(string Title, int? Year) { - public static bool LooksLikeTvEpisode(string fileName) - { - var name = Path.GetFileNameWithoutExtension(fileName); - return TVEpisodePatternRegex.IsMatch(name) - || LooksLikeAbsoluteEpisodeCandidate(name); - } + public override string ToString() => $"{Title}{(Year.HasValue ? $" ({Year})" : "")}"; +} - public static bool LooksLikeMovieCandidate(string fileName) - { - var name = Path.GetFileNameWithoutExtension(fileName); - if (string.IsNullOrWhiteSpace(name) || LooksLikeTvEpisode(fileName)) - { - return false; - } +public class MovieFileNameParser +{ + private const RegexOptions SharedRegexOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + private const RegexOptions DomainTailRegexOptions = RegexOptions.CultureInvariant; - return ParenthesisedYearRegex.IsMatch(name) - || BareYearRegex.IsMatch(name) - || SiteMarkerDomainTailRegex.IsMatch(name) - || DomainTailRegex.IsMatch(name) - || TechnicalTermPatterns.Any(pattern => Regex.IsMatch(name, pattern, SharedRegexOptions)); - } + 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); - public static bool TryParseFileName(string fileName, [NotNullWhen(true)] out string? title, out int? year) - { - year = null; + 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*)?(?<Year>(19|20)\d{2})\)", SharedRegexOptions); + + private static readonly Regex BareYearRegex = new(@"\b(?<Year>(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" + ]; - var workingTitle = Path.GetFileNameWithoutExtension(fileName); + public bool TryParse(string filePath, [NotNullWhen(true)] out ParsedMovieFileName? result) + { + var workingTitle = Path.GetFileNameWithoutExtension(filePath); if (string.IsNullOrWhiteSpace(workingTitle)) { - title = null; + result = null; return false; } - var hadTechnicalNoise = TechnicalTermPatterns.Any(pattern => Regex.IsMatch(workingTitle, pattern, SharedRegexOptions)); - 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; @@ -55,9 +110,17 @@ public static bool TryParseFileName(string fileName, [NotNullWhen(true)] out str workingTitle = withoutTrailingYear; } + var hadTechnicalNoise = false; foreach (var pattern in TechnicalTermPatterns) { - workingTitle = Regex.Replace(workingTitle, pattern, " ", SharedRegexOptions); + workingTitle = Regex.Replace(workingTitle, pattern, + _ => + { + hadTechnicalNoise = true; + return " "; + }, + SharedRegexOptions + ); } foreach (var pattern in LanguageTermPatterns) @@ -75,54 +138,32 @@ public static bool TryParseFileName(string fileName, [NotNullWhen(true)] out str workingTitle = MultiSpaceRegex.Replace(workingTitle, " ").Trim(); workingTitle = TrimmedPunctuationRegex.Replace(workingTitle, ""); - title = string.IsNullOrWhiteSpace(workingTitle) - ? null - : workingTitle; - - return title != null; - } - - public static IReadOnlyList<string> GetSearchCandidates(string title) - { - var candidates = new List<string>(); - AddCandidate(candidates, title); - - var words = title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (words.Length > 4) + if (!string.IsNullOrWhiteSpace(workingTitle)) { - for (int removedWords = 1; removedWords <= Math.Min(3, words.Length - 3); removedWords++) - { - AddCandidate(candidates, string.Join(' ', words.Take(words.Length - removedWords))); - } + result = new ParsedMovieFileName(workingTitle, year); + return true; } - return candidates; + result = null; + return false; } - private static void AddCandidate(List<string> candidates, string candidate) + private static bool TryExtractParenthesisedYear(string title, [NotNullWhen(true)] out int? year, + [NotNullWhen(true)] + out string? updatedTitle) { - if (!string.IsNullOrWhiteSpace(candidate) && - !candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase)) - { - candidates.Add(candidate); - } - } + year = null; + updatedTitle = null; - private static bool TryExtractParenthesisedYear(string title, out int year, [NotNullWhen(true)] out string? updatedTitle) - { var match = ParenthesisedYearRegex.Match(title); if (!match.Success) { - year = default; - updatedTitle = null; return false; } var candidateTitle = ParenthesisedYearRegex.Replace(title, " ", 1); if (!HasUsefulTitle(candidateTitle)) { - year = default; - updatedTitle = null; return false; } @@ -131,29 +172,28 @@ private static bool TryExtractParenthesisedYear(string title, out int year, [Not return true; } - private static bool TryExtractTrailingYear(string title, out int year, [NotNullWhen(true)] out string? updatedTitle) + 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) { - year = default; - updatedTitle = null; return false; } - var match = matches[matches.Count - 1]; + var match = matches[^1]; if (match.Index == 0) { - year = default; - updatedTitle = null; return false; } var candidateTitle = title.Remove(match.Index, match.Length); if (!HasUsefulTitle(candidateTitle)) { - year = default; - updatedTitle = null; return false; } @@ -162,87 +202,6 @@ private static bool TryExtractTrailingYear(string title, out int year, [NotNullW return true; } - private static bool HasUsefulTitle(string title) - => !string.IsNullOrWhiteSpace(MultiSpaceRegex.Replace(title, " ").Trim(TrimCharacters)); - - private static bool LooksLikeAbsoluteEpisodeCandidate(string fileNameWithoutExtension) - { - var match = AbsoluteEpisodePatternRegex.Match(fileNameWithoutExtension); - if (!match.Success - || !int.TryParse(match.Groups["Episode"].Value, out var episode) - || episode is >= 1900 and <= 2099) - { - return false; - } - - return SquareBracketGroupRegex.IsMatch(fileNameWithoutExtension) - || LanguageTermPatterns.Any(pattern => Regex.IsMatch(fileNameWithoutExtension, pattern, SharedRegexOptions)); - } - - 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*)?(?<Year>(19|20)\d{2})\)", SharedRegexOptions); - private static readonly Regex BareYearRegex = new(@"\b(?<Year>(19|20)\d{2})\b", SharedRegexOptions); - private static readonly Regex TVEpisodePatternRegex = new(@"\b(?:s\d{1,2}\s*e\d{1,3}|\d{1,2}x\d{1,3}|episode\s*\d{1,3}|e\d{1,3})\b", SharedRegexOptions); - private static readonly Regex AbsoluteEpisodePatternRegex = new(@"\((?<Episode>[1-9]\d{0,3})\)\s*$", 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" - ]; -} + 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( + @"^(?<SeriesName>(?!s\d).{2,}?)[._ -]*(?:\(?(?<Year>(?:19|20)\d{2})\)?)?[._ -]*(?:(?:s?(?<Season>\d+)[ex._ ](?<Episode>\d+\b))|(?:[e(]?(?<AbsoluteEpisode>\d+\b(?!.*s?\d+[ex]\d+))))(?:-e?(?<EndEpisode>\d+))?(?:.*p(?:ar)?t(?<Part>\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 bc21dd4..f50678b 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? RootPath { get; set; } - public string? SubtitlePath { get; set; } - public List<string> SubtitlePaths { get; set; } = []; - public bool Taggable { get; set; } = true; + public required string Path { get; init; } + public List<string> SubtitlePaths { get; init; } = []; + public bool Taggable { get; init; } = true; public string Status { get; set; } = ""; public bool Success { get; set; } = true; + public ParsedTVFileName? TVDetails { get; set; } + public ParsedMovieFileName? MovieDetails { get; set; } + public override string ToString() { return $"{System.IO.Path.GetFileName(Path)}: {Status}"; 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/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<IFileNameField> 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 18e0db3..5dc3feb 100644 --- a/AutoTag.Core/Movie/MovieProcessor.cs +++ b/AutoTag.Core/Movie/MovieProcessor.cs @@ -3,25 +3,19 @@ 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 (MovieNameNormalizer.LooksLikeTvEpisode(Path.GetFileName(file.Path))) + if (file.MovieDetails is null) { - ui.SetStatus("File skipped - filename looks like a TV episode", MessageType.Warning); return ProcessResult.ParseFailure; } - if (!MovieNameNormalizer.TryParseFileName(Path.GetFileName(file.Path), out string? title, out int? year)) - { - ui.SetStatus("Error: Failed to parse required information from filename", MessageType.Error); - return ProcessResult.ParseFailure; - } - - ui.SetStatus($"Parsed file as {title}", MessageType.Log); + ui.SetStatus($"Parsed file as {file.MovieDetails}", MessageType.Log); - var (findMovieResult, selectedResult) = await FindMovieAsync(title, year); + var (findMovieResult, selectedResult) = await FindMovieAsync(file.MovieDetails.Title, file.MovieDetails.Year); switch (findMovieResult) { case FindResult.Fail: @@ -30,13 +24,15 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) return ProcessResult.Skipped; } - ui.SetStatus($"Found {selectedResult!.Title} ({selectedResult.ReleaseDate?.Year.ToString() ?? "unknown year"}) on TheMovieDB", MessageType.Information); + 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 + var taggingSuccess = await writer.WriteAsync(file, result); + + return taggingSuccess && result.Complete ? ProcessResult.Success : ProcessResult.Fail; } @@ -82,12 +78,13 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) .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); - + ui.SetStatus($"Selected {selected.Title} ({selected.ReleaseDate?.Year.ToString() ?? "Unknown"})", + MessageType.Information); + return (FindResult.Success, selected); } @@ -101,7 +98,7 @@ private async Task<MovieFileMetadata> GetMovieMetadataAsync(TMDBMovie selectedRe var movie = selectedResult.Language == config.Language ? selectedResult : await tmdb.GetMovieAsync(selectedResult.Id); - + var result = new MovieFileMetadata { Id = selectedResult.Id, @@ -110,10 +107,9 @@ private async Task<MovieFileMetadata> GetMovieMetadataAsync(TMDBMovie selectedRe CoverURL = string.IsNullOrEmpty(movie.PosterPath) ? null : $"https://image.tmdb.org/t/p/original{movie.PosterPath}", - Date = movie.ReleaseDate + Date = movie.ReleaseDate, + Genres = movie.Genres }; - - result.Genres = movie.Genres; if (config.ExtendedTagging && fileIsTaggable) { @@ -135,7 +131,7 @@ private async Task<MovieFileMetadata> GetMovieMetadataAsync(TMDBMovie selectedRe private IEnumerable<MovieSearchAttempt> GetSearchAttempts(string title, int? year) { - foreach (var candidate in MovieNameNormalizer.GetSearchCandidates(title)) + foreach (var candidate in GetSearchCandidates(title)) { foreach (var language in GetSearchLanguages()) { @@ -149,6 +145,25 @@ private IEnumerable<MovieSearchAttempt> GetSearchAttempts(string title, int? yea } } + 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]; @@ -163,4 +178,4 @@ 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/TMDB/TMDBService.cs b/AutoTag.Core/TMDB/TMDBService.cs index 41e2f7e..73e92be 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,7 +20,7 @@ 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); @@ -32,12 +34,15 @@ public interface ITMDBService 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, 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); @@ -66,7 +71,8 @@ public Task<ImagesWithId> GetTvShowImagesAsync(int id) public async Task<List<TMDBMovie>> SearchMovieAsync(string query, string language, int? year) { - var results = await client.SearchMovieAsync(query, language, includeAdult: config.IncludeAdult, year: year ?? 0); + var results = + await client.SearchMovieAsync(query, language, includeAdult: config.IncludeAdult, year: year ?? 0); if (results.Results.Count == 0) { @@ -79,9 +85,12 @@ public async Task<List<TMDBMovie>> SearchMovieAsync(string query, string languag } public async Task<TMDBMovie> GetMovieAsync(int movieId) - => TMDBMovie.FromMovie(await client.GetMovieAsync(movieId, language: config.Language), config.Language); + => TMDBMovie.FromMovie(await client.GetMovieAsync(movieId, config.Language), config.Language); + + public Task<Credits> GetMovieCreditsAsync(int movieId) + => client.GetMovieCreditsAsync(movieId); + - private async Task GetMovieGenreNamesAsync() { if (MovieGenres.Count == 0) @@ -90,7 +99,4 @@ private async Task GetMovieGenreNamesAsync() .ToDictionary(g => g.Id, g => g.Name); } } - - public Task<Credits> GetMovieCreditsAsync(int movieId) - => client.GetMovieCreditsAsync(movieId); -} +} \ 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..40b771c --- /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 92174c8..0000000 --- a/AutoTag.Core/TV/EpisodeParser.cs +++ /dev/null @@ -1,136 +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; - private static readonly Regex AbsoluteEpisodePattern = new(@"^(?<SeriesName>.+?)[. _-]*\((?<AbsoluteEpisode>[1-9]\d{0,3})\)[. _-]*(?<ExtraInfo>.*)?$", RegexOptions); - private static readonly Regex SquareBracketGroupRegex = new(@"\[[^\]]+\]", RegexOptions); - private static readonly Regex MultiSpaceRegex = new(@"\s+", RegexOptions); - 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), - new(@"^((?<SeriesName>.+?)[. _-]+)?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 = NormaliseSeriesName(match.Groups["SeriesName"].Value); - 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(episode)) - { - failureReason = "Unable to parse episode from filename"; - return false; - } - - failureReason = null; - metadata = new TVFileMetadata - { - SeriesName = seriesName, - Season = string.IsNullOrWhiteSpace(season) ? 1 : int.Parse(season), - Episode = int.Parse(episode) - }; - - return true; - } - - if (TryParseAbsoluteEpisodeInfo(fileName, out metadata, out failureReason)) - { - return true; - } - - if (!string.IsNullOrEmpty(failureReason)) - { - return false; - } - - failureReason = "Unable to parse required information from filename"; - return false; - } - - private static bool TryParseAbsoluteEpisodeInfo(string fileName, - [NotNullWhen(true)] out TVFileMetadata? metadata, - out string? failureReason) - { - metadata = null; - failureReason = null; - - var match = AbsoluteEpisodePattern.Match(Path.GetFileNameWithoutExtension(fileName)); - if (!match.Success) - { - return false; - } - - if (!int.TryParse(match.Groups["AbsoluteEpisode"].Value, out var absoluteEpisode) - || IsLikelyYear(absoluteEpisode)) - { - return false; - } - - var seriesName = NormaliseSeriesName(match.Groups["SeriesName"].Value); - if (string.IsNullOrWhiteSpace(seriesName)) - { - failureReason = "Unable to parse series name from filename"; - return false; - } - - metadata = new TVFileMetadata - { - SeriesName = seriesName, - Season = 0, - Episode = absoluteEpisode, - AbsoluteEpisode = absoluteEpisode - }; - - return true; - } - - private static string NormaliseSeriesName(string seriesName) - { - seriesName = SquareBracketGroupRegex.Replace(seriesName, " "); - seriesName = seriesName.Replace('.', ' ').Replace('_', ' '); - - foreach (var pattern in LanguageTermPatterns) - { - seriesName = Regex.Replace(seriesName, pattern, " ", RegexOptions); - } - - return MultiSpaceRegex.Replace(seriesName, " ").Trim(' ', '.', '-', '_'); - } - - private static bool IsLikelyYear(int value) => value is >= 1900 and <= 2099; - - private static readonly string[] LanguageTermPatterns = - [ - @"\bDublado\b", - @"\bLegendado\b", - @"\bDubbed\b", - @"\bSubbed\b", - @"\bSubtitles?\b", - @"\bSubtitled\b", - @"\bPT-BR\b", - @"\bENG\b" - ]; -} 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 1cb64fb..b3450ae 100644 --- a/AutoTag.Core/TV/TVFileMetadata.cs +++ b/AutoTag.Core/TV/TVFileMetadata.cs @@ -1,26 +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; } + 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? AbsoluteEpisode { get; set; } - - public int SeasonEpisodes { get; set; } + public int? Part { 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 ((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); @@ -35,19 +46,19 @@ 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); @@ -55,21 +66,23 @@ public override void WriteToFile(TagLib.File file, AutoTagConfig config, IUserIn if (Season >= byte.MinValue && Season <= 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) { // 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) @@ -77,44 +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 (AbsoluteEpisode.HasValue && Season == 0) - { - if (!string.IsNullOrEmpty(Title)) - { - return $"{SeriesName} E{AbsoluteEpisode.Value:000} ({Title})"; - } - else - { - return $"{SeriesName} E{AbsoluteEpisode.Value:000}"; - } - } - - 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 29c11f5..cee1853 100644 --- a/AutoTag.Core/TV/TVProcessor.cs +++ b/AutoTag.Core/TV/TVProcessor.cs @@ -1,26 +1,27 @@ -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 { - private static readonly Regex SeriesYearSuffixRegex = new(@"\s+\((19|20)\d{2}\)$", RegexOptions.CultureInvariant); + 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 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: @@ -30,34 +31,41 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) 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 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 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) { @@ -66,79 +74,30 @@ public async Task<ProcessResult> 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 fullPath = Path.GetFullPath(file.Path); - var match = Regex.Match(fullPath, config.ParsePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (!match.Success && fullPath.Contains(Path.DirectorySeparatorChar)) - { - var normalisedPath = fullPath.Replace(Path.DirectorySeparatorChar, '/'); - match = Regex.Match(normalisedPath, 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); - if (searchResults.Results.Count == 0) - { - var normalisedSeriesName = NormaliseSeriesSearchName(seriesName); - if (normalisedSeriesName != seriesName) - { - searchResults = await tmdb.SearchTvShowAsync(normalisedSeriesName); - } - } 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; @@ -147,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() ); @@ -155,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 @@ -167,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) @@ -181,14 +143,14 @@ 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); @@ -204,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 @@ -217,7 +179,7 @@ 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); if (groupInfo is null) { @@ -225,148 +187,162 @@ public async Task<FindResult> FindShowAsync(string seriesName) 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; - metadata.Id = showData.Id; - metadata.SeriesName = showData.Name; - + // 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 resolvedAbsoluteEpisode = false; + var lookupSeason = parsedDetails.Season; + var lookupEpisode = parsedDetails.Episode; - if (metadata.AbsoluteEpisode.HasValue) + if (show.HasEpisodeGroupMapping) { - var resolvedEpisode = await TryResolveAbsoluteEpisodeAsync(showData.Id, metadata.AbsoluteEpisode.Value); - if (!resolvedEpisode.HasValue) + if (!parsedDetails.Season.HasValue) { - return (FindResult.Skip, - $"Error: Cannot map absolute episode {metadata.AbsoluteEpisode.Value} for {metadata.SeriesName} on TheMovieDB"); + ui.SetStatus("Error: Cannot apply episode group numbering to absolute episode numbers", + MessageType.Error); + return (FindResult.Fail, null, null); } - metadata.Season = resolvedEpisode.Value.Season; - metadata.Episode = resolvedEpisode.Value.Episode; - - lookupSeason = resolvedEpisode.Value.Season; - lookupEpisode = resolvedEpisode.Value.Episode; - resolvedAbsoluteEpisode = true; - } - - if (show.HasEpisodeGroupMapping && !resolvedAbsoluteEpisode) - { - if (show.TryGetMapping(metadata.Season, metadata.Episode, out var groupNumbering)) + 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); } } - var seasonResult = await GetSeasonAsync(showData.Id, lookupSeason); - - if (seasonResult == null || - !seasonResult.Episodes.TryFind(e => e.EpisodeNumber == lookupEpisode, out var episodeResult)) + var result = await GetEpisodeAsync(showData.Id, lookupSeason, lookupEpisode); + if (result is null) { - return (FindResult.Skip, $"Error: Cannot find {metadata} on TheMovieDB"); + return (FindResult.Skip, null, $"Error: Cannot find {parsedDetails} on TheMovieDB"); } - - metadata.SeasonEpisodes = seasonResult.Episodes.Count; - if (!string.IsNullOrEmpty(seasonResult.PosterPath)) + var metadata = new TVFileMetadata { - metadata.CoverURL = $"https://image.tmdb.org/t/p/original/{seasonResult.PosterPath}"; - } - - metadata.Title = episodeResult.Name; - metadata.Overview = episodeResult.Overview; - - metadata.Genres = await tmdb.GetTvGenreNamesAsync(show.TvSearchResult.GenreIds); + Id = showData.Id, + SeriesName = showData.Name, + Year = parsedDetails.Year, + Season = parsedDetails.Season ?? result.Value.Season.SeasonNumber, + Episode = parsedDetails.Season.HasValue + ? parsedDetails.Episode + : 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 (config.ExtendedTagging && fileIsTaggable) { - metadata.Director = episodeResult.Crew.Find(c => c.Job == "Director")?.Name; + metadata.Director = result.Value.Episode.Crew.Find(c => c.Job == "Director")?.Name; - var credits = await tmdb.GetTvEpisodeCreditsAsync(showData.Id, lookupSeason, lookupEpisode); + var credits = await tmdb.GetTvEpisodeCreditsAsync(showData.Id, result.Value.Season.SeasonNumber, + 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, null); + return (FindResult.Success, metadata, null); } private async Task<TvSeason?> GetSeasonAsync(int showId, int seasonNumber) { - if (!cache.TryGetSeason(showId, seasonNumber, out var seasonResult)) + if (cache.TryGetSeason(showId, seasonNumber, out var seasonResult)) { - seasonResult = await tmdb.GetTvSeasonAsync(showId, seasonNumber); + return seasonResult; + } - if (seasonResult != null) - { - cache.AddSeason(showId, seasonNumber, seasonResult); - } + seasonResult = await tmdb.GetTvSeasonAsync(showId, seasonNumber); + if (seasonResult != null) + { + cache.AddSeason(showId, seasonNumber, seasonResult); } return seasonResult; } - private async Task<(int Season, int Episode)?> TryResolveAbsoluteEpisodeAsync(int showId, int absoluteEpisode) + private async Task<(TvSeason Season, TvSeasonEpisode Episode)?> GetEpisodeAsync(int showId, int? seasonNumber, + int episodeNumber) { - var remainingEpisode = absoluteEpisode; - - for (int seasonNumber = 1; seasonNumber <= 100; seasonNumber++) + if (seasonNumber.HasValue) { - var seasonResult = await GetSeasonAsync(showId, seasonNumber); - if (seasonResult == null || seasonResult.Episodes.Count == 0) + var season = await GetSeasonAsync(showId, seasonNumber.Value); + + if (season != null && season.Episodes.TryFind(e => e.EpisodeNumber == episodeNumber, out var episode)) { - break; + return (season, episode); } - if (remainingEpisode <= seasonResult.Episodes.Count) + return null; + } + + var mapping = await GetEpisodeNumberMapping(showId); + return mapping.GetByEpisodeNumber(episodeNumber); + } + + 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++) + { + var seasonResult = await GetSeasonAsync(showId, season); + + if (seasonResult is not null) { - return (seasonNumber, remainingEpisode); + seasons.Add(seasonResult); } - - remainingEpisode -= seasonResult.Episodes.Count; } - return null; + var newMapping = new EpisodeNumberMapping(seasons); + _episodeNumberMappings[showId] = newMapping; + + return newMapping; } public async Task FindPosterAsync(TVFileMetadata metadata) @@ -393,17 +369,14 @@ 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; } - - internal static string NormaliseSeriesSearchName(string seriesName) - => SeriesYearSuffixRegex.Replace(seriesName.Trim(), ""); -} +} \ No newline at end of file diff --git a/README.md b/README.md index 67338f9..d31a456 100644 --- a/README.md +++ b/README.md @@ -2,152 +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 +- Supports tagging mp4 and mkv containers - Subtitle file renaming for .srt, .vtt, .sub, .ssa and .ass files -## Requirements and running locally -To run AutoTag from source, install the .NET 10 SDK and run commands from the repository root. - -AutoTag fetches metadata from TheMovieDB, so a TMDB API key is required before processing files. Set it with the `TMDB_API_KEY` environment variable: +## Usage -PowerShell: -```powershell -$env:TMDB_API_KEY="your_tmdb_api_key" ``` +USAGE: + autotag [paths] [OPTIONS] -Linux/macOS: -```sh -export TMDB_API_KEY="your_tmdb_api_key" -``` +ARGUMENTS: + [paths] Files or directories to process -Check that the CLI starts: -```sh -dotnet run --project AutoTag.CLI -- --help +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 + -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 ``` -Process TV episodes: -```sh -dotnet run --project AutoTag.CLI -- -t "path/to/tv/files" -``` +## Parsing -Include adult titles in TMDB searches: -```sh -dotnet run --project AutoTag.CLI -- -t --include-adult "path/to/tv/files" -``` +AutoTag should be able to parse most common naming schemes for TV and movie files. -Move files into TV season folders or movie folders: -```sh -dotnet run --project AutoTag.CLI -- -t --organize-folders "path/to/tv/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. -Remove source folders after moving files if they are empty: -```sh -dotnet run --project AutoTag.CLI -- -t --organize-folders --remove-empty-folders "path/to/tv/files" -``` +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. -Rename and move subtitle files with matching videos: -```sh -dotnet run --project AutoTag.CLI -- -t --rename-subs --organize-folders "path/to/tv/files" -``` +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). -Process movies: -```sh -dotnet run --project AutoTag.CLI -- -m "path/to/movie/files" -``` +### Custom Parsing Regex -## Usage -``` -USAGE: - autotag [paths] [OPTIONS] +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. -ARGUMENTS: - [paths] Files or directories to process +The custom regex pattern is used on the full file path, not just the file name. -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 - --include-adult Include adult titles in TMDB searches - --organize-folders Move files into media folders after tagging - --remove-empty-folders Remove source folders after moving files if they are empty +The regex pattern should have the following named capturing groups: -``` +- `SeriesName` +- `Season` and `Episode` OR `AbsoluteEpisode` +- `Year` (optional) +- `EndEpisode` (optional) +- `Part` (optional) -### 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: +For the example given above, a pattern could be `.*/(?<SeriesName>.+)/Season (?<Season>\d+)/S\d+E(?<Episode>\d+)`. -- `%1`: TV Series Name/Movie Title -- `%2`: TV Season Number/Movie Year -- `%3`: TV Episode Number -- `%4`: TV Episode Title +Note that on Windows all directory separators (`\`) must be escaped as `\\`. -#### 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 `#`. +## Renaming -Example: to get the name "Series S01E01 Title.mkv", use the format `%1 S%2:00E%3:00 %4`. +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. -### 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`. +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. -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+)`. +### Rename Fields -Note that on Windows all directory separators (`\`) must be escaped as `\\`. +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. -Movie searching can optionally use additional fallback languages via the `searchLanguages` config setting. For example, with `"language": "pt-BR"` and `"searchLanguages": ["en-US"]`, AutoTag will still write metadata in Brazilian Portuguese but will retry movie searches in English if the Portuguese search fails. +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 | ✅ | @@ -156,10 +225,14 @@ 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": 14, // Internal use -"mode": 0, // Default tagging mode, 0 = TV, 1 = Movie +"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 @@ -181,25 +254,52 @@ AutoTag creates a config file to store default preferences at `~/.config/autotag "fileNameReplaces": [] // File name character replacements. Array of objects of the form { "replace": "", "replacement": "" } ``` -When `renameSubtitles` is enabled, AutoTag renames supported subtitle files along with matching videos. If multiple loose subtitle files match the same TV episode, AutoTag keeps them all and adds numbered suffixes such as `.1.ass` and `.2.ass`. - -## 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" +``` + +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 should be implemented for any new features. Tests can be executed by running `dotnet test`. + +Note: unit tests should avoid side effects (e.g. writing files to disk) and should work cross-platform. + ## 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 From f07b4bfbbf2fc863e5c77db37f31f0bcbc9d49fe Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Mon, 18 May 2026 22:04:07 +0100 Subject: [PATCH 23/35] fix warning --- AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs index 4b3cff5..e1bb927 100644 --- a/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs +++ b/AutoTag.CLI/Settings/RootCommandSettings.Tagging.cs @@ -42,7 +42,7 @@ public partial class RootCommandSettings [CommandOption("--search-language <language>")] [Description("Additional languages to use when searching TMDB")] - public string[] SearchLanguages { get; init; } + public string[]? SearchLanguages { get; init; } [CommandOption("-g|--episode-group")] [Description("Manually choose alternate episode orderings for a TV show")] @@ -103,7 +103,7 @@ private void SetTaggingOptions(AutoTagConfig config) config.Language = Language; } - if (SearchLanguages.Length > 0) + if (SearchLanguages?.Length > 0) { config.SearchLanguages = SearchLanguages.ToList(); } From f13cfee9110d7eafce4d7a2942366e5c55f5d8bf Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sun, 24 May 2026 21:25:22 +0100 Subject: [PATCH 24/35] test: add CLI integration tests --- .github/workflows/build.yml | 24 -------- .github/workflows/test.yml | 48 +++++++++++++++ .gitignore | 2 + AutoTag.CLI.Test/AutoTag.CLI.Test.csproj | 23 ++++++++ AutoTag.CLI.Test/ConfigTests.cs | 17 ++++++ AutoTag.CLI.Test/Helpers/CLIFixture.cs | 69 ++++++++++++++++++++++ AutoTag.Core.Test/AutoTag.Core.Test.csproj | 18 +++--- autotag.sln | 14 +++++ 8 files changed, 182 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/test.yml create mode 100644 AutoTag.CLI.Test/AutoTag.CLI.Test.csproj create mode 100644 AutoTag.CLI.Test/ConfigTests.cs create mode 100644 AutoTag.CLI.Test/Helpers/CLIFixture.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index eb66ae8..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: build - -on: [push, pull_request] - -jobs: - build: - 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 --no-build --verbosity normal \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0b216ae --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +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: + 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 + 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..da99a8d --- /dev/null +++ b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="CliWrap" Version="3.10.1"/> + <PackageReference Include="coverlet.collector" Version="6.0.4"/> + <PackageReference Include="FluentAssertions" Version="8.10.0"/> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/> + <PackageReference Include="xunit.v3" Version="3.2.2"/> + </ItemGroup> + + <ItemGroup> + <Using Include="Xunit"/> + </ItemGroup> + +</Project> \ 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..afdf21f --- /dev/null +++ b/AutoTag.CLI.Test/ConfigTests.cs @@ -0,0 +1,17 @@ +using AutoTag.CLI.Test.Helpers; +using FluentAssertions; + +namespace AutoTag.CLI.Test; + +public class ConfigTests(CLIFixture cli) +{ + [Fact] + public async Task Should_CreateConfigFile_WhenDoesntExist() + { + var configPath = Path.Combine(".", "test-config.json"); + + await cli.ExecuteAsync(c => c.WithArguments(["-c", configPath, "--print-config"])); + + File.Exists(configPath).Should().BeTrue(); + } +} \ 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..33a7dd8 --- /dev/null +++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs @@ -0,0 +1,69 @@ +using System.Text; +using AutoTag.CLI.Test.Helpers; +using CliWrap; + +[assembly: AssemblyFixture(typeof(CLIFixture))] +[assembly: CaptureConsole] + +namespace AutoTag.CLI.Test.Helpers; + +public class CLIFixture : IAsyncLifetime +{ + private string _cliPublishOutput = null!; + + public async ValueTask InitializeAsync() + { + var wd = Directory.GetCurrentDirectory(); + + string? outputPath = null; + var build = await Cli.Wrap("dotnet") + .WithWorkingDirectory(Path.Combine("..", "..", "..", "..")) + .WithArguments(["publish", "AutoTag.CLI", "-c", "Release"]) + .WithStandardOutputPipe(PipeTarget.ToDelegate(l => + { + Console.WriteLine(l); + if (l.Contains("AutoTag.CLI ->")) + { + outputPath = Path.Combine( + l.Split("->", 2, StringSplitOptions.TrimEntries)[1], + $"autotag{(OperatingSystem.IsWindows() ? ".exe" : "")}" + ); + } + })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(Console.WriteLine)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + if (!build.IsSuccess || string.IsNullOrEmpty(outputPath)) + { + throw new Exception("CLI build failed"); + } + + _cliPublishOutput = outputPath; + } + + public ValueTask DisposeAsync() + { + _cliPublishOutput = ""; + + return ValueTask.CompletedTask; + } + + public async Task<(string, int)> ExecuteAsync(Func<Command, Command> configure) + { + if (string.IsNullOrEmpty(_cliPublishOutput)) + { + throw new InvalidOperationException("CLI publish output not set"); + } + + var stdout = new StringBuilder(); + + var cmd = configure(Cli.Wrap(_cliPublishOutput) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdout)) + .WithValidation(CommandResultValidation.None)); + + var result = await cmd.ExecuteAsync(); + + return (stdout.ToString(), result.ExitCode); + } +} \ 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 c25eef0..657e4d1 100644 --- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj +++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj @@ -10,17 +10,17 @@ <ItemGroup> <PackageReference Include="coverlet.collector" Version="6.0.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="FluentAssertions" Version="8.8.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> - <PackageReference Include="Moq" Version="4.20.72" /> - <PackageReference Include="xunit" Version="2.9.3" /> + <PackageReference Include="FluentAssertions" Version="8.8.0"/> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/> + <PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> + <PackageReference Include="xunit.v3" Version="3.2.2"/> </ItemGroup> <PropertyGroup Condition="'$(Configuration)' == 'Release'"> @@ -33,6 +33,6 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj" /> + <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj"/> </ItemGroup> </Project> 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 From c316463aee570c2ba64c88dd56d7c4ae87362459 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sun, 24 May 2026 21:27:29 +0100 Subject: [PATCH 25/35] fix: set TMDB_API_KEY for integration tests --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b216ae..12afc9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,4 +45,6 @@ jobs: 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 From 71f3ce0aac3a8ae2d59655cfbfc88da5714951b3 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Wed, 27 May 2026 22:12:58 +0100 Subject: [PATCH 26/35] fix: file name replacement config deserialisation test: add test for setting default config refactor: switch to AwesomeAssertions --- AutoTag.CLI.Test/AutoTag.CLI.Test.csproj | 7 ++- AutoTag.CLI.Test/ConfigTests.cs | 68 ++++++++++++++++++++-- AutoTag.CLI.Test/GlobalUsing.cs | 2 + AutoTag.CLI.Test/Helpers/CLIFixture.cs | 26 ++++++--- AutoTag.CLI.Test/Helpers/CLITestBase.cs | 13 +++++ AutoTag.CLI.Test/xunit.runner.json | 4 ++ AutoTag.CLI/RootCommand.cs | 29 ++++----- AutoTag.Core.Test/AutoTag.Core.Test.csproj | 2 +- AutoTag.Core.Test/GlobalUsing.cs | 2 +- AutoTag.Core/Files/FileNameReplace.cs | 10 ++++ README.md | 25 +++++++- 11 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 AutoTag.CLI.Test/GlobalUsing.cs create mode 100644 AutoTag.CLI.Test/Helpers/CLITestBase.cs create mode 100644 AutoTag.CLI.Test/xunit.runner.json diff --git a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj index da99a8d..bc14bd3 100644 --- a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj +++ b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj @@ -8,16 +8,19 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AwesomeAssertions" Version="9.4.0"/> <PackageReference Include="CliWrap" Version="3.10.1"/> <PackageReference Include="coverlet.collector" Version="6.0.4"/> - <PackageReference Include="FluentAssertions" Version="8.10.0"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/> <PackageReference Include="xunit.v3" Version="3.2.2"/> </ItemGroup> <ItemGroup> - <Using Include="Xunit"/> + <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj"/> + </ItemGroup> </Project> \ No newline at end of file diff --git a/AutoTag.CLI.Test/ConfigTests.cs b/AutoTag.CLI.Test/ConfigTests.cs index afdf21f..6e74ebb 100644 --- a/AutoTag.CLI.Test/ConfigTests.cs +++ b/AutoTag.CLI.Test/ConfigTests.cs @@ -1,17 +1,75 @@ +using System.Text.Json; using AutoTag.CLI.Test.Helpers; -using FluentAssertions; +using AutoTag.Core.Config; +using AutoTag.Core.Files; +using AwesomeAssertions.Execution; namespace AutoTag.CLI.Test; -public class ConfigTests(CLIFixture cli) +public class ConfigTests(CLIFixture cli, ITestContextAccessor context) : CLITestBase { [Fact] public async Task Should_CreateConfigFile_WhenDoesntExist() { - var configPath = Path.Combine(".", "test-config.json"); + await cli.ExecuteAsync("--print-config"); - await cli.ExecuteAsync(c => c.WithArguments(["-c", configPath, "--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<AutoTagConfig>( + await File.ReadAllTextAsync(ConfigPath, context.Current.CancellationToken), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + )!; - File.Exists(configPath).Should().BeTrue(); + 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(); + } } } \ 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 index 33a7dd8..90f1899 100644 --- a/AutoTag.CLI.Test/Helpers/CLIFixture.cs +++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs @@ -3,17 +3,17 @@ using CliWrap; [assembly: AssemblyFixture(typeof(CLIFixture))] -[assembly: CaptureConsole] +[assembly: CaptureConsole(CaptureOut = true, CaptureError = true)] namespace AutoTag.CLI.Test.Helpers; -public class CLIFixture : IAsyncLifetime +public class CLIFixture(ITestContextAccessor context) : IAsyncLifetime { private string _cliPublishOutput = null!; public async ValueTask InitializeAsync() { - var wd = Directory.GetCurrentDirectory(); + var stdout = new StringBuilder(); string? outputPath = null; var build = await Cli.Wrap("dotnet") @@ -21,7 +21,8 @@ public async ValueTask InitializeAsync() .WithArguments(["publish", "AutoTag.CLI", "-c", "Release"]) .WithStandardOutputPipe(PipeTarget.ToDelegate(l => { - Console.WriteLine(l); + stdout.AppendLine(l); + if (l.Contains("AutoTag.CLI ->")) { outputPath = Path.Combine( @@ -30,12 +31,14 @@ public async ValueTask InitializeAsync() ); } })) - .WithStandardErrorPipe(PipeTarget.ToDelegate(Console.WriteLine)) + .WithStandardErrorPipe(PipeTarget.ToDelegate(context.Current.SendDiagnosticMessage)) .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"); } @@ -49,18 +52,23 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - public async Task<(string, int)> ExecuteAsync(Func<Command, Command> configure) + public async Task<(string, int)> ExecuteAsync(params string[] arguments) { if (string.IsNullOrEmpty(_cliPublishOutput)) { throw new InvalidOperationException("CLI publish output not set"); } - var stdout = new StringBuilder(); + if (context.Current.TestClassInstance is not CLITestBase cliTest) + { + throw new InvalidOperationException($"Test class must derive from {nameof(CLITestBase)}"); + } - var cmd = configure(Cli.Wrap(_cliPublishOutput) + var stdout = new StringBuilder(); + var cmd = Cli.Wrap(_cliPublishOutput) + .WithArguments([..arguments, "-c", cliTest.ConfigPath]) .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdout)) - .WithValidation(CommandResultValidation.None)); + .WithValidation(CommandResultValidation.None); var result = await cmd.ExecuteAsync(); diff --git a/AutoTag.CLI.Test/Helpers/CLITestBase.cs b/AutoTag.CLI.Test/Helpers/CLITestBase.cs new file mode 100644 index 0000000..132213c --- /dev/null +++ b/AutoTag.CLI.Test/Helpers/CLITestBase.cs @@ -0,0 +1,13 @@ +namespace AutoTag.CLI.Test.Helpers; + +public abstract class CLITestBase : IDisposable +{ + protected CLITestBase() + { + ConfigPath = Path.Combine(".", $"test-config.{Random.Shared.Next()}.json"); + } + + public string ConfigPath { get; } + + public void Dispose() => File.Delete(ConfigPath); +} \ No newline at end of file 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/RootCommand.cs b/AutoTag.CLI/RootCommand.cs index 1dc3782..bf009ea 100644 --- a/AutoTag.CLI/RootCommand.cs +++ b/AutoTag.CLI/RootCommand.cs @@ -9,14 +9,20 @@ namespace AutoTag.CLI; public class RootCommand : AsyncCommand<RootCommandSettings> { - public override async Task<int> ExecuteAsync(CommandContext context, RootCommandSettings cmdSettings, CancellationToken cancellationToken) + private static readonly JsonSerializerOptions PrintJsonOptions = new() + { + WriteIndented = true + }; + + public override async Task<int> ExecuteAsync(CommandContext context, RootCommandSettings cmdSettings, + CancellationToken cancellationToken) { if (cmdSettings.PrintVersion) { AnsiConsole.WriteLine(CLIInterface.GetVersion()); return 0; } - + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { DisableDefaults = true }); builder.Services.AddCoreServices(ThisAssembly.Constants.TMDBApiKey); builder.Services.AddScoped<IUserInterface, CLIInterface>(); @@ -25,26 +31,21 @@ public override async Task<int> ExecuteAsync(CommandContext context, RootCommand var configService = host.Services.GetRequiredService<IAutoTagConfigService>(); 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(); + AnsiConsole.Write(new JsonText(JsonSerializer.Serialize(config, PrintJsonOptions))); + return 0; } var ui = (CLIInterface)host.Services.GetRequiredService<IUserInterface>(); return await ui.RunAsync(cmdSettings.Paths); } - - private static readonly JsonSerializerOptions PrintJsonOptions = new() - { - WriteIndented = true - }; -} +} \ 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 657e4d1..a446558 100644 --- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj +++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj @@ -9,11 +9,11 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AwesomeAssertions" Version="9.4.0"/> <PackageReference Include="coverlet.collector" Version="6.0.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="FluentAssertions" Version="8.8.0"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/> <PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> 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/Files/FileNameReplace.cs b/AutoTag.Core/Files/FileNameReplace.cs index 91dad37..c975302 100644 --- a/AutoTag.Core/Files/FileNameReplace.cs +++ b/AutoTag.Core/Files/FileNameReplace.cs @@ -1,12 +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<FileNameReplace> FromDictionary(IDictionary<string, string> 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/README.md b/README.md index d31a456..5b3b2e1 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,8 @@ Linux/macOS: export TMDB_API_KEY="your_tmdb_api_key" ``` +You should also set the environment variable within your IDE to allow debugging. + 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. @@ -294,9 +296,28 @@ To run the CLI run `dotnet run --project AutoTag.CLI -- [arguments]`. ### Testing -Unit tests should be implemented for any new features. Tests can be executed by running `dotnet test`. +#### 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: -Note: unit tests should avoid side effects (e.g. writing files to disk) and should work cross-platform. +- 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 From b5c0d8f89aba2e879ee5885f260db0f59d80a877 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Fri, 29 May 2026 21:46:58 +0100 Subject: [PATCH 27/35] test: add integration test for processing files --- AutoTag.CLI.Test/AutoTag.CLI.Test.csproj | 2 + AutoTag.CLI.Test/ConfigTests.cs | 22 +++ AutoTag.CLI.Test/Helpers/CLIFixture.cs | 38 ++++- AutoTag.CLI.Test/Helpers/CLITestBase.cs | 12 +- AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs | 33 ++++ AutoTag.CLI.Test/ProcessTests.cs | 154 ++++++++++++++++++ AutoTag.CLI.Test/TestFiles/test.mkv | Bin 0 -> 4200 bytes AutoTag.CLI.Test/TestFiles/test.mp4 | Bin 0 -> 5194 bytes AutoTag.CLI/AutoTag.CLI.csproj | 80 ++++----- AutoTag.CLI/CLIInterface.cs | 35 ++-- AutoTag.CLI/RootCommand.cs | 9 +- 11 files changed, 311 insertions(+), 74 deletions(-) create mode 100644 AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs create mode 100644 AutoTag.CLI.Test/ProcessTests.cs create mode 100644 AutoTag.CLI.Test/TestFiles/test.mkv create mode 100644 AutoTag.CLI.Test/TestFiles/test.mp4 diff --git a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj index bc14bd3..3ea01fd 100644 --- a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj +++ b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj @@ -12,6 +12,7 @@ <PackageReference Include="CliWrap" Version="3.10.1"/> <PackageReference Include="coverlet.collector" Version="6.0.4"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> + <PackageReference Include="Spectre.Console.Cli.Testing" Version="0.55.0"/> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/> <PackageReference Include="xunit.v3" Version="3.2.2"/> </ItemGroup> @@ -21,6 +22,7 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\AutoTag.CLI\AutoTag.CLI.csproj"/> <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj"/> </ItemGroup> </Project> \ No newline at end of file diff --git a/AutoTag.CLI.Test/ConfigTests.cs b/AutoTag.CLI.Test/ConfigTests.cs index 6e74ebb..1458889 100644 --- a/AutoTag.CLI.Test/ConfigTests.cs +++ b/AutoTag.CLI.Test/ConfigTests.cs @@ -72,4 +72,26 @@ await File.ReadAllTextAsync(ConfigPath, context.Current.CancellationToken), 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<AutoTagConfig>(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/Helpers/CLIFixture.cs b/AutoTag.CLI.Test/Helpers/CLIFixture.cs index 90f1899..c383b5a 100644 --- a/AutoTag.CLI.Test/Helpers/CLIFixture.cs +++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs @@ -1,6 +1,9 @@ +using System.Diagnostics; using System.Text; 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)] @@ -13,6 +16,11 @@ public class CLIFixture(ITestContextAccessor context) : IAsyncLifetime public async ValueTask InitializeAsync() { + if (Debugger.IsAttached) + { + return; + } + var stdout = new StringBuilder(); string? outputPath = null; @@ -54,24 +62,36 @@ public ValueTask DisposeAsync() public async Task<(string, int)> ExecuteAsync(params string[] arguments) { - if (string.IsNullOrEmpty(_cliPublishOutput)) + if (context.Current.TestClassInstance is not CLITestBase cliTest) { - throw new InvalidOperationException("CLI publish output not set"); + throw new InvalidOperationException($"Test class must derive from {nameof(CLITestBase)}"); } - if (context.Current.TestClassInstance is not CLITestBase cliTest) + string[] args = [..arguments, "-c", cliTest.ConfigPath]; + + if (Debugger.IsAttached) { - throw new InvalidOperationException($"Test class must derive from {nameof(CLITestBase)}"); + context.Current.SendDiagnosticMessage("Debugger detected, running CLI in-process"); + + var app = new CommandAppTester(); + app.SetDefaultCommand<RootCommand>(); + + var appResult = await app.RunAsync(args); + + return (appResult.Output, appResult.ExitCode); + } + + if (string.IsNullOrEmpty(_cliPublishOutput)) + { + throw new InvalidOperationException("CLI publish output not set"); } - var stdout = new StringBuilder(); var cmd = Cli.Wrap(_cliPublishOutput) - .WithArguments([..arguments, "-c", cliTest.ConfigPath]) - .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdout)) + .WithArguments(args) .WithValidation(CommandResultValidation.None); - var result = await cmd.ExecuteAsync(); + var result = await cmd.ExecuteBufferedAsync(); - return (stdout.ToString(), result.ExitCode); + return (result.StandardOutput, result.ExitCode); } } \ No newline at end of file diff --git a/AutoTag.CLI.Test/Helpers/CLITestBase.cs b/AutoTag.CLI.Test/Helpers/CLITestBase.cs index 132213c..dda7ad5 100644 --- a/AutoTag.CLI.Test/Helpers/CLITestBase.cs +++ b/AutoTag.CLI.Test/Helpers/CLITestBase.cs @@ -2,12 +2,20 @@ namespace AutoTag.CLI.Test.Helpers; public abstract class CLITestBase : IDisposable { + private readonly string _testDirectory; + protected CLITestBase() { - ConfigPath = Path.Combine(".", $"test-config.{Random.Shared.Next()}.json"); + _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; } - public void Dispose() => File.Delete(ConfigPath); + 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..c3c7df8 --- /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 + { + File.Create(filePath); + } + + return this; + } + + public FileSystemBuilder CreateDirectory(string name, Action<FileSystemBuilder> 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..3a08705 --- /dev/null +++ b/AutoTag.CLI.Test/ProcessTests.cs @@ -0,0 +1,154 @@ +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); + } + ); + } + + private void AssertFile(string originalPath, string newPath, Action<FileTags>? 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); + + using (new AssertionScope()) + { + 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 + ); + + 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 0000000000000000000000000000000000000000..bb9fb092bc6d6da5c526a8599aa2c43ac39112fc GIT binary patch literal 4200 zcmb`LeM}qo8OMJ%Z^jrfutaQHw`&?r(G>96yo6GnW*fYbDI^F&lT_8r`FwsUemU-r zVm3|kft$E(l(2M76NtzQ?Mk;OVv6$8Ws^N^lUVyt_E(ar>X<66be&q-YAaJW*z>?S zzB|?b4JGF8^S%4}x!>=DaPl?ZUlta;#}$+JbNKtIQttgsv3RGlaaI<S(kSaa0q8l! z+&q*Hn+Sf{+Z+1l<a{o3pxiOsf9J)aUu1Ude<L(>Rk^%<w)60{Q8@d!|ISIs8V~<2 zJal!+bZYzb>qB=SoU%9nW4nzEZI%9iUvQp3<}y3JyY}w4@BMPR-s|p9pFF@m8|rkn z?rU|@t|K1Zsr2g1p}o7la*BO==gs#o46i6ZrhA{yy02%q#=_@upU-6)FF!JSO`ZCM zdx}Z=L-w7K)5lKPo)2D7$gUd~rpFV(tU`{Te$wAR;QL;?WB<@0hv&(jX~p!|d(#<n z;3=~5lcgz>`&Y`^tYRKs$_&4$n1(-6NMSL|gm71`nS!Kt^RMKqVfcRafyeWO$xcWa z!Iv`X6Yu#E6RC`R_}<&Q^8a05C%C}wsa$6F+?7E|y2$0z;pOwbg%iGmJ%@UIgS{v8 zk1E~c@-K45(ZkDEv=_e&%f!JlIUk30a#FEqAm!?84=-Q#Eo6I#2R(=PA3E53;w75y z#y=gcbm5D9v*+e`|9^6szfYXG@xDS9y#G`PNfTneR#~yR`SW*tAN=NT5BhGM-Fu&U z@YqKmV=dJ}aY>P<oShw%*yeJ%s6bmsC*8q9(ANqVIB*?X+9_B>xH8Cd5cDL4$rvA! zDHlz*wYX?k2f&Cd3*8RK#Kc7Fv(X@*jIoK<q!@PKyR=5+cnq#d3UV}=kh&?34X_;J zq(nZ%v{6Am5KD5SjI$dah+-3LY*OMGI@Q)qr<~3<D$X+zK1E660XW)63DP9o3a@@K z$T;EoyJ>i#;?Wcz^kbtF?(mCjBFr<+PKt|&$vEqWTb-0F^08P{f}^ffSCEt8fE$Z5 zG>pIof0#`0jLW&#>7+ueB>M$vG%DZ~+XZ8SKN$*1yv($?C^;g+9TH3vOD0Fz2%Pu} z%t=YHC<n6?08J&tt@q%f@I#GrL=!SE##p!rcpx?|vXg!;85dX?&N)~jS!AOLsDKMa z7Ox43Y@C;1u>yW!5{{!m2KLI&23Y~0BH#~1Sqa}HD)L*;HNi*25jg-^Qs5K*a8iJD z8wzmiC_f3^jH`oI5B>3I0!}H3;}bkLE;H>kjw!OZq#`dx;C7MoKYU!wMULUb5ET!= zy5eKO3FB&OrKvG|7=~``ghK&~x4yF}rt@(ClFSQCI~5gRlRyaAL+E11VE=J9(OB{X zpUsdbHa9*$m^07$UU{bM%n|Fq-g@<0$KD(qHGTRsLO%QcvCFw1z5RpLwF(O%vEN^R z<WG;j)^+mqz8+=aWrbXV4pQrx-5u-6ZM<=okYs-0j6wzwTRrpM)N`AJOe@ItY%$Y3 zD|>T<+)N`&sf>GOizy3piph<v$uk?-zvzjaKVX^I+%Ep@!cP^lhM~<ff85uw*YU7= z8L8DY|JrsnzMZLAWv_2nr?Gkk8B|||>X@cF57nj_459ih-;QFnDS{NLKPXOZx~;Mg zi`C{dGN@iNRO8fU8bh1sVx6|y4C{&27h8%`n~$o@QLHYbkTrSc+6~n>wfQcFP(7%t zUPKDjW5ub>>nf8p)p=x4oibG8)MWyOP<^&gjaydMhZL%B6sImbr?Oky)wqr2Bgmlo z*M@4Gy4;Q-RR6h9jayc>iWI8<U7WhSOJ!dat1A#;^@^byr!HT@5Vr9fg=(C-Jc|^z zk<nD&SJ`(o)t8XLHtsf5<J1)c7(%tTP>oYp>_H0E8m}u-D${sf=|-k{t(6a_u2{ox z%WI8%70XEVURO4&OyhOs3bGPjSI%H)^tv*FRPS}=ZIx-fu1X^-;dK>_q0#Fq3sSw; zRYz5(@!CQmE8%t3T?~z0S1lsddtJ4zGL6@kJhBpATLcV^UR(N*>b<s{Q<=u=>Jelm zysoxmX!P2$id64)b(hLCURx1i^_O{Fy@a9B>*_30z1P+ERi^RUdI?zxudM?Z8ojpe zL8|xKno^m@>l!z*5?))^Ff@8?T}G<+x~5rW8n0_skd^SdW(Gr}*EJEOdarA4t4!my zEsd;%*ESkMqt`YIQoYx<qbk#QT}vS=;kE59hDNV#i%9ie+tyX4@wzsTtc2IK0)|Gf zYx|Juy{<i{GL6@DBgjg4U1!J8=ymNXQoYx8T`JRfU5^N>zs&2pB@B&T*JY9Fy{@~j zGL6^umynh4x_$sdqu2F&km|jzPpM4fwcU-ZgxB?J7#h8<Uq-6;+TN@(jo0=SWF@?| z&tPcu+8#lw_u772Wg4#=(#T49-9Te#^t!=<RPS}eQI%=DZlsWDyiRUzyq+QC-8B3H zYq*P{(d&jqq<XI#)>Zb<>sh5SkF2ryIV8NfF>fbjVQ6j?Ff(f1*oRcFb>lgeJ=A(u zX&ONW&4rI4M>aRQ+@!V-nw#vH8Le(yMXI;DsY_)AtMT9VujDc#54_epr>`sI)(h}| N3ZG1wcdjYb{{yEJXXXF^ literal 0 HcmV?d00001 diff --git a/AutoTag.CLI.Test/TestFiles/test.mp4 b/AutoTag.CLI.Test/TestFiles/test.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..440b6ffe2da311807ea51927d5b7e9b04876fbf6 GIT binary patch literal 5194 zcmeHLZ)hAv6rao4HpaHJ_Ky{!Of~o^x$N#G*ED4@mk^9nMGCeQ#C2|W?sj{-x0~Hb za#s;5*3u7x;D<^ZxKps!mLh^^DJY51PwgkaSrJhLX(?!{pw(i|_jd2vy`^Bk7H-Jy zd-MMM=FPm>d2=Cz(4we$R^U2>^bn-*C~RJr9dAS?g!C4D&Ix&Ftz$3&HlM6s)oh;m ze){THzg(U<cWm%mdilv;f2%6F5jsS5*XLBujZ#0WC<-lPM|09B1I0`R42GY7ar&8| z5t`pS1(pWaK{4feHJcX&RU|1pq)5sr2u&fp@!{cxg@w$#WpLMKm5l3`hH;dPDI6PY zTu)eTB^akVD==M^sn3gQmKwZZyLwrb$E9(JvI?_n0avAJc3i5;a+W$=HF=c=a|Pgb zP%o%~E4($|Q00sSPVl0RRpo|;h%$I+KC6_tD(9$f`mVz?aFwa>xoukkaAVamLl?m4 zvyLi32+TO>R=BFjgR)GEED)L(lr0Y}B7j*>bBo1*3w21L!t}u-fM{&jEi)53T3aU5 zz_xUVr7e(X#Sh2OEr$t=!>S1G+YF3AUa;qUR?~FX@tA<V4of6_W>o+J41I<+MV~o5 zfW<0kUJZE5P$5?uGnj|7C};(X1vrT1^Kf1Z+$x!(06N#>6|LlYppAqcxR!YhuvKML zYO$JQRiH})omaR%C)5!M!}J+0sm}uw+<jfUJzdoMx~ls{>J(sIaazz(m25_$vp5-5 z%H)9c5ICf)s^)frAQ0SBN2ujNl3+tX4grhJLjEzE<PhxOUHs@_va7lB*9%KMi_`BN zNWZ(U@6U@LKDGb+o^tB<j|lnWx&0q3y>{u9E7w=`5@KKY?6GgQpB#JpogGtefcv`q zi2-|RY31}WLfmB#^yTZ5)wi02you_*RGo>*B|^S96!r82VGnhi@+Zt^KhB%SclRzd zBY>|#yf$AyGQDwdI0kVtf@lGriXadWAkG4W9fMeoATCaC>43P}5yiC*i0cW6D2m4V zPKd@(Jc`C}9Aa@K0TD&f*pq;mjYrW4Iv}bEh*lKG+7O3eVtuK`=N(a;iy&}iHsu$; zOhB}v_^u5REnwsCjwo(*K>V11h@z-J-iC;xsH<^^`qOcU69Wl|D2n=I8zPG0fA>W^ zsjVoEBp|x`0zR`SBi((`JumR=Y;?~HC`9-DdEqu!P8}xX2mH7psrOt*9?mbe>qx1& zQr|$*r3m4kw*?@y%gDaw%Gd;1HxnV;YZZBLtK+)!;A1=UrU5k8(pR8P9VID(e|K6( z2gHf~#2w4KaXoMi&%^bp49b^9IUF+`Gi;&3xXG3n=WgSsCcx=>=s3U)5dhcE?Evoj zVFLujeV`Tdn)8lfG0;%QNX}gW@jv9H3!&eTY1n?q!3%HkZ64`fyw%UJieYoKS!2Mx zt_aNLc_##ohdp3Kx_$#LkLU~y^=s#Rn@0Safj|^&;Fkjtv=f3hpW+W0pxEM=kzfL* zjj6{6TWZU0u+PaEd1pqJWNKRl{KXL|!}qo(oWUdqR#Pd1tZV*69xVYqdpV!KmD)f^ zn&2x<Z%iJ4HAPmN*M7eA#PYv4Z=&-Cj|Co3j02y|aMNuq^2Ya}k8N8Z!UQ0XHm~E4 zGH9D&UYHncE&udQ{f;fzp&XJF(!LgL_pyum5DREmAFW4seWG>1?a|#I_sRFs4`+cM zW-QEEcpkLA|DImZ9c<~4;Q4U_=tg+f$DT)khm-2ItlPi8)?;fTf5|@73*7}9Y*;TO zc-WjF7|{I=4=J!@d%Q?PVfUHmVI95Q2Ce8OFmPJ92-pw*1Vj)7B~UGkP%U>ru~hy8 DqzDGu literal 0 HcmV?d00001 diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index ac4e23e..f40b92c 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -1,46 +1,46 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <AssemblyName>autotag</AssemblyName> - <OutputType>Exe</OutputType> - <TargetFramework>net10.0</TargetFramework> - <Version>4.0.3</Version> - <Nullable>enable</Nullable> - <ImplicitUsings>enable</ImplicitUsings> - <RuntimeIdentifiers>linux-x64;osx-arm64;osx-x64;win-x64</RuntimeIdentifiers> - </PropertyGroup> + <PropertyGroup> + <AssemblyName>autotag</AssemblyName> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0</TargetFramework> + <Version>4.0.3</Version> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <RuntimeIdentifiers>linux-x64;osx-arm64;osx-x64;win-x64</RuntimeIdentifiers> + </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)' == 'Release'"> - <PublishSingleFile>true</PublishSingleFile> - <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> - <SelfContained>true</SelfContained> - <PublishTrimmed>true</PublishTrimmed> - <!-- needed to serialize config. see https://devblogs.microsoft.com/dotnet/system-text-json-in-dotnet-8/#disabling-reflection-defaults --> - <JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> - <TrimMode>partial</TrimMode> - <DebugSymbols>False</DebugSymbols> - <DebugType>None</DebugType> - </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)' == 'Release'"> + <PublishSingleFile>true</PublishSingleFile> + <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> + <SelfContained>true</SelfContained> + <PublishTrimmed>true</PublishTrimmed> + <!-- needed to serialize config. see https://devblogs.microsoft.com/dotnet/system-text-json-in-dotnet-8/#disabling-reflection-defaults --> + <JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> + <TrimMode>partial</TrimMode> + <DebugSymbols>False</DebugSymbols> + <DebugType>None</DebugType> + </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" /> - <PackageReference Include="Spectre.Console" Version="0.54.0" /> - <PackageReference Include="Spectre.Console.Cli" Version="0.53.1" /> - <PackageReference Include="Spectre.Console.Json" Version="0.54.0" /> - <PackageReference Include="ThisAssembly.Constants" Version="2.1.2"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1"/> + <PackageReference Include="Spectre.Console" Version="0.55.2"/> + <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/> + <PackageReference Include="Spectre.Console.Json" Version="0.55.2"/> + <PackageReference Include="ThisAssembly.Constants" Version="2.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj" /> - </ItemGroup> - - <ItemGroup> - <Constant Include="TMDBApiKey" Value="$(TMDB_API_KEY)" /> - </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\AutoTag.Core\AutoTag.Core.csproj"/> + </ItemGroup> - <Target Name="KeyCheck" BeforeTargets="PrepareForBuild" Condition="'$(Configuration)' == 'Release'"> - <Error Condition="$(TMDB_API_KEY) == ''" Text="TMDB API key missing. Set TMDB_API_KEY environment variable before building." /> - </Target> + <ItemGroup> + <Constant Include="TMDBApiKey" Value="$(TMDB_API_KEY)"/> + </ItemGroup> + + <Target Name="KeyCheck" BeforeTargets="PrepareForBuild" Condition="'$(Configuration)' == 'Release'"> + <Error Condition="$(TMDB_API_KEY) == ''" Text="TMDB API key missing. Set TMDB_API_KEY environment variable before building."/> + </Target> </Project> diff --git a/AutoTag.CLI/CLIInterface.cs b/AutoTag.CLI/CLIInterface.cs index 4be4d3b..a653244 100644 --- a/AutoTag.CLI/CLIInterface.cs +++ b/AutoTag.CLI/CLIInterface.cs @@ -5,7 +5,7 @@ namespace AutoTag.CLI; -public class CLIInterface(IServiceProvider serviceProvider) : IUserInterface +public class CLIInterface(IServiceProvider serviceProvider, IAnsiConsole console) : IUserInterface { private AutoTagConfig Config = null!; private TaggingFile CurrentFile = null!; @@ -31,7 +31,7 @@ public void DisplayMessage(string message, MessageType type) colour = Color.Yellow; } - AnsiConsole.Write(new Text($"{message}\n", new Style(colour))); + console.Write(new Text($"{message}\n", new Style(colour))); } public void SetStatus(string status, MessageType type) @@ -69,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<string> options) { - var choice = AnsiConsole.Prompt( + var choice = console.Prompt( new SelectionPrompt<(int?, string)>() .Title($" [yellow]{Markup.Escape(message)}[/]") .PageSize(10) @@ -102,8 +102,8 @@ public async Task<int> RunAsync(IEnumerable<FileSystemInfo> entries) var tvProcessor = serviceProvider.GetRequiredKeyedService<IProcessor>(Mode.TV); var fileFinder = serviceProvider.GetRequiredService<IFileFinder>(); - AnsiConsole.WriteLine($"AutoTag v{GetVersion()}"); - AnsiConsole.MarkupLine("[link]https://jtattersall.net[/]"); + console.WriteLine($"AutoTag v{GetVersion()}"); + console.MarkupLine("[link]https://jtattersall.net[/]"); Files = fileFinder.FindFilesToProcess(entries); @@ -116,7 +116,7 @@ public async Task<int> RunAsync(IEnumerable<FileSystemInfo> entries) foreach (var file in Files) { CurrentFile = file; - AnsiConsole.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]"); + console.MarkupLineInterpolated($"[fuchsia]\n{file.Path}:[/]"); Success &= (await ProcessWithFallbackAsync(file, movieProcessor, tvProcessor)).IsSuccess(); } @@ -130,12 +130,12 @@ private int ReportResults(int fileCount) { if (Warnings == 0) { - AnsiConsole.MarkupLineInterpolated( + console.MarkupLineInterpolated( $"\n\n[green]{(fileCount > 1 ? $"All {fileCount} files" : "File")} successfully processed.[/]"); } else { - AnsiConsole.MarkupLineInterpolated( + console.MarkupLineInterpolated( $"[yellow]\n\n{(fileCount > 1 ? $"All {fileCount} files" : "File")} successfully processed with {Warnings} warning{(Warnings > 1 ? "s" : "")}.[/]"); } @@ -148,36 +148,33 @@ private int ReportResults(int fileCount) { if (Warnings == 0) { - AnsiConsole.MarkupLineInterpolated( + console.MarkupLineInterpolated( $"[green]\n\n{fileCount - failedFiles} file{(fileCount - failedFiles > 1 ? "s" : "")} successfully processed.[/]"); } else { - AnsiConsole.MarkupLineInterpolated( + console.MarkupLineInterpolated( $"[yellow]\n\n{fileCount - failedFiles} file{(fileCount - failedFiles > 1 ? "s" : "")} successfully processed with {Warnings} warning{(Warnings > 1 ? "s" : "")}.[/]"); } - AnsiConsole.MarkupLineInterpolated( + console.MarkupLineInterpolated( $"[maroon]Errors encountered for {failedFiles} file{(failedFiles > 1 ? "s" : "")}:[/]"); } else { - AnsiConsole.MarkupLine("[maroon]\n\nErrors encountered for all files:[/]"); + console.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[/]"); + console.MarkupLineInterpolated($"[magenta]{file.Path}:[/]"); + console.MarkupLineInterpolated($"[red] {file.Status}\n[/]"); } return 1; } - public static string GetVersion() - { - return Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(3)!; - } + public static string GetVersion() => Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(3)!; private async Task<ProcessResult> ProcessWithFallbackAsync(TaggingFile file, IProcessor movieProcessor, IProcessor tvProcessor) diff --git a/AutoTag.CLI/RootCommand.cs b/AutoTag.CLI/RootCommand.cs index bf009ea..62b2dd8 100644 --- a/AutoTag.CLI/RootCommand.cs +++ b/AutoTag.CLI/RootCommand.cs @@ -7,24 +7,25 @@ namespace AutoTag.CLI; -public class RootCommand : AsyncCommand<RootCommandSettings> +public class RootCommand(IAnsiConsole console) : AsyncCommand<RootCommandSettings> { private static readonly JsonSerializerOptions PrintJsonOptions = new() { WriteIndented = true }; - public override async Task<int> ExecuteAsync(CommandContext context, RootCommandSettings cmdSettings, + protected override async Task<int> 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(ThisAssembly.Constants.TMDBApiKey); + builder.Services.AddSingleton(console); builder.Services.AddScoped<IUserInterface, CLIInterface>(); using var host = builder.Build(); @@ -41,7 +42,7 @@ public override async Task<int> ExecuteAsync(CommandContext context, RootCommand if (cmdSettings.PrintConfig) { - AnsiConsole.Write(new JsonText(JsonSerializer.Serialize(config, PrintJsonOptions))); + console.Write(new JsonText(JsonSerializer.Serialize(config, PrintJsonOptions))); return 0; } From 57f65981d26ace09efdd616609f1a5c2221458f3 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sat, 30 May 2026 16:27:08 +0100 Subject: [PATCH 28/35] debug: print stdout for failing test --- AutoTag.CLI.Test/Helpers/CLIFixture.cs | 3 ++- AutoTag.CLI.Test/ProcessTests.cs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/AutoTag.CLI.Test/Helpers/CLIFixture.cs b/AutoTag.CLI.Test/Helpers/CLIFixture.cs index c383b5a..b5a8c6d 100644 --- a/AutoTag.CLI.Test/Helpers/CLIFixture.cs +++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs @@ -88,7 +88,8 @@ public ValueTask DisposeAsync() var cmd = Cli.Wrap(_cliPublishOutput) .WithArguments(args) - .WithValidation(CommandResultValidation.None); + .WithValidation(CommandResultValidation.None) + .WithEnvironmentVariables(env => env.Set("TERM", "dumb")); var result = await cmd.ExecuteBufferedAsync(); diff --git a/AutoTag.CLI.Test/ProcessTests.cs b/AutoTag.CLI.Test/ProcessTests.cs index 3a08705..17c9eb1 100644 --- a/AutoTag.CLI.Test/ProcessTests.cs +++ b/AutoTag.CLI.Test/ProcessTests.cs @@ -29,7 +29,7 @@ public async Task Should_ProcessFilesWithRenamePattern() .CreateFile("Star Wars (1977).mkv") ); - var (_, exitCode) = await cli.ExecuteAsync( + var (stdout, exitCode) = await cli.ExecuteAsync( FileSystem.GetPath("Downloads"), FileSystem.GetPath("Movies", "Interstellar.2014.avi"), FileSystem.GetPath("Movies", "Star Wars (1977).mkv"), @@ -38,6 +38,11 @@ public async Task Should_ProcessFilesWithRenamePattern() "--rename-subs" ); + if (exitCode != 0) + { + TestContext.Current.SendDiagnosticMessage(stdout); + } + exitCode.Should().Be(0); AssertFile( From 1c945d712c862d4ca0776cfb9e79758928c45c14 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sat, 30 May 2026 16:51:53 +0100 Subject: [PATCH 29/35] test: strip ANSI colour codes from stdout --- AutoTag.CLI.Test/Helpers/CLIFixture.cs | 21 ++++++++++++++++----- AutoTag.CLI.Test/ProcessTests.cs | 5 ----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/AutoTag.CLI.Test/Helpers/CLIFixture.cs b/AutoTag.CLI.Test/Helpers/CLIFixture.cs index b5a8c6d..6d73a30 100644 --- a/AutoTag.CLI.Test/Helpers/CLIFixture.cs +++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using System.Text.RegularExpressions; using AutoTag.CLI.Test.Helpers; using CliWrap; using CliWrap.Buffered; @@ -10,10 +11,13 @@ namespace AutoTag.CLI.Test.Helpers; -public class CLIFixture(ITestContextAccessor context) : IAsyncLifetime +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) @@ -78,7 +82,7 @@ public ValueTask DisposeAsync() var appResult = await app.RunAsync(args); - return (appResult.Output, appResult.ExitCode); + return (RemoveAnsiColourCodes(appResult.Output), appResult.ExitCode); } if (string.IsNullOrEmpty(_cliPublishOutput)) @@ -88,11 +92,18 @@ public ValueTask DisposeAsync() var cmd = Cli.Wrap(_cliPublishOutput) .WithArguments(args) - .WithValidation(CommandResultValidation.None) - .WithEnvironmentVariables(env => env.Set("TERM", "dumb")); + .WithValidation(CommandResultValidation.None); var result = await cmd.ExecuteBufferedAsync(); - return (result.StandardOutput, result.ExitCode); + 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/ProcessTests.cs b/AutoTag.CLI.Test/ProcessTests.cs index 17c9eb1..3fb70c0 100644 --- a/AutoTag.CLI.Test/ProcessTests.cs +++ b/AutoTag.CLI.Test/ProcessTests.cs @@ -38,11 +38,6 @@ public async Task Should_ProcessFilesWithRenamePattern() "--rename-subs" ); - if (exitCode != 0) - { - TestContext.Current.SendDiagnosticMessage(stdout); - } - exitCode.Should().Be(0); AssertFile( From ba51bcf400fe956638a6fdd9f6df7004ad8e5e27 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sat, 30 May 2026 16:53:44 +0100 Subject: [PATCH 30/35] ci: disable fail-fast on integration tests --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12afc9b..b891112 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ "ubuntu-latest", "macos-latest", "windows-latest" ] From 4f2d276eb0d1db085cd5ec2a9168de16da6d80eb Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Sat, 30 May 2026 16:58:36 +0100 Subject: [PATCH 31/35] test: dispose FileStream when creating test file --- AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs b/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs index c3c7df8..a10a6dc 100644 --- a/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs +++ b/AutoTag.CLI.Test/Helpers/FileSystemBuilder.cs @@ -12,7 +12,7 @@ public FileSystemBuilder CreateFile(string name) } else { - File.Create(filePath); + using var _ = File.Create(filePath); } return this; From b473f0b91f335160ffe22abdc277b5aeb1162ba2 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:45:51 +0100 Subject: [PATCH 32/35] fix: IsAlreadyNamedCorrectly not handling relative paths --- AutoTag.Core/Files/FileWriter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AutoTag.Core/Files/FileWriter.cs b/AutoTag.Core/Files/FileWriter.cs index 220f4a4..cc4a5a8 100644 --- a/AutoTag.Core/Files/FileWriter.cs +++ b/AutoTag.Core/Files/FileWriter.cs @@ -191,7 +191,8 @@ private string GetFullOutputPath(string path, string newPath) private static bool IsAlreadyNamedCorrectly(TaggingFile taggingFile, string newPath, IEnumerable<(string Path, string NewPath)> subtitlePaths) - => taggingFile.Path == newPath && subtitlePaths.All(p => p.Path == p.NewPath); + => 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 From 99c99e341c08085942cb512add0927207f77a306 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:46:12 +0100 Subject: [PATCH 33/35] test: add integration test for absolute rename pattern --- AutoTag.CLI.Test/Helpers/CLIFixture.cs | 1 + AutoTag.CLI.Test/ProcessTests.cs | 208 +++++++++++++++++++++++-- AutoTag.CLI/AutoTag.CLI.csproj | 2 +- README.md | 4 +- 4 files changed, 199 insertions(+), 16 deletions(-) diff --git a/AutoTag.CLI.Test/Helpers/CLIFixture.cs b/AutoTag.CLI.Test/Helpers/CLIFixture.cs index 6d73a30..a1022fe 100644 --- a/AutoTag.CLI.Test/Helpers/CLIFixture.cs +++ b/AutoTag.CLI.Test/Helpers/CLIFixture.cs @@ -44,6 +44,7 @@ public async ValueTask InitializeAsync() } })) .WithStandardErrorPipe(PipeTarget.ToDelegate(context.Current.SendDiagnosticMessage)) + .WithEnvironmentVariables(b => b.Set("TMDB_API_KEY", Environment.GetEnvironmentVariable("TMDB_API_KEY"))) .WithValidation(CommandResultValidation.None) .ExecuteAsync(); diff --git a/AutoTag.CLI.Test/ProcessTests.cs b/AutoTag.CLI.Test/ProcessTests.cs index 3fb70c0..d8b2dbe 100644 --- a/AutoTag.CLI.Test/ProcessTests.cs +++ b/AutoTag.CLI.Test/ProcessTests.cs @@ -29,7 +29,7 @@ public async Task Should_ProcessFilesWithRenamePattern() .CreateFile("Star Wars (1977).mkv") ); - var (stdout, exitCode) = await cli.ExecuteAsync( + var (_, exitCode) = await cli.ExecuteAsync( FileSystem.GetPath("Downloads"), FileSystem.GetPath("Movies", "Interstellar.2014.avi"), FileSystem.GetPath("Movies", "Star Wars (1977).mkv"), @@ -108,6 +108,186 @@ public async Task Should_ProcessFilesWithRenamePattern() ); } + [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 void AssertFile(string originalPath, string newPath, Action<FileTags>? assertTags = null) { if (originalPath != newPath) @@ -121,21 +301,21 @@ private void AssertFile(string originalPath, string newPath, Action<FileTags>? a { 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()) { - 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 - ); - assertTags(tags); } } diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index f40b92c..67ce8e0 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -40,7 +40,7 @@ <Constant Include="TMDBApiKey" Value="$(TMDB_API_KEY)"/> </ItemGroup> - <Target Name="KeyCheck" BeforeTargets="PrepareForBuild" Condition="'$(Configuration)' == 'Release'"> + <Target Name="KeyCheck" BeforeTargets="PrepareForBuild"> <Error Condition="$(TMDB_API_KEY) == ''" Text="TMDB API key missing. Set TMDB_API_KEY environment variable before building."/> </Target> </Project> diff --git a/README.md b/README.md index 5b3b2e1..5da532a 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,9 @@ Linux/macOS: export TMDB_API_KEY="your_tmdb_api_key" ``` -You should also set the environment variable within your IDE to allow debugging. +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. From 829c1a1297f878987db5f3a93c85e6aa704e4688 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:49:55 +0100 Subject: [PATCH 34/35] build: restore key check for releases only --- AutoTag.CLI/AutoTag.CLI.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index 67ce8e0..f40b92c 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -40,7 +40,7 @@ <Constant Include="TMDBApiKey" Value="$(TMDB_API_KEY)"/> </ItemGroup> - <Target Name="KeyCheck" BeforeTargets="PrepareForBuild"> + <Target Name="KeyCheck" BeforeTargets="PrepareForBuild" Condition="'$(Configuration)' == 'Release'"> <Error Condition="$(TMDB_API_KEY) == ''" Text="TMDB API key missing. Set TMDB_API_KEY environment variable before building."/> </Target> </Project> From 90e3252a413b3dc0da1458f19c593074f510dd20 Mon Sep 17 00:00:00 2001 From: James Tattersall <10601770+jamerst@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:29:01 +0100 Subject: [PATCH 35/35] chore: update packages chore: fix minor issues detected by Rider chore: bump version number --- AutoTag.CLI.Test/AutoTag.CLI.Test.csproj | 9 ++- AutoTag.CLI.Test/ProcessTests.cs | 2 +- AutoTag.CLI/AutoTag.CLI.csproj | 4 +- AutoTag.Core.Test/AutoTag.Core.Test.csproj | 4 +- .../Files/TVFileNameParser/TryParse.cs | 2 +- .../TV/TVProcessor/FindEpisodeGroupAsync.cs | 2 +- .../TV/TVProcessor/TVProcessorTestBase.cs | 2 +- AutoTag.Core/AutoTag.Core.csproj | 8 +-- AutoTag.Core/Config/AutoTagConfig.cs | 16 ++--- AutoTag.Core/Extensions.cs | 44 +++++++------ AutoTag.Core/Files/FileFinder.cs | 6 +- AutoTag.Core/Files/FileNameField.cs | 7 ++- AutoTag.Core/Files/FileNamer.cs | 10 +-- AutoTag.Core/Files/FileSystem.cs | 2 +- .../Files/Parsing/MovieFileNameParser.cs | 2 +- AutoTag.Core/Files/TaggingFile.cs | 14 ++--- AutoTag.Core/Movie/MovieProcessor.cs | 6 +- AutoTag.Core/TMDB/TMDBMovie.cs | 61 ++++++++++--------- AutoTag.Core/TMDB/TMDBService.cs | 24 ++++---- AutoTag.Core/TV/EpisodeNumberMapping.cs | 2 +- AutoTag.Core/TV/ShowResults.cs | 57 ++++++++--------- AutoTag.Core/TV/TVFileMetadata.cs | 4 +- AutoTag.Core/TV/TVProcessor.cs | 30 ++++----- 23 files changed, 164 insertions(+), 154 deletions(-) diff --git a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj index 3ea01fd..4cfb705 100644 --- a/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj +++ b/AutoTag.CLI.Test/AutoTag.CLI.Test.csproj @@ -9,9 +9,12 @@ <ItemGroup> <PackageReference Include="AwesomeAssertions" Version="9.4.0"/> - <PackageReference Include="CliWrap" Version="3.10.1"/> - <PackageReference Include="coverlet.collector" Version="6.0.4"/> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> + <PackageReference Include="CliWrap" Version="3.10.2"/> + <PackageReference Include="coverlet.collector" Version="10.0.1"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0"/> <PackageReference Include="Spectre.Console.Cli.Testing" Version="0.55.0"/> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/> <PackageReference Include="xunit.v3" Version="3.2.2"/> diff --git a/AutoTag.CLI.Test/ProcessTests.cs b/AutoTag.CLI.Test/ProcessTests.cs index d8b2dbe..55f1673 100644 --- a/AutoTag.CLI.Test/ProcessTests.cs +++ b/AutoTag.CLI.Test/ProcessTests.cs @@ -288,7 +288,7 @@ public async Task Should_ProcessFilesWithAbsoluteRenamePattern() ); } - private void AssertFile(string originalPath, string newPath, Action<FileTags>? assertTags = null) + private static void AssertFile(string originalPath, string newPath, Action<FileTags>? assertTags = null) { if (originalPath != newPath) { diff --git a/AutoTag.CLI/AutoTag.CLI.csproj b/AutoTag.CLI/AutoTag.CLI.csproj index f40b92c..f048ae4 100644 --- a/AutoTag.CLI/AutoTag.CLI.csproj +++ b/AutoTag.CLI/AutoTag.CLI.csproj @@ -3,7 +3,7 @@ <AssemblyName>autotag</AssemblyName> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> - <Version>4.0.3</Version> + <Version>4.1.0</Version> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <RuntimeIdentifiers>linux-x64;osx-arm64;osx-x64;win-x64</RuntimeIdentifiers> @@ -22,7 +22,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1"/> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8"/> <PackageReference Include="Spectre.Console" Version="0.55.2"/> <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/> <PackageReference Include="Spectre.Console.Json" Version="0.55.2"/> diff --git a/AutoTag.Core.Test/AutoTag.Core.Test.csproj b/AutoTag.Core.Test/AutoTag.Core.Test.csproj index a446558..0cf303a 100644 --- a/AutoTag.Core.Test/AutoTag.Core.Test.csproj +++ b/AutoTag.Core.Test/AutoTag.Core.Test.csproj @@ -10,11 +10,11 @@ <ItemGroup> <PackageReference Include="AwesomeAssertions" Version="9.4.0"/> - <PackageReference Include="coverlet.collector" Version="6.0.4"> + <PackageReference Include="coverlet.collector" Version="10.0.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0"/> <PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PrivateAssets>all</PrivateAssets> diff --git a/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs b/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs index 25d5e86..ef5af73 100644 --- a/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs +++ b/AutoTag.Core.Test/Files/TVFileNameParser/TryParse.cs @@ -6,7 +6,7 @@ namespace AutoTag.Core.Test.Files.TVFileNameParser; public class TryParse { - private Core.Files.Parsing.TVFileNameParser GetInstance(AutoTagConfig? config = null) + private static Core.Files.Parsing.TVFileNameParser GetInstance(AutoTagConfig? config = null) => new(config.OrDefaultMock()); [Theory] diff --git a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs index 8acc2c9..f742dfc 100644 --- a/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs +++ b/AutoTag.Core.Test/TV/TVProcessor/FindEpisodeGroupAsync.cs @@ -92,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] 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 1e37204..479b048 100644 --- a/AutoTag.Core/AutoTag.Core.csproj +++ b/AutoTag.Core/AutoTag.Core.csproj @@ -6,10 +6,10 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1"/> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1"/> - <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1"/> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8"/> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8"/> + <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8"/> <PackageReference Include="TagLibSharp" Version="2.3.0"/> - <PackageReference Include="TMDbLib" Version="2.3.0"/> + <PackageReference Include="TMDbLib" Version="3.0.0"/> </ItemGroup> </Project> diff --git a/AutoTag.Core/Config/AutoTagConfig.cs b/AutoTag.Core/Config/AutoTagConfig.cs index b80ff0c..0c8ba2b 100644 --- a/AutoTag.Core/Config/AutoTagConfig.cs +++ b/AutoTag.Core/Config/AutoTagConfig.cs @@ -10,9 +10,9 @@ public class AutoTagConfig public Mode Mode { get; set; } = Mode.Auto; - public bool ManualMode { get; set; } = false; + public bool ManualMode { get; set; } - public bool Verbose { get; set; } = false; + public bool Verbose { get; set; } public bool AddCoverArt { get; set; } = true; @@ -20,7 +20,7 @@ public class AutoTagConfig public bool RenameFiles { get; set; } = true; - public bool RemoveEmptyFolders { get; set; } = false; + public bool RemoveEmptyFolders { get; set; } public string TVRenamePattern { get; set; } = "{Series} - {Season}x{Episode:00}{EndEpisode:-00|} - {Title}{Part:pt-0|}"; @@ -29,19 +29,19 @@ public class AutoTagConfig public string? ParsePattern { get; set; } - public bool WindowsSafe { get; set; } = false; + public bool WindowsSafe { get; set; } - public bool ExtendedTagging { get; set; } = false; + public bool ExtendedTagging { get; set; } - public bool AppleTagging { get; set; } = false; + public bool AppleTagging { get; set; } - public bool RenameSubtitles { get; set; } = false; + public bool RenameSubtitles { get; set; } public string Language { get; set; } = "en"; public List<string> SearchLanguages { get; set; } = []; - public bool IncludeAdult { get; set; } = false; + public bool IncludeAdult { get; set; } public bool EpisodeGroup { get; set; } diff --git a/AutoTag.Core/Extensions.cs b/AutoTag.Core/Extensions.cs index 14d1dc1..c90bbf0 100644 --- a/AutoTag.Core/Extensions.cs +++ b/AutoTag.Core/Extensions.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using AutoTag.Core.Config; using AutoTag.Core.Files; @@ -27,26 +26,28 @@ public static void AddCoreServices(this IServiceCollection services, string apiK services.AddSingleton<IFileNameParser, FileNameParser>(); services.AddSingleton<TVFileNameParser>(); services.AddSingleton<MovieFileNameParser>(); - + services.AddSingleton<IFileWriter, FileWriter>(); services.AddSingleton<IFileNamer, FileNamer>(); - + services.AddScoped<ICoverArtFetcher, CoverArtFetcher>(); services.AddKeyedScoped<IProcessor, TVProcessor>(Mode.TV); services.AddKeyedScoped<IProcessor, MovieProcessor>(Mode.Movie); services.AddMemoryCache(); - + services.AddHttpClient(); - services.RemoveAll<IHttpMessageHandlerBuilderFilter>(); // disable HttpClient logging - prints unwanted output to console - + services + .RemoveAll< + IHttpMessageHandlerBuilderFilter>(); // disable HttpClient logging - prints unwanted output to console + services.AddScoped<TMDbClient>(serviceProvider => { var configService = serviceProvider.GetRequiredService<IAutoTagConfigService>(); var config = configService.GetConfig(); - - return new(apiKey) + + return new TMDbClient(apiKey) { DefaultLanguage = config.Language, DefaultImageLanguage = config.Language @@ -57,18 +58,6 @@ public static void AddCoreServices(this IServiceCollection services, string apiK services.AddScoped<ITVCache, TVCache>(); } - public static bool IsError(this MessageType type) - => (type & MessageType.Error) == MessageType.Error; - - public static bool IsWarning(this MessageType type) - => (type & MessageType.Warning) == MessageType.Warning; - - public static bool IsInformation(this MessageType type) - => (type & MessageType.Information) == MessageType.Information; - - public static bool IsLog(this MessageType type) - => (type & MessageType.Log) == MessageType.Log; - public static bool TryFind<T>(this List<T> list, Predicate<T> match, [NotNullWhen(true)] out T? item) { item = list.Find(match); @@ -82,4 +71,19 @@ public static bool TryFind<T>(this List<T> list, Predicate<T> match, [NotNullWhe => groups.TryGetValue(groupName, out var match) && int.TryParse(match.Value, out var value) ? value : null; + + extension(MessageType type) + { + public bool IsError() + => (type & MessageType.Error) == MessageType.Error; + + 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/Files/FileFinder.cs b/AutoTag.Core/Files/FileFinder.cs index 7f5a2be..b3e9726 100644 --- a/AutoTag.Core/Files/FileFinder.cs +++ b/AutoTag.Core/Files/FileFinder.cs @@ -167,9 +167,9 @@ private bool IsSupportedFile(FileInfo info) => || (config.RenameSubtitles && IsSubtitleFile(info.Extension)); - private bool IsVideoFile(string extension) => ProcessableVideoExtensions.Contains(extension); + private static bool IsVideoFile(string extension) => ProcessableVideoExtensions.Contains(extension); - private bool IsTaggableVideoFile(string extension) => TaggableVideoExtensions.Contains(extension); + private static bool IsTaggableVideoFile(string extension) => TaggableVideoExtensions.Contains(extension); - private bool IsSubtitleFile(string extension) => SubtitleExtensions.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 index ced5bf2..abf3529 100644 --- a/AutoTag.Core/Files/FileNameField.cs +++ b/AutoTag.Core/Files/FileNameField.cs @@ -16,12 +16,13 @@ public class StringFileNameField(string specifier, string? legacySpecifier, stri public string Specifier { get; } = specifier; public string? LegacySpecifier { get; } = legacySpecifier; - public string GetFormattedValue(string? _) => Value ?? ""; + public string GetFormattedValue(string? format) => Value ?? ""; } -public class IntegerFileNameField(string specifier, string? legacySpecifier, int? value) : IFileNameField +public partial class IntegerFileNameField(string specifier, string? legacySpecifier, int? value) : IFileNameField { - private static readonly Regex FormatSpecifierRegex = new("[0#]+"); + [GeneratedRegex("[0#]+")] + private static partial Regex FormatSpecifierRegex { get; } private int? Value { get; } = value; public string Specifier { get; } = specifier; diff --git a/AutoTag.Core/Files/FileNamer.cs b/AutoTag.Core/Files/FileNamer.cs index c41983a..6c4d4e0 100644 --- a/AutoTag.Core/Files/FileNamer.cs +++ b/AutoTag.Core/Files/FileNamer.cs @@ -8,16 +8,16 @@ public interface IFileNamer (string Result, bool ReplacedInvalid) GetNewFileName(FileMetadata metadata); } -public class FileNamer(AutoTagConfig config) : IFileNamer +public partial class FileNamer(AutoTagConfig config) : IFileNamer { - private static readonly Regex RenameRegex = - new( - @"{(?<specifier>[A-z]+)(?:\:(?<specifierFormat>[^}]+))?}|%(?<legacySpecifier>\d+)(?:\:(?<legacySpecifierFormat>[0#]+))?"); - private static readonly char[] InvalidNtfsChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; private readonly char[] _invalidFilenameChars = GetInvalidFileNameChars(config); + [GeneratedRegex( + @"{(?<specifier>[A-z]+)(?:\:(?<specifierFormat>[^}]+))?}|%(?<legacySpecifier>\d+)(?:\:(?<legacySpecifierFormat>[0#]+))?")] + private static partial Regex RenameRegex { get; } + public (string Result, bool ReplacedInvalid) GetNewFileName(FileMetadata metadata) { var pattern = metadata.GetRenamePattern(config); diff --git a/AutoTag.Core/Files/FileSystem.cs b/AutoTag.Core/Files/FileSystem.cs index 0e54aa8..90521e9 100644 --- a/AutoTag.Core/Files/FileSystem.cs +++ b/AutoTag.Core/Files/FileSystem.cs @@ -40,7 +40,7 @@ public IEnumerable<FileSystemInfo> GetDirectoryContents(DirectoryInfo directoryI 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)); diff --git a/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs b/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs index 7039e10..8d093e9 100644 --- a/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs +++ b/AutoTag.Core/Files/Parsing/MovieFileNameParser.cs @@ -28,7 +28,7 @@ public class MovieFileNameParser new(@"\((?:[^,)]*,\s*)?(?<Year>(19|20)\d{2})\)", SharedRegexOptions); private static readonly Regex BareYearRegex = new(@"\b(?<Year>(19|20)\d{2})\b", SharedRegexOptions); - private static readonly Regex SeparatorRegex = new(@"[._-]+", 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); diff --git a/AutoTag.Core/Files/TaggingFile.cs b/AutoTag.Core/Files/TaggingFile.cs index f50678b..6cc9f68 100644 --- a/AutoTag.Core/Files/TaggingFile.cs +++ b/AutoTag.Core/Files/TaggingFile.cs @@ -1,6 +1,7 @@ using AutoTag.Core.Files.Parsing; namespace AutoTag.Core.Files; + public record TaggingFile { public required string Path { get; init; } @@ -9,11 +10,8 @@ public record TaggingFile public string Status { get; set; } = ""; public bool Success { get; set; } = true; - public ParsedTVFileName? TVDetails { get; set; } - public ParsedMovieFileName? MovieDetails { get; set; } - - 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/Movie/MovieProcessor.cs b/AutoTag.Core/Movie/MovieProcessor.cs index 5dc3feb..17fd3c8 100644 --- a/AutoTag.Core/Movie/MovieProcessor.cs +++ b/AutoTag.Core/Movie/MovieProcessor.cs @@ -115,9 +115,9 @@ private async Task<MovieFileMetadata> GetMovieMetadataAsync(TMDBMovie selectedRe { 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(); + 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)) diff --git a/AutoTag.Core/TMDB/TMDBMovie.cs b/AutoTag.Core/TMDB/TMDBMovie.cs index 9272ff0..1d27343 100644 --- a/AutoTag.Core/TMDB/TMDBMovie.cs +++ b/AutoTag.Core/TMDB/TMDBMovie.cs @@ -4,41 +4,44 @@ namespace AutoTag.Core.TMDB; public class TMDBMovie { - public int Id { get; set; } - - public required string Title { get; set; } - - public required string Overview { get; set; } - - public string? PosterPath { get; set; } - - public DateTime? ReleaseDate { get; set; } - - public required List<string> Genres { get; set; } - - public required string Language { get; set; } - - private TMDBMovie() {} - - public static TMDBMovie FromSearchMovie(SearchMovie movie, string language, Dictionary<int, string> genreLookup) => new() + private TMDBMovie() { - 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 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, + Title = movie.Title!, + Overview = movie.Overview!, PosterPath = movie.PosterPath, ReleaseDate = movie.ReleaseDate, - Genres = movie.Genres.Select(g => g.Name).ToList(), + 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 73e92be..d0e0139 100644 --- a/AutoTag.Core/TMDB/TMDBService.cs +++ b/AutoTag.Core/TMDB/TMDBService.cs @@ -38,13 +38,13 @@ public class TMDBService(TMDbClient client, AutoTagConfig config) : ITMDBService private Dictionary<int, string> TVGenres = []; public Task<SearchContainer<SearchTv>> SearchTvShowAsync(string query) - => client.SearchTvShowAsync(query, config.Language, includeAdult: config.IncludeAdult); + => client.SearchTvShowAsync(query, config.Language, includeAdult: config.IncludeAdult)!; public Task<TvShow> GetTvShowAsync(int id) - => client.GetTvShowAsync(id, language: config.Language); + => 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); @@ -56,25 +56,25 @@ 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"); + => client.GetTvShowImagesAsync(id, $"{config.Language},null")!; public async Task<List<TMDBMovie>> SearchMovieAsync(string query, string language, int? year) { var results = await client.SearchMovieAsync(query, language, includeAdult: config.IncludeAdult, year: year ?? 0); - if (results.Results.Count == 0) + if (results!.Results!.Count == 0) { return []; } @@ -85,18 +85,18 @@ public async Task<List<TMDBMovie>> SearchMovieAsync(string query, string languag } public async Task<TMDBMovie> GetMovieAsync(int movieId) - => TMDBMovie.FromMovie(await client.GetMovieAsync(movieId, config.Language), config.Language); + => 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); + 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 index 40b771c..bf4ba71 100644 --- a/AutoTag.Core/TV/EpisodeNumberMapping.cs +++ b/AutoTag.Core/TV/EpisodeNumberMapping.cs @@ -10,7 +10,7 @@ public class EpisodeNumberMapping(List<TvSeason> seasons) var episodeCounter = 0; foreach (var season in seasons) { - if (episodeNumber <= episodeCounter + season.Episodes.Count) + if (episodeNumber <= episodeCounter + season.Episodes!.Count) { return (season, season.Episodes[episodeNumber - episodeCounter - 1]); } 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/TVFileMetadata.cs b/AutoTag.Core/TV/TVFileMetadata.cs index b3450ae..5af9cdc 100644 --- a/AutoTag.Core/TV/TVFileMetadata.cs +++ b/AutoTag.Core/TV/TVFileMetadata.cs @@ -63,7 +63,7 @@ public override void WriteToFile(File file, AutoTagConfig config, IUserInterface // 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 ByteVector((byte)Season), (uint)AppleDataBox.FlagType.ContainsData); @@ -74,7 +74,7 @@ public override void WriteToFile(File file, AutoTagConfig config, IUserInterface MessageType.Warning); } - if (Episode >= byte.MinValue && Episode <= byte.MaxValue) + if (Episode is >= byte.MinValue and <= byte.MaxValue) { // Episode number appleTags.SetData("tves", new ByteVector((byte)Episode), (uint)AppleDataBox.FlagType.ContainsData); diff --git a/AutoTag.Core/TV/TVProcessor.cs b/AutoTag.Core/TV/TVProcessor.cs index cee1853..ced1a6f 100644 --- a/AutoTag.Core/TV/TVProcessor.cs +++ b/AutoTag.Core/TV/TVProcessor.cs @@ -89,8 +89,8 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) // 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(); @@ -156,7 +156,7 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) 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)") @@ -180,7 +180,7 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) 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}""", @@ -255,31 +255,31 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) var metadata = new TVFileMetadata { Id = showData.Id, - SeriesName = showData.Name, + SeriesName = showData.Name!, Year = parsedDetails.Year, Season = parsedDetails.Season ?? result.Value.Season.SeasonNumber, Episode = parsedDetails.Season.HasValue ? parsedDetails.Episode - : result.Value.Episode.EpisodeNumber, + : (int)result.Value.Episode.EpisodeNumber, EndEpisode = parsedDetails.EndEpisode, - SeasonEpisodes = result.Value.Season.Episodes.Count, + 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, + Title = result.Value.Episode.Name!, Overview = result.Value.Episode.Overview, - Genres = await tmdb.GetTvGenreNamesAsync(show.TvSearchResult.GenreIds), + Genres = await tmdb.GetTvGenreNamesAsync(show.TvSearchResult.GenreIds!), Part = parsedDetails.Part }; if (config.ExtendedTagging && fileIsTaggable) { - metadata.Director = result.Value.Episode.Crew.Find(c => c.Job == "Director")?.Name; + metadata.Director = result.Value.Episode.Crew!.Find(c => c.Job == "Director")?.Name; var credits = await tmdb.GetTvEpisodeCreditsAsync(showData.Id, result.Value.Season.SeasonNumber, - result.Value.Episode.EpisodeNumber); - metadata.Actors = credits.Cast.Select(c => c.Name).ToArray(); - metadata.Characters = credits.Cast.Select(c => c.Character).ToArray(); + (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); @@ -308,7 +308,7 @@ public async Task<ProcessResult> ProcessAsync(TaggingFile file) { var season = await GetSeasonAsync(showId, seasonNumber.Value); - if (season != null && season.Episodes.TryFind(e => e.EpisodeNumber == episodeNumber, out var episode)) + if (season != null && season.Episodes!.TryFind(e => e.EpisodeNumber == episodeNumber, out var episode)) { return (season, episode); } @@ -355,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();