diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index f77ae6d9..e0025dc7 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -41,6 +41,8 @@ from shelfmark.release_sources.prowlarr.api import ( ) from shelfmark.release_sources.prowlarr.cache import cache_release from shelfmark.release_sources.prowlarr.utils import ( + AUTHOR_UNKNOWN, + author_affinity, build_source_id, coerce_float_like, coerce_int_like, @@ -1008,10 +1010,17 @@ class ProwlarrSource(ReleaseSource): if time.monotonic() > deadline: _raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s") - def search_indexers( - query: str, cats: list[int] | None, *, enriched_query: str | None = None - ) -> _IndexerSearchOutcome: - """Search indexers with given categories via Torznab/Newznab.""" + def search_indexers(query: str, cats: list[int] | None) -> _IndexerSearchOutcome: + """Search indexers with given categories via Torznab/Newznab. + + Every indexer gets the same title-only query. Enriched indexers used + to be sent "{title} {author}", but an indexer that ANDs its search + terms (MyAnonamouse) returns nothing whenever the metadata provider + spells the author differently to the tracker - "Timothy Ferriss" vs + "Tim Ferriss" - and the UI reports the book as missing (#1293). The + author still decides ordering below, where a spelling difference + costs a release its position rather than its existence. + """ outcome = _IndexerSearchOutcome(results=[]) target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats) if not target_indexer_ids: @@ -1019,16 +1028,11 @@ class ProwlarrSource(ReleaseSource): for indexer_id in target_indexer_ids: _check_timeout() - indexer_query = ( - enriched_query - if indexer_id in enriched_indexer_ids_set and enriched_query - else query - ) outcome.attempted += 1 try: raw = client.torznab_search( indexer_id=indexer_id, - query=indexer_query, + query=query, categories=cats, search_type="book", ) @@ -1053,14 +1057,11 @@ class ProwlarrSource(ReleaseSource): for idx, variant in enumerate(variants, start=1): _check_timeout() query = variant.title - enriched_query = variant.query # title + author if len(variants) > 1: logger.debug("Prowlarr query %s/%s: '%s'", idx, len(variants), query) - outcome = search_indexers( - query=query, cats=categories, enriched_query=enriched_query - ) + outcome = search_indexers(query=query, cats=categories) # Auto-expand: if no results with categories and auto-expand enabled, retry without. # Only when every indexer actually answered: a failed search says nothing about @@ -1077,9 +1078,7 @@ class ProwlarrSource(ReleaseSource): "Prowlarr: no results for query '%s' with category filter, auto-expanding search", query, ) - expanded = search_indexers( - query=query, cats=None, enriched_query=enriched_query - ) + expanded = search_indexers(query=query, cats=None) outcome.results = expanded.results outcome.attempted += expanded.attempted outcome.failed += expanded.failed @@ -1117,6 +1116,10 @@ class ProwlarrSource(ReleaseSource): results: list[Release] = [] enriched_source_ids: set[str] = set() + affinity_by_source_id: dict[str, int] = {} + # A manual query is the user's own words; ranking it against the + # metadata author would second-guess what they typed. + wanted_author = "" if plan.manual_query else plan.author for raw_result in all_results: result_with_seed_settings = _apply_indexer_seed_settings( @@ -1136,13 +1139,20 @@ class ProwlarrSource(ReleaseSource): if idx_id_int is not None and idx_id_int in indexer_priority: release.extra["indexer_priority"] = indexer_priority[idx_id_int] results.append(release) + affinity_by_source_id[release.source_id] = author_affinity( + wanted_author, release.extra.get("author") + ) if is_enriched: enriched_source_ids.add(release.source_id) + # Indexer priority first: it is an explicit user preference. Author + # agreement then orders what one indexer returned, so the editions that + # match the requested author lead and the rest stay reachable below. results.sort( key=lambda r: ( _release_indexer_rank(r, indexer_priority), + affinity_by_source_id.get(r.source_id, AUTHOR_UNKNOWN), 0 if r.source_id in enriched_source_ids else 1, ) ) diff --git a/shelfmark/release_sources/prowlarr/utils.py b/shelfmark/release_sources/prowlarr/utils.py index 1afdddfa..c5b1b516 100644 --- a/shelfmark/release_sources/prowlarr/utils.py +++ b/shelfmark/release_sources/prowlarr/utils.py @@ -14,6 +14,20 @@ if TYPE_CHECKING: _INTEGER_LIKE_PATTERN = re.compile(r"^[+-]?\d+$") _FLOAT_LIKE_PATTERN = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$") +_AUTHOR_TOKEN_PATTERN = re.compile(r"\w+", re.UNICODE) +_AUTHOR_NOISE_TOKENS = frozenset( + {"jr", "sr", "ii", "iii", "iv", "phd", "md", "dr", "mr", "mrs", "ms", "et", "al", "and", "the"} +) + +# Ordering tiers for author agreement between the requested book and what an +# indexer reported. Lower sorts first. +AUTHOR_MATCH = 0 +AUTHOR_UNKNOWN = 1 +AUTHOR_MISMATCH = 2 + +# A mononym ("Homer") can only ever agree on one token; a longer name needs a +# given name and a surname to agree before it counts as the same person. +_AUTHOR_TOKENS_REQUIRED = 2 def coerce_int_like(value: object) -> int | None: @@ -32,6 +46,49 @@ def coerce_int_like(value: object) -> int | None: return int(normalized) +def _author_tokens(value: object) -> list[str]: + """Split an author string into comparable lowercase name tokens.""" + if not isinstance(value, str): + return [] + tokens = [token.lower() for token in _AUTHOR_TOKEN_PATTERN.findall(value)] + return [token for token in tokens if token not in _AUTHOR_NOISE_TOKENS] + + +def _author_tokens_compatible(wanted: str, offered: str) -> bool: + """Treat an abbreviated given name as the name it abbreviates.""" + return wanted == offered or wanted.startswith(offered) or offered.startswith(wanted) + + +def author_affinity(wanted: object, offered: object) -> int: + """Rank how far an indexer's author field is from the requested author. + + Shelfmark ranks on this rather than filtering on it, so a wrong verdict only + costs a release its position in the list, never its visibility. That is what + makes the loose token comparison safe: "Tim"/"Timothy" and "T."/"Timothy" + agree, while a transliteration ("Dostoevsky"/"Dostoyevsky") is merely sorted + last instead of being hidden. + + Three-way on purpose: an indexer that reports no author at all must not sort + below one that reports a wrong author, so "no metadata" ranks between + agreement and disagreement rather than counting as either. + """ + wanted_tokens = _author_tokens(wanted) + offered_tokens = _author_tokens(offered) + if not wanted_tokens or not offered_tokens: + return AUTHOR_UNKNOWN + + matched = sum( + 1 + for wanted_token in wanted_tokens + if any( + _author_tokens_compatible(wanted_token, offered_token) + for offered_token in offered_tokens + ) + ) + required = min(_AUTHOR_TOKENS_REQUIRED, len(wanted_tokens)) + return AUTHOR_MATCH if matched >= required else AUTHOR_MISMATCH + + def build_source_id(result: dict) -> str: """Build the Release.source_id for a raw Prowlarr result. diff --git a/tests/prowlarr/test_author_matching.py b/tests/prowlarr/test_author_matching.py new file mode 100644 index 00000000..0b92382f --- /dev/null +++ b/tests/prowlarr/test_author_matching.py @@ -0,0 +1,186 @@ +"""Author agreement ranks Prowlarr results; it never narrows the query (#1293). + +MyAnonamouse - the only indexer Shelfmark treats as enriched - used to receive +"{title} {author}". MAM ANDs its search terms, so any difference between the +metadata provider's author spelling and the tracker's ("Timothy Ferriss" vs +"Tim Ferriss") returned nothing at all and the UI reported the book as missing. +""" + +import pytest + +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources.prowlarr.source import ProwlarrSource +from shelfmark.release_sources.prowlarr.utils import ( + AUTHOR_MATCH, + AUTHOR_MISMATCH, + AUTHOR_UNKNOWN, + author_affinity, +) + +MAM_INDEXER_ID = 1 + + +class TestAuthorAffinity: + @pytest.mark.parametrize( + ("wanted", "offered"), + [ + ("Timothy Ferriss", "Tim Ferriss"), + ("Tim Ferriss", "Timothy Ferriss"), + ("T. Ferriss", "Timothy Ferriss"), + ("Ursula K. Le Guin", "Ursula Le Guin"), + ("Iain M. Banks", "Iain Banks"), + ("Frank Herbert", "Frank Herbert, Brian Herbert"), + ("Robert Jordan Jr.", "Robert Jordan"), + ("homer", "Homer"), + ], + ) + def test_same_author_spelled_differently_agrees(self, wanted, offered): + assert author_affinity(wanted, offered) == AUTHOR_MATCH + + @pytest.mark.parametrize( + ("wanted", "offered"), + [ + ("Timothy Ferriss", "Frank Herbert"), + ("Frank Herbert", "Brian Herbert"), + ("Homer", "Virgil"), + ], + ) + def test_different_author_disagrees(self, wanted, offered): + assert author_affinity(wanted, offered) == AUTHOR_MISMATCH + + @pytest.mark.parametrize( + ("wanted", "offered"), + [ + ("Timothy Ferriss", None), + ("Timothy Ferriss", ""), + ("", "Tim Ferriss"), + (None, "Tim Ferriss"), + ("Timothy Ferriss", {"name": "Tim Ferriss"}), + ], + ) + def test_missing_metadata_is_neither_agreement_nor_disagreement(self, wanted, offered): + # An indexer that reports no author must not sort below one that reports + # the wrong author, so this tier sits between the two. + assert author_affinity(wanted, offered) == AUTHOR_UNKNOWN + assert AUTHOR_MATCH < AUTHOR_UNKNOWN < AUTHOR_MISMATCH + + def test_a_surname_alone_is_not_enough_for_a_full_name(self): + # "Ferriss" appearing under some other given name is a different person. + assert author_affinity("Timothy Ferriss", "Bruce Ferriss") == AUTHOR_MISMATCH + + +class _EnrichedIndexerClient: + """Stands in for a Prowlarr with MyAnonamouse enabled.""" + + def __init__(self, search_results=None): + self.queries: list[str] = [] + self.search_results = search_results or [] + self.indexer_timeout = 90 + + def get_enabled_indexers_detailed(self, *, raise_on_error=False): + del raise_on_error + return [ + { + "id": MAM_INDEXER_ID, + "enable": True, + "implementation": "MyAnonamouse", + "capabilities": { + "categories": [ + {"id": 7000, "subCategories": []}, + {"id": 3030, "subCategories": []}, + ] + }, + } + ] + + def torznab_search( + self, *, indexer_id, query, categories=None, search_type="book", limit=100, offset=0 + ): + del indexer_id, categories, search_type, limit, offset + self.queries.append(query) + return self.search_results + + def get_enriched_indexer_ids(self, restrict_to=None, indexers=None): + del restrict_to, indexers + return [MAM_INDEXER_ID] + + def get_indexer_seed_settings(self, restrict_to=None): + del restrict_to + return {} + + +def _mam_result(guid: str, author: str | None) -> dict: + return { + "guid": guid, + "title": "The Tao of Seneca", + "author": author, + "indexerId": MAM_INDEXER_ID, + "indexer": "MyAnonamouse", + "protocol": "torrent", + "size": 1048576, + "seeders": 10, + "leechers": 1, + "categories": [{"id": 7020}], + "infoUrl": f"https://tracker.example/{guid}", + } + + +def _search(monkeypatch, client, *, manual_query=None): + import shelfmark.release_sources.prowlarr.source as prowlarr_source + from shelfmark.core.search_plan import build_release_search_plan + + values = {"PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False} + monkeypatch.setattr( + prowlarr_source.config, "get", lambda key, default=None: values.get(key, default) + ) + + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: client) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="The Tao of Seneca", + authors=["Timothy Ferriss"], + ) + plan = build_release_search_plan(book, languages=["en"], manual_query=manual_query) + return source.search(book, plan, content_type="ebook") + + +class TestEnrichedIndexerQuery: + def test_enriched_indexer_is_queried_without_the_author(self, monkeypatch): + client = _EnrichedIndexerClient() + + _search(monkeypatch, client) + + assert client.queries == ["The Tao of Seneca"] + assert not any("Ferriss" in query for query in client.queries) + + +class TestAuthorOrdering: + def test_matching_author_leads_and_mismatch_stays_visible(self, monkeypatch): + client = _EnrichedIndexerClient( + search_results=[ + _mam_result("other-author", "Frank Herbert"), + _mam_result("no-author", None), + _mam_result("right-author", "Tim Ferriss"), + ] + ) + + results = _search(monkeypatch, client) + + # Ranked, not filtered: the wrong author is last but still reachable. + assert [r.extra["author"] for r in results] == ["Tim Ferriss", None, "Frank Herbert"] + + def test_manual_query_is_not_reordered_against_the_metadata_author(self, monkeypatch): + client = _EnrichedIndexerClient( + search_results=[ + _mam_result("other-author", "Frank Herbert"), + _mam_result("right-author", "Tim Ferriss"), + ] + ) + + results = _search(monkeypatch, client, manual_query="tao seneca") + + assert client.queries == ["tao seneca"] + assert [r.extra["author"] for r in results] == ["Frank Herbert", "Tim Ferriss"]