From 1dcab852baa93e7e3ce5563715cf979f5ca5b79b Mon Sep 17 00:00:00 2001 From: jonnotk <166983273+jonnotk@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:36:21 -0300 Subject: [PATCH 1/2] Fix author-name collisions and box-set search queries --- ...leaseSearchServiceTitleSelectionFixture.cs | 18 ++++++++++ .../Parser/ReleaseTitleMatchScorerFixture.cs | 35 +++++++++++++++++++ .../Definitions/BookSearchCriteria.cs | 9 ++++- .../Parser/ReleaseTitleMatchScorer.cs | 5 ++- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs b/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs index 77c067e5..b620118e 100644 --- a/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs +++ b/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs @@ -9,6 +9,24 @@ namespace Chaptarr.Core.Test.Indexers [TestFixture] public class ReleaseSearchServiceTitleSelectionFixture { + [TestCase("Galactic Empire Series 3 Books Set", "Galactic+Empire")] + [TestCase("Galactic Empire 3-Book Box Set", "Galactic+Empire")] + [TestCase("Galactic Empire 3 Books Set: Pebble in the Sky and more", "Galactic+Empire")] + [TestCase("The Three-Body Problem", "Three+Body+Problem")] + [TestCase("Foundation Book 3", "Foundation+Book+3")] + [TestCase("3 Books Set", "3+Books+Set")] + public void book_query_should_remove_only_trailing_box_set_packaging(string title, string query) + { + var criteria = new BookSearchCriteria + { + Author = new Author { Name = "Isaac Asimov" }, + BookTitle = title + }; + + Assert.That(criteria.BookQuery, Is.EqualTo(query)); + Assert.That(criteria.BookTitle, Is.EqualTo(title)); + } + [Test] public void should_use_selected_edition_title_when_any_edition_ok() { diff --git a/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs b/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs index a20e59ad..b8162f91 100644 --- a/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs +++ b/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs @@ -10,6 +10,41 @@ namespace Chaptarr.Core.Test.Parser [TestFixture] public class ReleaseTitleMatchScorerFixture { + [TestCase("The Naked Sun", "Isaac Asimov The Naked Sun 2011 RETAiL EPUB eBook-NODE")] + [TestCase("Foundation", "Isaac Asimov Foundation EPUB")] + [TestCase("Foundation", "Foundation Isaac Asimov M4B")] + public void should_not_treat_author_credit_as_a_conflicting_book_title(string title, string releaseTitle) + { + var author = new Author { Name = "Isaac Asimov" }; + var target = new Book { Id = 1, Title = title, Author = author }; + var namesake = new Book { Id = 2, Title = "Isaac Asimov", Author = author }; + + var result = ReleaseTitleMatchScorer.FindBestMatch( + releaseTitle, author.Name, new[] { target }, null, new[] { target, namesake }); + + Assert.That(result, Is.Not.Null); + Assert.That(result.IsMatch, Is.True); + Assert.That(result.Problems, Is.Empty); + } + + [Test] + public void should_still_reject_a_different_book_when_catalog_contains_author_namesake() + { + var author = new Author { Name = "Isaac Asimov" }; + var target = new Book { Id = 1, Title = "Foundation", Author = author }; + var namesake = new Book { Id = 2, Title = "Isaac Asimov", Author = author }; + var sequel = new Book { Id = 3, Title = "Foundation and Empire", Author = author }; + + var result = ReleaseTitleMatchScorer.FindBestMatch( + "Isaac Asimov Foundation and Empire EPUB", author.Name, new[] { target }, null, + new[] { target, namesake, sequel }); + + Assert.That(result, Is.Not.Null); + Assert.That(result.IsMatch, Is.False); + Assert.That(result.ProblemCode, Is.EqualTo(TitleMatchProblemCode.SiblingTitleContradiction)); + Assert.That(result.MeaningfulLeftovers, Does.Contain("Foundation and Empire")); + } + [Test] public void should_return_source_spans_for_backend_title_tokens() { diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/BookSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/BookSearchCriteria.cs index 811085d5..b4c6cae1 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/BookSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/BookSearchCriteria.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; using NzbDrone.Core.Parser; namespace NzbDrone.Core.IndexerSearch.Definitions { public class BookSearchCriteria : SearchCriteriaBase { + private static readonly Regex BoxSetSuffix = new Regex(@"\s+(?:series\s+)?\d+[\s-]+books?\s+(?:box(?:ed)?\s+)?set\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public string BookTitle { get; set; } public int BookYear { get; set; } public string BookIsbn { get; set; } @@ -26,7 +29,11 @@ internal static string GetMainSearchTitle(string title, string author) } var mainTitle = titleWithoutAuthor.SplitBookTitle(author).Item1; - return string.IsNullOrWhiteSpace(mainTitle) ? titleWithoutAuthor : mainTitle; + var searchTitle = string.IsNullOrWhiteSpace(mainTitle) ? titleWithoutAuthor : mainTitle; + + // Retail packaging terms rarely appear in indexer titles. Broaden only the + // query; identity and pack checks still use the original selected title. + return BoxSetSuffix.Replace(searchTitle, string.Empty); } internal static string RemoveLeadingAuthorPrefix(string title, string author) diff --git a/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs b/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs index a34925fe..8fd4423a 100644 --- a/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs +++ b/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs @@ -317,7 +317,10 @@ private static List GetProblems(IReadOnlyList release continue; } - if (IsTargetSeriesContext(contradiction.Title, context)) + // A catalogue can contain a book named after its author. The author + // credit in a release is not evidence that it contains that book. + if (TokenizedEquals(contradiction.Title, authorName) || + IsTargetSeriesContext(contradiction.Title, context)) { continue; } From 71e67411bd46f96fa57f0ffc0ad9b50498853066 Mon Sep 17 00:00:00 2001 From: jonnotk <166983273+jonnotk@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:43:06 -0300 Subject: [PATCH 2/2] Match series sets without retail counts while rejecting partial sets --- .../ReleaseTitleMatchSpecificationFixture.cs | 23 ++++++++++++++++ .../Parser/ReleaseTitleMatchScorerFixture.cs | 19 ++++++++++++++ .../Parser/ReleaseTitleMatchScorer.cs | 26 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/src/Chaptarr.Core.Test/DecisionEngine/ReleaseTitleMatchSpecificationFixture.cs b/src/Chaptarr.Core.Test/DecisionEngine/ReleaseTitleMatchSpecificationFixture.cs index 59d228a2..f38547b1 100644 --- a/src/Chaptarr.Core.Test/DecisionEngine/ReleaseTitleMatchSpecificationFixture.cs +++ b/src/Chaptarr.Core.Test/DecisionEngine/ReleaseTitleMatchSpecificationFixture.cs @@ -14,6 +14,29 @@ namespace Chaptarr.Core.Test.DecisionEngine [TestFixture] public class ReleaseTitleMatchSpecificationFixture { + [TestCase("Isaac Asimov Galactic Empire Series 2 Books Set EPUB")] + [TestCase("Isaac Asimov Galactic Empire Series Book 1 EPUB")] + [TestCase("Isaac Asimov Galactic Empire Series Pebble in the Sky EPUB")] + public void should_reject_partial_series_sets_with_default_balanced_matching(string releaseTitle) + { + var author = new Author { Name = "Isaac Asimov" }; + var book = new Book { Title = "Galactic Empire Series 3 Books Set", Author = author }; + var criteria = new BookSearchCriteria + { + Author = author, + Books = new List { book }, + InteractiveSearch = true + }; + var remoteBook = new RemoteBook + { + Release = new ReleaseInfo { Title = releaseTitle, Author = author.Name } + }; + + var spec = new ReleaseTitleMatchSpecification(LogManager.GetCurrentClassLogger()); + + Assert.That(spec.IsSatisfiedBy(remoteBook, criteria).Accepted, Is.False); + } + [Test] public void should_accept_interactive_search_result_when_release_title_omits_author_but_release_author_hint_matches() { diff --git a/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs b/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs index b8162f91..cbe04f69 100644 --- a/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs +++ b/src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs @@ -10,6 +10,25 @@ namespace Chaptarr.Core.Test.Parser [TestFixture] public class ReleaseTitleMatchScorerFixture { + [TestCase("Galactic Empire series by Isaac Asimov EPUB", true)] + [TestCase("Isaac Asimov Galactic Empire Series M4B", true)] + [TestCase("Isaac Asimov Galactic Empire Series 3 Books Set EPUB", true)] + [TestCase("Isaac Asimov Galactic Empire Series 2 Books Set EPUB", false)] + [TestCase("Isaac Asimov Galactic Empire Series Book 1 EPUB", false)] + [TestCase("Isaac Asimov Galactic Empire EPUB", false)] + [TestCase("Isaac Asimov Galactic Empire Series Pebble in the Sky EPUB", false)] + public void should_match_series_set_without_retail_book_count_but_reject_explicit_partial_sets(string releaseTitle, bool accepted) + { + var author = new Author { Name = "Isaac Asimov" }; + var target = new Book { Id = 1, Title = "Galactic Empire Series 3 Books Set", Author = author }; + var namesake = new Book { Id = 2, Title = "Isaac Asimov", Author = author }; + + var result = ReleaseTitleMatchScorer.FindBestMatch( + releaseTitle, author.Name, new[] { target }, null, new[] { target, namesake }); + + Assert.That(result?.IsMatch == true, Is.EqualTo(accepted)); + } + [TestCase("The Naked Sun", "Isaac Asimov The Naked Sun 2011 RETAiL EPUB eBook-NODE")] [TestCase("Foundation", "Isaac Asimov Foundation EPUB")] [TestCase("Foundation", "Foundation Isaac Asimov M4B")] diff --git a/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs b/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs index 8fd4423a..74565497 100644 --- a/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs +++ b/src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs @@ -57,6 +57,7 @@ internal sealed class BookTitleMatchContext public string PrimaryTitle { get; set; } public string SeriesName { get; set; } public string SeriesPosition { get; set; } + public string SeriesSetVariant { get; set; } public List PrimaryVariants { get; } = new List(); public HashSet PrefixAllowanceTokens { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); } @@ -80,6 +81,7 @@ public static class ReleaseTitleMatchScorer private static readonly Regex SubtitleArticleInsertionPointRegex = new Regex(@"(?[:;\-\u2013\u2014]\s+)(?[\p{L}\p{Nd}])", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex LeadingOptionalArticleRegex = new Regex(@"^(?:a|an|the)\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex WhitespaceRegex = new Regex(@"\s+", RegexOptions.Compiled); + private static readonly Regex SeriesSetTitleRegex = new Regex(@"^(?.+\s+series)\s+\d+[\s-]+books?\s+(?:box(?:ed)?\s+)?set$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex SpaceBeforePunctuationRegex = new Regex(@"\s+([:;,])", RegexOptions.Compiled); private static readonly Regex SpaceAfterPunctuationRegex = new Regex(@"([:;,])(?=\S)", RegexOptions.Compiled); private static readonly Regex YearTokenRegex = new Regex(@"^(?:18\d{2}|19\d{2}|20\d{2}|21\d{2})$", RegexOptions.Compiled); @@ -233,6 +235,12 @@ private static TitleMatchResult ScoreAgainstTitleVariant(IReadOnlyList r foreach (var span in spans) { + if (TokenizedEquals(bookTitleVariant, context?.SeriesSetVariant) && + HasSeriesSetExtras(releaseTokens, span.Start, span.End, book?.Author?.Name)) + { + continue; + } + var problems = GetProblems(releaseTokens, span.Start, span.End, titleTokens.Count, hasAuthorInTitle, book?.Author?.Name, context, contradictoryVariants); var leftovers = problems.Select(problem => problem.Value).ToList(); @@ -256,6 +264,14 @@ private static TitleMatchResult ScoreAgainstTitleVariant(IReadOnlyList r return best; } + private static bool HasSeriesSetExtras(IReadOnlyList releaseTokens, int matchedStart, int matchedEnd, string authorName) + { + var authorTokens = Tokenize(authorName); + return releaseTokens.Where((_, index) => index < matchedStart || index > matchedEnd).Any(token => + (IsNumericToken(token) && !YearTokenRegex.IsMatch(token)) || + (!IsMetadataToken(token) && !authorTokens.Any(authorToken => TokensMatch(authorToken, token)))); + } + private static bool IsLongAuthorlessYearTitleMatch(IReadOnlyList releaseTokens, int matchedStart, int matchedEnd, IReadOnlyCollection titleTokens, IReadOnlyCollection problems) { var yearIndex = matchedEnd + 1; @@ -889,6 +905,16 @@ internal static BookTitleMatchContext GetBookTitleMatchContext(Book book) AddTitleVariants(context.PrimaryVariants, primaryTitle); context.PrimaryTitle = primaryTitle; + // Keep "series" in this variant so a bare title or an individual volume + // cannot stand in for the set. Explicit counts and extra titles are checked + // before scoring this variant, including in relaxed matching modes. + var seriesSet = SeriesSetTitleRegex.Match(primaryTitle); + if (seriesSet.Success) + { + context.SeriesSetVariant = seriesSet.Groups["series"].Value; + AddTitleVariants(context.PrimaryVariants, context.SeriesSetVariant); + } + foreach (var variant in context.PrimaryVariants) { AddTokens(context.PrefixAllowanceTokens, variant);