Skip to content
2 changes: 2 additions & 0 deletions changelog.d/2813-series-position-title-relevance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- **Books stored as "Series 4: Title" now match releases that name only the title** (#2813): a book whose title carries its series and position ahead of a colon, like "Feral Mage 4: The Mining Company Contract", was only ever matched on the whole name or on the series half, so a release called "The Mining Company Contract by Chase Kilgore" was dropped by both automatic and interactive search. The book's own title is now tried as well when the part before the colon ends in a number ("Feral Mage 4", "Book 8", "Discworld #3"), after the stricter readings fail, and it has to be at least two meaningful words, keep its words in order, and not name a different author. An ordinary "Title: Subtitle" is unaffected. Thanks OMegaliterosMalakas for the report.
12 changes: 9 additions & 3 deletions internal/indexer/newznab/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,9 +690,15 @@ func titleHasRelevantResult(queryTitle string, results []SearchResult) bool {
//
// Gated on a non-empty word list: SigWordsElided returns nil for a title
// with no apostrophe, and an empty list means "nothing checkable" (true)
// upstream — letting it through here would accept junk for every title.
if elided := SigWordsElided(queryTitle); len(elided) > 0 {
return wordsPresentInAll(elided, combined)
// upstream; letting it through here would accept junk for every title.
if elided := SigWordsElided(queryTitle); len(elided) > 0 && wordsPresentInAll(elided, combined) {
return true
}
// A "Series N: Title" name is judged on the book's own title as well,
// since release names carry the title without the series position. Same
// two word floor as the relevance filter's fallback reading.
if series := SigWords(SeriesPositionTitle(queryTitle)); len(series) >= 2 && wordsPresentInAll(series, combined) {
return true
}
return false
}
Expand Down
38 changes: 38 additions & 0 deletions internal/indexer/newznab/words.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,41 @@ var queryUmlautReplacer = strings.NewReplacer(
func TransliterateQuery(s string) string {
return queryUmlautReplacer.Replace(norm.NFC.String(s))
}

// SeriesPositionTitle returns the book's own title from a "Series N: Title"
// name, or "" when title does not have that shape.
//
// Providers often store a series entry with the series and its position ahead
// of the colon ("Feral Mage 4: The Mining Company Contract"), while release
// names carry only the book's own title ("The Mining Company Contract by Chase
// Kilgore"). The full title then needs "feral" and "mage", and the part before
// the colon is the series, not the book, so neither reading can match a
// release that names the book correctly.
//
// The shape is recognised only when the part before the colon ends in a
// position: a number, optionally prefixed with "#" or followed by a period
// ("Feral Mage 4", "Book 8", "Discworld #3", "Vol. 2."), with at least one word
// ahead of it. An ordinary "Title: Subtitle" has no such number, so it is left
// to the existing readings. The returned title is a fallback reading for the
// relevance checks and must never replace the stricter ones.
func SeriesPositionTitle(title string) string {
i := strings.Index(title, ":")
if i <= 0 {
return ""
}
head := strings.Fields(title[:i])
tail := strings.TrimSpace(title[i+1:])
if len(head) < 2 || tail == "" {
return ""
}
pos := strings.TrimSuffix(strings.TrimPrefix(head[len(head)-1], "#"), ".")
if pos == "" {
return ""
}
for _, r := range pos {
if r < '0' || r > '9' {
return ""
}
}
return tail
}
46 changes: 46 additions & 0 deletions internal/indexer/newznab/words_series_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package newznab

import "testing"

func TestSeriesPositionTitle(t *testing.T) {
cases := []struct{ in, want string }{
{"Feral Mage 4: The Mining Company Contract", "The Mining Company Contract"},
{"Cradle Book 8: Wintersteel", "Wintersteel"},
{"Discworld #3: Equal Rites", "Equal Rites"},
{"Dungeon Crawler Carl 07: This Inevitable Ruin", "This Inevitable Ruin"},
{"The Wandering Inn Vol. 2.: Volume Two", "Volume Two"},
// No series position before the colon: an ordinary subtitle, left alone.
{"12 Rules for Life: An Antidote to Chaos", ""},
{"Dune: Messiah", ""},
{"The Lord of the Rings: The Fellowship of the Ring", ""},
// A bare number is a title, not a series with a position.
{"1984: A Novel", ""},
// Roman numerals are ambiguous with words ("I"), so they do not count.
{"Rocky IV: The Novel", ""},
{"Feral Mage 4:", ""},
{"No colon here 4", ""},
{"", ""},
}
for _, c := range cases {
if got := SeriesPositionTitle(c.in); got != c.want {
t.Errorf("SeriesPositionTitle(%q) = %q, want %q", c.in, got, c.want)
}
}
}

// TestTitleHasRelevantResult_SeriesPositionTitle covers the tier gate. A tier
// whose only results name the book's own title is relevant; without this it is
// read as a canned feed and the cascade walks on past it.
func TestTitleHasRelevantResult_SeriesPositionTitle(t *testing.T) {
q := "Feral Mage 4: The Mining Company Contract"
if !titleHasRelevantResult(q, []SearchResult{{Title: "The Mining Company Contract by Chase Kilgore [ENG / EPUB]"}}) {
t.Error("a release naming the book's own title should make the tier relevant")
}
if titleHasRelevantResult(q, []SearchResult{{Title: "Wulfe Untamed: Feral Warriors, Book 8 by Pamela Palmer"}}) {
t.Error("a release naming neither the series nor the book's own title must not")
}
// A one word book title is below the floor, so it does not open the gate.
if titleHasRelevantResult("Cradle Book 8: Wintersteel", []SearchResult{{Title: "Wintersteel by Someone"}}) {
t.Error("a one word series position title must not be enough on its own")
}
}
26 changes: 23 additions & 3 deletions internal/indexer/searcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,18 @@ func filterRelevantDetailed(results []newznab.SearchResult, title, author string
fullIdentityElided := titleIdentityWords(newznab.ElideApostrophes(title))
primaryIdentityElided := titleIdentityWords(newznab.ElideApostrophes(primaryTitle(title)))
authorKws := newznab.SigWords(author)
// A "Series N: Title" name also gets the book's own title as a reading,
// because releases name the book, not its series position ("The Mining
// Company Contract" for "Feral Mage 4: The Mining Company Contract"). It
// needs two significant words so a one word title cannot turn the author's
// whole catalogue into matches, and it is tried only after the full and
// primary readings both fail. See newznab.SeriesPositionTitle.
seriesTitle := newznab.SeriesPositionTitle(title)
seriesKws := newznab.SigWords(seriesTitle)
if len(seriesKws) < 2 || sameKws(seriesKws, fullKws) || sameKws(seriesKws, primaryKws) {
seriesTitle, seriesKws = "", nil
}
seriesIdentity := titleIdentityWords(seriesTitle)

authorTokenSets := latinAliasTokenSets(author, aliases)

Expand Down Expand Up @@ -951,7 +963,8 @@ func filterRelevantDetailed(results []newznab.SearchResult, title, author string
for i, r := range results {
n := normTitles[i]
if conflictingTitleAuthor(r.Title, title, authorTokenSets) ||
conflictingTitleAuthor(r.Title, primaryTitle(title), authorTokenSets) {
conflictingTitleAuthor(r.Title, primaryTitle(title), authorTokenSets) ||
(seriesTitle != "" && conflictingTitleAuthor(r.Title, seriesTitle, authorTokenSets)) {
dropped = append(dropped, drop(r, "release names a different author for this title"))
continue
}
Expand All @@ -972,12 +985,19 @@ func filterRelevantDetailed(results []newznab.SearchResult, title, author string
identityOK(identity, primaryIdentity, primaryIdentityElided) &&
volumeNumberAgrees(n, primaryTitle(title))
}
if fullOK || primaryOK {
seriesKeywords, seriesOK := false, false
if !fullOK && !primaryOK && len(seriesKws) > 0 {
seriesKeywords = tryMatch(n, seriesKws)
seriesOK = seriesKeywords &&
(len(seriesIdentity) < 2 || ContainsPhrase(identity, seriesIdentity)) &&
volumeNumberAgrees(n, seriesTitle)
}
if fullOK || primaryOK || seriesOK {
filtered = append(filtered, r)
continue
}
reason := "title/author keywords did not match release name"
if fullKeywords || primaryKeywords {
if fullKeywords || primaryKeywords || seriesKeywords {
// The keywords are all there but the release's title is not this
// one: other words sit between them, as "12 Rules for Life" inside
// "12 More Rules for Life", or a number differs, as "Volume 16"
Expand Down
83 changes: 83 additions & 0 deletions internal/indexer/series_position_title_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package indexer

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/vavallee/bindery/internal/indexer/newznab"
"github.com/vavallee/bindery/internal/models"
)

// A book stored as "Series N: Title" was only ever matched on the whole name or
// on the series half, so a release naming the book itself was dropped. This
// release is the one from the Discord report.
func TestFilterRelevant_SeriesPositionTitle(t *testing.T) {
const title, author = "Feral Mage 4: The Mining Company Contract", "Chase Kilgore"
cases := []struct {
release string
want bool
}{
{"The Mining Company Contract by Chase Kilgore [ENG / EPUB] [VIP]", true},
{"Chase Kilgore - The Mining Company Contract (epub)", true},
// Already matched before this change, and still does.
{"Chase Kilgore - Feral Mage 4 - The Mining Company Contract (epub)", true},
// The author is still required.
{"The Mining Company Contract by Someone Else [ENG / EPUB]", false},
{"The Mining Company Contract [ENG / EPUB]", true},
// A different book by the same author.
{"The Silver Company Contract by Chase Kilgore [ENG / EPUB]", false},
// Words present but split by words the title does not have.
{"The Mining Company Breach of Contract by Chase Kilgore", false},
{"Wulfe Untamed: Feral Warriors, Book 8 by Pamela Palmer [ENG / MP3]", false},
}
for _, f := range relevanceFilters {
for _, c := range cases {
t.Run(f.name+"/"+c.release, func(t *testing.T) {
got := f.fn([]newznab.SearchResult{{Title: c.release}}, title, author, nil)
if (len(got) == 1) != c.want {
t.Fatalf("%s kept=%v, want %v for %q", f.name, len(got) == 1, c.want, c.release)
}
})
}
}
}

// A one word book title after a series position is below the floor, so the
// fallback reading does not open up every release by the author that contains
// that word.
func TestFilterRelevant_SeriesPositionTitleNeedsTwoWords(t *testing.T) {
got := filterRelevant([]newznab.SearchResult{{Title: "Wintersteel Anthology by Will Wight"}},
"Cradle Book 8: Wintersteel", "Will Wight", nil)
for _, r := range got {
t.Errorf("kept %q through a one word series position title", r.Title)
}
}

// Drives the production entry points so the reading is proven on the paths
// that search and grab, not only on the filter function.
func TestSeriesPositionTitle_ReachesProductionEntrypoints(t *testing.T) {
const right = "The Mining Company Contract by Chase Kilgore [ENG / EPUB] [VIP]"
const wrong = "Wulfe Untamed: Feral Warriors, Book 8 by Pamela Palmer [ENG / MP3]"
feed := feedOf(right, wrong)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(feed))
}))
defer srv.Close()

idxs := []models.Indexer{{ID: 1, Name: "test", URL: srv.URL, Enabled: true, Categories: []int{7020}}}
crit := MatchCriteria{Title: "Feral Mage 4: The Mining Company Contract", Author: "Chase Kilgore"}
ctx := context.Background()
debugged, _ := newTestSearcher().SearchBookWithDebug(ctx, idxs, crit)
outcomes, _ := newTestSearcher().SearchBookWithOutcomes(ctx, idxs, crit)
for name, got := range map[string][]string{
"SearchBookWithDebug": resultTitles(debugged),
"SearchBookWithOutcomes": resultTitles(outcomes),
} {
if len(got) != 1 || got[0] != right {
t.Errorf("%s kept %v, want only %q", name, got, right)
}
}
}
Loading