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 @@ -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<Book>
{
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<EditionFtsMatch>
{
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<string, List<string>>(StringComparer.OrdinalIgnoreCase)
{
{ "TITLE", new List<string> { fileTitle } },
{ "AUTHOR", new List<string> { "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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ public void should_accept_compact_and_split_litrpg_title_tokens(string editionTi

private static IEnumerable<TestCaseData> 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);
Expand Down Expand Up @@ -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<string, List<string>>
{
{ "ALBUM", new List<string> { tagTitle } }
});

Assert.That(evidence, Has.Count.EqualTo(1));
Assert.That(evidence.Single().IsNearExact, Is.EqualTo(isNearExact));
Assert.That(evidence.Single().FieldValue, Is.EqualTo(tagTitle));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -487,7 +486,7 @@ private IReadOnlyList<EditionTitleEvidence> 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(
Expand Down Expand Up @@ -887,12 +886,7 @@ private static List<string> ExtractTokensAsList(string normalized)
var list = new List<string>();
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);
Expand All @@ -908,12 +902,19 @@ private static bool CanConsumeAllTokens(List<string> editionTokens, List<string>
{
return TitleTokenAlignment.TryAlignStructural(
editionTokens,
fieldTokens,
GetFieldTokensForTitleAlignment(editionTokens, fieldTokens),
allowNearExact: false,
allowTransposition: false,
out _);
}

private static List<string> GetFieldTokensForTitleAlignment(List<string> editionTokens, List<string> fieldTokens)
{
return editionTokens.Any(token => token.All(char.IsDigit))
? fieldTokens
: fieldTokens.Where(token => !token.All(char.IsDigit)).ToList();
}

private List<AuthorWord> ParseIntoWords(string authorName)
{
var normalized = NormalizeForWordExtraction(authorName);
Expand Down