From 907544cea1b93ea1b0622daf2a8af74fb6a61a4b Mon Sep 17 00:00:00 2001 From: Jordan Harney <962189+JordanFromIT@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:17:58 -0400 Subject: [PATCH] Search for the book, not the omnibus that contains it When a book's monitored edition is a compilation named after several works ("A Game of Thrones / A Clash of Kings"), that whole string is sent to the indexers as the search query. No release is named after a compilation, so every indexer returns nothing and the book can never be found. Fall back to the book's own title when it is one of the slash-separated works listed in the edition title. Titles where the slash is part of a phrase ("Horror/Sci-Fi") produce no matching segment and are left alone. --- ...leaseSearchServiceTitleSelectionFixture.cs | 119 ++++++++++++++++++ .../IndexerSearch/ReleaseSearchService.cs | 51 +++++++- 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs b/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs index 77c067e5..7feed424 100644 --- a/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs +++ b/src/Chaptarr.Core.Test/Indexers/ReleaseSearchServiceTitleSelectionFixture.cs @@ -56,6 +56,125 @@ public void should_fallback_to_book_title_when_selected_edition_title_is_blank() Assert.That(title, Is.EqualTo("Alanna: The First Adventure")); } + [Test] + public void should_use_book_title_when_selected_edition_is_an_omnibus_containing_it() + { + var omnibus = new Edition { Title = "A Game of Thrones / A Clash of Kings" }; + + var book = new Book + { + Title = "A Clash of Kings", + Editions = new List { omnibus } + }; + + var title = ReleaseSearchService.GetSearchBookTitle(book, omnibus); + + Assert.That(title, Is.EqualTo("A Clash of Kings")); + } + + [Test] + public void should_use_book_title_when_omnibus_separator_has_no_surrounding_spaces() + { + var omnibus = new Edition { Title = "A Game of Thrones/A Clash of Kings" }; + + var book = new Book + { + Title = "A Clash of Kings", + Editions = new List { omnibus } + }; + + var title = ReleaseSearchService.GetSearchBookTitle(book, omnibus); + + Assert.That(title, Is.EqualTo("A Clash of Kings")); + } + + [Test] + public void should_match_omnibus_segment_ignoring_case_and_padding() + { + var omnibus = new Edition { Title = "A GAME OF THRONES / a clash of kings" }; + + var book = new Book + { + Title = "A Clash of Kings", + Editions = new List { omnibus } + }; + + var title = ReleaseSearchService.GetSearchBookTitle(book, omnibus); + + Assert.That(title, Is.EqualTo("A Clash of Kings")); + } + + [Test] + public void should_keep_edition_title_when_slash_is_part_of_a_phrase() + { + // "Horror/Sci-Fi" is one phrase, not two works. Splitting here would search for nonsense. + var edition = new Edition { Title = "Stories of Fantasy, Horror/Sci-Fi, and a Man Called Tuf" }; + + var book = new Book + { + Title = "Dreamsongs", + Editions = new List { edition } + }; + + var title = ReleaseSearchService.GetSearchBookTitle(book, edition); + + Assert.That(title, Is.EqualTo("Stories of Fantasy, Horror/Sci-Fi, and a Man Called Tuf")); + } + + [Test] + public void should_keep_edition_title_when_no_segment_matches_the_book() + { + // A marketplace-style listing that happens to contain a slash. The book title is not a + // clean segment, so there is nothing safe to fall back to. + var edition = new Edition { Title = "Rare George R R Martin / A KNIGHT OF THE SEVEN KINGDOMS Signed 1st Edition 2015" }; + + var book = new Book + { + Title = "A Knight of the Seven Kingdoms", + Editions = new List { edition } + }; + + var title = ReleaseSearchService.GetSearchBookTitle(book, edition); + + Assert.That(title, Is.EqualTo("Rare George R R Martin / A KNIGHT OF THE SEVEN KINGDOMS Signed 1st Edition 2015")); + } + + [Test] + public void should_keep_edition_title_when_book_title_is_blank() + { + var omnibus = new Edition { Title = "A Game of Thrones / A Clash of Kings" }; + + var book = new Book + { + Title = " ", + Editions = new List { omnibus } + }; + + var title = ReleaseSearchService.GetSearchBookTitle(book, omnibus); + + Assert.That(title, Is.EqualTo("A Game of Thrones / A Clash of Kings")); + } + + [Test] + public void omnibus_edition_should_produce_a_single_work_book_query() + { + var omnibus = new Edition { Title = "A Game of Thrones / A Clash of Kings" }; + + var book = new Book + { + Title = "A Clash of Kings", + Editions = new List { omnibus } + }; + + var criteria = new BookSearchCriteria + { + Author = new Author { Name = "George R.R. Martin" }, + BookTitle = ReleaseSearchService.GetSearchBookTitle(book, omnibus) + }; + + Assert.That(criteria.BookQuery, Is.EqualTo("A+Clash+of+Kings")); + } + [Test] public void book_query_should_use_main_title_section() { diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 619b59cb..647d2c60 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using NLog; using NzbDrone.Common.Extensions; @@ -25,6 +26,8 @@ public interface ISearchForReleases public class ReleaseSearchService : ISearchForReleases { + private static readonly Regex CollapsedWhitespace = new Regex(@"\s+", RegexOptions.Compiled); + private readonly IIndexerFactory _indexerFactory; private readonly IBookService _bookService; private readonly IAuthorService _authorService; @@ -145,12 +148,58 @@ internal static string GetSearchBookTitle(Book book, Edition selectedEdition) var selectedTitle = selectedEdition?.Title; if (!string.IsNullOrWhiteSpace(selectedTitle)) { - return selectedTitle; + // An omnibus edition is named after every work it collects ("A Game of Thrones / A + // Clash of Kings"). Searching for that whole string finds nothing, because releases + // are named after a book, not after the compilation that happens to contain it. When + // the book being searched for is itself one of the collected works, search for it by + // name instead of for the compilation. + return GetCollectedWorkTitle(selectedTitle, book.Title) ?? selectedTitle; } return book.Title ?? string.Empty; } + /// + /// Returns the book's own title when is a slash-separated + /// compilation that lists it as one of its works; otherwise null, leaving the caller's title + /// untouched. Deliberately conservative: a slash inside a phrase ("Horror/Sci-Fi") only ever + /// yields segments that fail the equality check, so such titles are left alone. + /// + internal static string GetCollectedWorkTitle(string editionTitle, string bookTitle) + { + if (string.IsNullOrWhiteSpace(editionTitle) || string.IsNullOrWhiteSpace(bookTitle)) + { + return null; + } + + var segments = editionTitle.Split('/'); + if (segments.Length < 2) + { + return null; + } + + var normalisedBookTitle = NormaliseTitleSegment(bookTitle); + if (normalisedBookTitle.Length == 0) + { + return null; + } + + foreach (var segment in segments) + { + if (string.Equals(NormaliseTitleSegment(segment), normalisedBookTitle, StringComparison.OrdinalIgnoreCase)) + { + return bookTitle.Trim(); + } + } + + return null; + } + + private static string NormaliseTitleSegment(string value) + { + return CollapsedWhitespace.Replace(value ?? string.Empty, " ").Trim(); + } + internal static bool HasConfiguredQualityProfileForMediaType(Author author, BookMediaType mediaType) { if (author == null)