Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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> { 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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
54 changes: 54 additions & 0 deletions src/Chaptarr.Core.Test/Parser/ReleaseTitleMatchScorerFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,60 @@ 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")]
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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
Expand All @@ -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)
Expand Down
31 changes: 30 additions & 1 deletion src/NzbDrone.Core/Parser/ReleaseTitleMatchScorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> PrimaryVariants { get; } = new List<string>();
public HashSet<string> PrefixAllowanceTokens { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
Expand All @@ -80,6 +81,7 @@ public static class ReleaseTitleMatchScorer
private static readonly Regex SubtitleArticleInsertionPointRegex = new Regex(@"(?<prefix>[:;\-\u2013\u2014]\s+)(?<head>[\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(@"^(?<series>.+\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);
Expand Down Expand Up @@ -233,6 +235,12 @@ private static TitleMatchResult ScoreAgainstTitleVariant(IReadOnlyList<string> 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();

Expand All @@ -256,6 +264,14 @@ private static TitleMatchResult ScoreAgainstTitleVariant(IReadOnlyList<string> r
return best;
}

private static bool HasSeriesSetExtras(IReadOnlyList<string> 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<string> releaseTokens, int matchedStart, int matchedEnd, IReadOnlyCollection<string> titleTokens, IReadOnlyCollection<TitleMatchProblem> problems)
{
var yearIndex = matchedEnd + 1;
Expand Down Expand Up @@ -317,7 +333,10 @@ private static List<TitleMatchProblem> GetProblems(IReadOnlyList<string> 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;
}
Expand Down Expand Up @@ -886,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);
Expand Down