From ede8134513b57ab5e2d2b7cd1b459190edc76a53 Mon Sep 17 00:00:00 2001 From: Arthur031221 Date: Mon, 5 Oct 2026 04:39:02 +0800 Subject: [PATCH] Retain numeric metadata outside numbered title identity Preserve existing containment behavior for catalog titles without numbers in both exact and near-exact matching. Keep original tag values as evidence. Co-authored-by: SaxxyToo <198826566+SaxxyToo@users.noreply.github.com> Signed-off-by: Arthur031221 --- .../BookImport/LogicalWorkGroupingFixture.cs | 66 +++++++++++++++++++ .../NearExactTitleEvidenceFixture.cs | 24 +++++++ .../Services/ContainmentValidator.cs | 21 +++--- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/Chaptarr.Core.Test/MediaFiles/BookImport/LogicalWorkGroupingFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/BookImport/LogicalWorkGroupingFixture.cs index 0ac2347c..c416ad44 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/BookImport/LogicalWorkGroupingFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/BookImport/LogicalWorkGroupingFixture.cs @@ -200,6 +200,72 @@ private static FileMatchingService CreateSut( logger: logger); } + [TestCase("The Primal Hunter 6", 6598)] + [TestCase("The Primal Hunter 6: A LitRPG Adventure", 6598)] + [TestCase("The Primal Hunter", 6589)] + public void should_keep_numbered_book_identity_when_file_title_has_a_subtitle(string fileTitle, int expectedBookId) + { + var books = new List + { + new Book + { + Id = 6598, + Title = "The Primal Hunter 6", + BaseBookId = "hc:1093426", + AuthorId = 25, + MediaType = BookMediaType.Ebook + }, + new Book + { + Id = 6589, + Title = "The Primal Hunter, Vol. 1", + BaseBookId = "primal-hunter-1", + AuthorId = 25, + MediaType = BookMediaType.Ebook + } + }; + var candidates = new List + { + new EditionFtsMatch + { + EditionId = 16318, + BookId = 6598, + EditionTitle = "The Primal Hunter 6", + BookTitle = "The Primal Hunter 6", + AuthorId = 25, + AuthorName = "J.K. Rowling", + ReadingFormatId = 3, + MatchScore = 10.0 + }, + new EditionFtsMatch + { + EditionId = 16309, + BookId = 6589, + EditionTitle = "The Primal Hunter", + BookTitle = "The Primal Hunter, Vol. 1", + AuthorId = 25, + AuthorName = "J.K. Rowling", + ReadingFormatId = 3, + MatchScore = 9.0 + } + }; + var file = new DiscoveredFileWithMetadata + { + Path = "/ebooks/The Primal Hunter/book.epub", + AllTags = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { "TITLE", new List { fileTitle } }, + { "AUTHOR", new List { "J.K. Rowling" } } + } + }; + + var match = CreateSut(candidates, books).HolyGrailMatchFile(file, BookMediaType.Ebook, restrictToAuthorId: 25); + + Assert.That(match, Is.Not.Null); + Assert.That(match.BookId, Is.EqualTo(expectedBookId)); + Assert.That(match.EditionId, Is.EqualTo(expectedBookId == 6598 ? 16318 : 16309)); + } + [Test] public void should_treat_same_provider_work_clones_as_one_title_candidate() { diff --git a/src/Chaptarr.Core.Test/MediaFiles/BookImport/NearExactTitleEvidenceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/BookImport/NearExactTitleEvidenceFixture.cs index 76e3832a..3240bff9 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/BookImport/NearExactTitleEvidenceFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/BookImport/NearExactTitleEvidenceFixture.cs @@ -195,6 +195,14 @@ public void should_accept_compact_and_split_litrpg_title_tokens(string editionTi private static IEnumerable TitleEvidenceRegressionCases() { + yield return new TestCaseData("numbered-title-with-subtitle", "The Primal Hunter 6", "The Primal Hunter 6: A LitRPG Adventure", false, true, false); + yield return new TestCaseData("numbered-title-without-subtitle", "The Primal Hunter 6", "The Primal Hunter 6", false, true, false); + yield return new TestCaseData("numbered-title-without-number", "The Primal Hunter 6", "The Primal Hunter", false, false, false); + yield return new TestCaseData("numbered-title-with-wrong-number", "The Primal Hunter 6", "The Primal Hunter 5: A LitRPG Adventure", false, false, false); + yield return new TestCaseData("number-after-subtitle", "The Primal Hunter 6", "The Primal Hunter: A LitRPG Adventure 6", false, false, false); + yield return new TestCaseData("number-before-title", "The Primal Hunter 6", "6 The Primal Hunter", false, false, false); + yield return new TestCaseData("conflicting-number-before-title-number", "The Primal Hunter 6", "The Primal Hunter 5 6", false, false, false); + yield return new TestCaseData("numeric-title-prefix", "1984 Volume 2", "1984 Volume 2: A Novel", false, true, false); yield return new TestCaseData("exact-title", "Fantastic Beasts and Where to Find Them", "Fantastic Beasts and Where to Find Them", false, true, false); yield return new TestCaseData("single-trailing-char", "Fantastic Beasts and Where to Find Them", "Fantastic Beast and Where to Find Them", false, true, false); yield return new TestCaseData("short-title-no-token-count-magic", "It Takes Two", "It Take Two", false, true, false); @@ -231,5 +239,21 @@ public void should_preserve_title_evidence_regression_matrix( Assert.That(evidence.Single().RequiresAudiobookDurationCorroboration, Is.EqualTo(shouldRequireDuration), name); } } + + [TestCase("Harry Potter 5 - The Order of the Phoenix", false)] + [TestCase("Harry Potter 5 - The Order of the Phoeni", true)] + public void should_preserve_numeric_metadata_in_title_evidence(string tagTitle, bool isNearExact) + { + var evidence = CreateValidator().GetEditionTitleEvidence( + "Harry Potter and the Order of the Phoenix", + new Dictionary> + { + { "ALBUM", new List { tagTitle } } + }); + + Assert.That(evidence, Has.Count.EqualTo(1)); + Assert.That(evidence.Single().IsNearExact, Is.EqualTo(isNearExact)); + Assert.That(evidence.Single().FieldValue, Is.EqualTo(tagTitle)); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Services/ContainmentValidator.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Services/ContainmentValidator.cs index d668111a..fc3e27b7 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Services/ContainmentValidator.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Services/ContainmentValidator.cs @@ -70,8 +70,7 @@ public class ContainmentValidator : IContainmentValidator // Unicode-aware word boundary pattern private static readonly Regex WordPattern = new Regex(@"\p{L}+", RegexOptions.Compiled); - // Numeric tokens (e.g., 1984) - private static readonly Regex NumberPattern = new Regex(@"\b\d+\b", RegexOptions.Compiled); + private static readonly Regex TitleTokenPattern = new Regex(@"\p{L}+|\b\d+\b", RegexOptions.Compiled); // Roman numerals (conservative) private static readonly Regex RomanRegex = new Regex(@"^(M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3}))$", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -487,7 +486,7 @@ private IReadOnlyList TryGetNearExactTitleEvidence(string continue; } - if (TitleTokenAlignment.TryAlignStructural(editionTokens, fieldTokens, allowNearExact: true, allowTransposition: includeDurationGatedNearExact, out var alignment) && + if (TitleTokenAlignment.TryAlignStructural(editionTokens, GetFieldTokensForTitleAlignment(editionTokens, fieldTokens), allowNearExact: true, allowTransposition: includeDurationGatedNearExact, out var alignment) && alignment.UsedNearExact) { evidence.Add(new EditionTitleEvidence( @@ -887,12 +886,7 @@ private static List ExtractTokensAsList(string normalized) var list = new List(); if (string.IsNullOrWhiteSpace(normalized)) return list; - foreach (Match m in WordPattern.Matches(normalized)) - { - var v = m.Value.ToLowerInvariant(); - if (!string.IsNullOrEmpty(v)) list.Add(v); - } - foreach (Match m in NumberPattern.Matches(normalized)) + foreach (Match m in TitleTokenPattern.Matches(normalized)) { var v = m.Value.ToLowerInvariant(); if (!string.IsNullOrEmpty(v)) list.Add(v); @@ -908,12 +902,19 @@ private static bool CanConsumeAllTokens(List editionTokens, List { return TitleTokenAlignment.TryAlignStructural( editionTokens, - fieldTokens, + GetFieldTokensForTitleAlignment(editionTokens, fieldTokens), allowNearExact: false, allowTransposition: false, out _); } + private static List GetFieldTokensForTitleAlignment(List editionTokens, List fieldTokens) + { + return editionTokens.Any(token => token.All(char.IsDigit)) + ? fieldTokens + : fieldTokens.Where(token => !token.All(char.IsDigit)).ToList(); + } + private List ParseIntoWords(string authorName) { var normalized = NormalizeForWordExtraction(authorName);