fix(irc): search by surname, and rank the answer by author (#1331) (#1332)

Fixes #1331.

A search bot ANDs every term against a filename, so the given name is
the term
that empties the result set. Measured against irchighway's #ebooks:
"Revelations
David Petrie" is answered "no results", "Revelations Petrie" returns 9
matches,
6 of which parse, all filed as "D Petrie".

The query now carries the title and the surname, read off the search
variant so
the ISBN fallback and a manual query - which set author="" on purpose -
keep
their current shape.

Title-only, the shape #1295 settled on for Prowlarr, does not transfer:
the bot
caps an answer at 1000 matches, and a bare "Revelations" hits that cap
with 923
parsed rows across 500 authors, so the cap itself can drop the wanted
book. The
surname is the token the two spellings share and it keeps the answer
small.

The full author then orders what comes back, reusing author_affinity
from #1295,
since a surname also matches a different author who shares it. It sits
under
server availability the way indexer priority does in #1295: a download
addresses
one named bot and waits 120s for it, so a match from a bot that has left
the
channel must not outrank a mismatch that can answer. Ranking runs on the
way out
rather than before the cache, because one query identity is shared by
every book
that produced that query.

Two things found while testing:

- The parser writes the literal "Unknown" when a filename has no " - "
separator
  (parser.py:168). Ranked literally that sorts as a wrong author, so
author_affinity's middle tier was unreachable here; it is now read as
absent.
  5 of those 923 rows are affected.
- author_affinity moves to shelfmark/core/author_match.py, unchanged, so
IRC
does not import from the Prowlarr package. Prowlarr behaviour is
untouched and
  its tests pass as they are.

The three IRC assertions in the #1252 regression file move to the
surname form.
The invariant they pin - one contributor's name reaches the query, never
the
whole credit list - is unchanged.

Tested with make python-lint, python-format, python-dead-code,
python-typecheck
and python-test, and end to end against irchighway with the patched
source: it
posts "Revelations Petrie" and returns 6 releases.
This commit is contained in:
Zoltán Szabó
2026-09-11 22:14:37 -04:00
committed by GitHub
parent 8c902d7f7a
commit 35037b35fd
8 changed files with 391 additions and 93 deletions
+90
View File
@@ -0,0 +1,90 @@
"""Comparing and trimming author names for release search.
Lives in core because more than one release source needs it: Prowlarr ranks
results on author agreement (#1293), and IRC both trims the name it searches
for and ranks what comes back.
"""
import re
_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 _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 search_surname(author: object) -> str:
"""The one name token worth sending to a source that matches conjunctively.
Given names are where catalogues disagree - "David Petrie" is filed as
"D. Petrie", "Timothy" as "Tim" - so a query carrying one matches nothing on
a source that requires every term to appear. The surname is the token both
spellings share.
Keeps the author's own capitalisation, because the result is posted to a
public channel, and returns "" when no usable token is left so the caller
searches by title alone rather than by noise.
"""
if not isinstance(author, str):
return ""
tokens = [
token
for token in _AUTHOR_TOKEN_PATTERN.findall(author)
if token.lower() not in _AUTHOR_NOISE_TOKENS
]
if not tokens:
return ""
return tokens[-1]
+62 -21
View File
@@ -13,9 +13,9 @@ if TYPE_CHECKING:
from shelfmark.metadata_providers import BookMetadata
from shelfmark.api.websocket import ws_manager
from shelfmark.core.author_match import author_affinity, search_surname
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.search_plan import pick_search_author
from shelfmark.core.utils import is_audiobook
from shelfmark.release_sources import (
ColumnColorHint,
@@ -86,6 +86,18 @@ def _emit_status(message: str, phase: str = "searching") -> None:
)
def _reported_author(release: Release) -> str:
"""The author a result actually claims, with the parser's sentinel read as none.
A filename with no " - " separator has no author to report and parser.py:168
fills in "Unknown". Ranked literally that sorts as a wrong author, below every
result that named someone else; as absent it sorts between agreement and
disagreement, which is what the tier was built for.
"""
author = release.extra.get("author", "")
return "" if author == "Unknown" else author
# Rate limiting to avoid server throttling
MIN_SEARCH_INTERVAL = 15.0
_last_search_time: float = 0
@@ -227,12 +239,15 @@ class IRCReleaseSource(ReleaseSource):
logger.debug("IRC source is disabled, skipping search")
return []
# Build search query
query = plan.primary_query or self._build_query(book)
query = self._build_query(book, plan)
if not query:
logger.warning("No search query could be built")
return []
# 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
# Get IRC settings
server = _config_text("IRC_SERVER")
port = _config_port("IRC_PORT", 6697)
@@ -277,7 +292,9 @@ class IRCReleaseSource(ReleaseSource):
if cached:
_emit_status("Using cached results", phase="complete")
self._online_servers = set(cached.get("online_servers", []))
return self._filter_by_content_type(cached["releases"], requested)
return self._rank_by_author(
self._filter_by_content_type(cached["releases"], requested), wanted_author
)
# Anti-spam cap: the exact same query may only be POSTED a limited number of times
# per window, even via refresh. Beyond that, serve whatever is cached rather than
@@ -294,7 +311,9 @@ class IRCReleaseSource(ReleaseSource):
cached = get_cached_results(query_key)
if cached:
self._online_servers = set(cached.get("online_servers", []))
return self._filter_by_content_type(cached["releases"], requested)
return self._rank_by_author(
self._filter_by_content_type(cached["releases"], requested), wanted_author
)
return []
logger.info("IRC search: %s", query)
@@ -370,7 +389,12 @@ class IRCReleaseSource(ReleaseSource):
ebook_releases + audiobook_releases,
online_servers=online_servers,
)
releases = audiobook_releases if requested == "audiobook" else ebook_releases
# Ranked on the way out, never before the cache: one query identity is
# shared by every book that produced the same query, so the order has to
# follow the author asked for now, not the one that filled the cache.
releases = self._rank_by_author(
audiobook_releases if requested == "audiobook" else ebook_releases, wanted_author
)
except DCCError as e:
logger.exception("DCC error during search")
@@ -388,24 +412,41 @@ class IRCReleaseSource(ReleaseSource):
else:
return releases
def _build_query(self, book: BookMetadata) -> str:
"""Build search query from book metadata."""
parts = []
if book.search_title or book.title:
parts.append(book.search_title or book.title)
# Only ever the first author: both metadata fields can arrive holding every
# contributor joined with ", ", and an IRC query carrying an author plus two
# translators matches nothing. The choice between them - and the narrowing - is
# `pick_search_author`, shared with the search plan so this cannot drift from it
# again. See issue #1252.
author = pick_search_author(book)
if author:
parts.append(author)
def _build_query(self, book: BookMetadata, plan: ReleaseSearchPlan) -> str:
"""Build the line posted to the channel: a title, plus a surname.
Both come off the variant rather than the plan: an ISBN fallback variant
carries `author=""` on purpose (search_plan.py:246), and so does a manual
query, so reading `plan.author` here would append a surname to searches
that deliberately have none.
"""
variant = plan.title_variants[0] if plan.title_variants else None
title = variant.title if variant else (book.search_title or book.title)
author = variant.author if variant else plan.author
parts = [part for part in (title, search_surname(author)) if part]
return " ".join(parts)
def _rank_by_author(self, releases: list[Release], wanted_author: str) -> list[Release]:
"""Order releases by author agreement, under the server's availability.
A surname is a weak filter - it also matches a different author who shares
it - so the full name decides the order while the search bot decides the
set. Availability stays the outer key: downloading asks one named bot and
waits 120s for it (handler.py:133-139), so a release from a bot that is not
in the channel must not outrank one that can actually answer. Sorting is
stable, so format and server order survive inside each tier.
"""
if not wanted_author:
return releases
online = self._online_servers or set()
return sorted(
releases,
key=lambda release: (
0 if release.extra.get("server", "") in online else 1,
author_affinity(wanted_author, _reported_author(release)),
),
)
# Format priority for sorting (lower = higher priority)
EBOOK_FORMAT_PRIORITY: ClassVar[dict[str, int]] = {
"epub": 0,
+1 -2
View File
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.author_match import AUTHOR_UNKNOWN, author_affinity
from shelfmark.core.config import config
from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger
@@ -41,8 +42,6 @@ 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,
@@ -14,20 +14,6 @@ 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:
@@ -46,49 +32,6 @@ 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.
+13 -4
View File
@@ -81,12 +81,19 @@ def test_manual_query_is_untouched():
def test_irc_query_uses_one_author_too():
"""The IRC source builds its own query and had the same verbatim preference."""
"""The IRC source builds its own query and had the same verbatim preference.
It now posts the surname rather than the full name - a search bot ANDs its terms
against a filename - but the invariant this pins is unchanged: one contributor's
name reaches the query, never the whole credit list.
"""
from shelfmark.release_sources.irc.source import IRCReleaseSource
book = _book(search_author=JOINED, authors=[a.strip() for a in JOINED.split(",")])
assert IRCReleaseSource()._build_query(book) == "Blindness José Saramago"
query = IRCReleaseSource()._build_query(book, build_release_search_plan(book))
assert query == "Blindness Saramago"
def test_a_blank_leading_contributor_falls_back_instead_of_dropping_the_author():
@@ -105,7 +112,9 @@ def test_a_blank_leading_contributor_does_not_strand_the_irc_query_either():
book = _book(search_author=", Giovanni Pontiero", authors=["", "Giovanni Pontiero"])
assert IRCReleaseSource()._build_query(book) == "Blindness Giovanni Pontiero"
query = IRCReleaseSource()._build_query(book, build_release_search_plan(book))
assert query == "Blindness Pontiero"
def test_an_all_blank_author_leaves_a_clean_title_only_query():
@@ -115,7 +124,7 @@ def test_an_all_blank_author_leaves_a_clean_title_only_query():
book = _book(search_author=", ,", authors=["", " "])
assert _queries(book) == ["Blindness"]
assert IRCReleaseSource()._build_query(book) == "Blindness"
assert IRCReleaseSource()._build_query(book, build_release_search_plan(book)) == "Blindness"
def test_a_bare_string_in_authors_is_not_iterated_character_by_character():
+204
View File
@@ -0,0 +1,204 @@
"""What the IRC source posts to the channel, and how it orders the answer.
A search bot ANDs every term against a filename, so a given name the channel
spells differently returns nothing at all. Measured against irchighway's
#ebooks: "Revelations David Petrie" was answered "no results", while
"Revelations Petrie" returned 9 matches.
"""
import pytest
from shelfmark.core.author_match import search_surname
from shelfmark.core.search_plan import build_release_search_plan
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import Release
from shelfmark.release_sources.irc.source import IRCReleaseSource
def _release(author, server="OnlineBot"):
return Release(
source="irc",
source_id=f"line-{author}-{server}",
title="Revelations",
extra={"author": author, "server": server},
)
class TestSearchSurname:
@pytest.mark.parametrize(
("author", "expected"),
[
# The bug: the channel abbreviates the given name, so only the surname
# is common to both spellings.
("David Petrie", "Petrie"),
("D. Petrie", "Petrie"),
("Kurt Vonnegut Jr.", "Vonnegut"),
("Homer", "Homer"),
("", ""),
],
)
def test_trims_to_the_token_both_spellings_share(self, author, expected):
assert search_surname(author) == expected
def test_a_particle_is_dropped_with_the_given_names(self):
# "Le Guin" loses its particle, which costs precision, not matches: the
# remaining token is still a substring of the filename that the full name
# would have matched, and _rank_by_author restores the precision.
assert search_surname("Ursula K. Le Guin") == "Guin"
class TestQueryPostedToChannel:
def test_query_carries_the_surname_not_the_given_name(self):
# Through the real plan builder, so the query cannot drift from what
# build_release_search_plan actually produces.
book = BookMetadata(
provider="hardcover", provider_id="1", title="Revelations", authors=["David Petrie"]
)
query = IRCReleaseSource()._build_query(book, build_release_search_plan(book))
assert query == "Revelations Petrie"
def test_manual_query_is_posted_verbatim(self):
book = BookMetadata(
provider="hardcover", provider_id="1", title="Revelations", authors=["David Petrie"]
)
plan = build_release_search_plan(book, manual_query="revelations petrie epub")
query = IRCReleaseSource()._build_query(book, plan)
assert query == "revelations petrie epub"
def test_an_isbn_fallback_query_stays_authorless(self):
# The ISBN variant carries author="" on purpose; appending a surname would
# make an already-narrow query require the author in the filename too.
book = BookMetadata(
provider="hardcover",
provider_id="1",
title="",
isbn_13="9780306406157",
authors=["David Petrie"],
)
query = IRCReleaseSource()._build_query(book, build_release_search_plan(book))
assert query == "9780306406157"
def test_title_alone_when_no_author_is_known(self):
book = BookMetadata(provider="hardcover", provider_id="1", title="Beowulf")
assert IRCReleaseSource()._build_query(book, build_release_search_plan(book)) == "Beowulf"
class TestAuthorOrdersTheAnswer:
def test_requested_author_leads_and_the_rest_stay_visible(self):
source = IRCReleaseSource()
source._online_servers = {"OnlineBot"}
releases = [
_release("Gordon Petrie"),
# What the parser writes when a filename has no " - " separator; it
# must not sort below a result that named a different author.
_release("Unknown"),
_release("D Petrie"),
]
ranked = source._rank_by_author(releases, "David Petrie")
assert [release.extra["author"] for release in ranked] == [
"D Petrie",
"Unknown",
"Gordon Petrie",
]
def test_an_offline_server_does_not_outrank_one_that_can_answer(self):
# Downloading addresses one named bot and waits 120s for it, so a matching
# author on a bot that left the channel is worse than a mismatch that is there.
source = IRCReleaseSource()
source._online_servers = {"OnlineBot"}
releases = [
_release("Gordon Petrie", server="OnlineBot"),
_release("D Petrie", server="DeadBot"),
]
ranked = source._rank_by_author(releases, "David Petrie")
assert [release.extra["server"] for release in ranked] == ["OnlineBot", "DeadBot"]
def test_no_requested_author_leaves_the_order_the_bot_sent(self):
source = IRCReleaseSource()
releases = [_release("Gordon Petrie"), _release("D Petrie")]
assert source._rank_by_author(releases, "") == releases
def test_search_posts_the_surname_and_ranks_the_cached_answer(monkeypatch):
"""End to end: the line reaching the channel, and ordering on the cached path.
The cached path matters because one cache entry is keyed by query alone, so it
is shared by every book that produced that query - the order has to follow the
author being asked for now, not the one that filled the cache.
"""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
sent: list[str] = []
class FakeClient:
online_servers: set[str] = {"OnlineBot"}
def send_message(self, _channel: str, message: str) -> None:
sent.append(message)
def wait_for_dcc(self, **_kwargs: object) -> None:
return None
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
}.get(key, ""),
)
irc_source._recent_message_sends.clear()
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda *_args, **_kwargs: None,
)
book = BookMetadata(
provider="hardcover", provider_id="1", title="Revelations", authors=["David Petrie"]
)
source.search(book, build_release_search_plan(book), expand_search=True)
assert sent == ["@search Revelations Petrie"]
# Same query, now served from cache for the same author: the match leads.
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda *_args, **_kwargs: {
"releases": [_release("Gordon Petrie"), _release("D Petrie")],
"online_servers": ["OnlineBot"],
},
)
cached = source.search(book, build_release_search_plan(book))
assert [release.extra["author"] for release in cached] == ["D Petrie", "Gordon Petrie"]
+18 -6
View File
@@ -7,6 +7,18 @@ from shelfmark.release_sources.irc.parser import SearchResult
from shelfmark.release_sources.irc.source import IRCReleaseSource
def _plan(title, author="", manual_query=None):
"""A plan stub for tests that only need one to reach search().
Tests that care how a plan is built use build_release_search_plan instead.
"""
return SimpleNamespace(
title_variants=[SimpleNamespace(title=title, author=author)],
author=author,
manual_query=manual_query,
)
def test_convert_to_releases_marks_audiobook_results_and_sorts_audio_before_archives():
source = IRCReleaseSource()
source._online_servers = set()
@@ -73,7 +85,7 @@ def test_search_uses_cached_results_without_opening_a_connection(monkeypatch):
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
book = BookMetadata(provider="hardcover", provider_id="123", title="Cached Book")
plan = SimpleNamespace(primary_query="Cached Book")
plan = _plan("Cached Book")
releases = source.search(book, plan)
@@ -144,7 +156,7 @@ def test_search_no_dcc_offer_releases_connection_and_caches_empty_result(monkeyp
)
book = BookMetadata(provider="hardcover", provider_id="abc", title="Missing Result")
plan = SimpleNamespace(primary_query="Missing Result")
plan = _plan("Missing Result")
releases = source.search(book, plan, content_type="audiobook")
@@ -225,7 +237,7 @@ def test_audiobook_search_routes_to_configured_audiobook_channel_and_bot(monkeyp
)
book = BookMetadata(provider="hardcover", provider_id="ab", title="Audio Book")
plan = SimpleNamespace(primary_query="Audio Book")
plan = _plan("Audio Book")
source.search(book, plan, content_type="audiobook")
@@ -292,7 +304,7 @@ def test_audiobook_search_reuses_main_bot_when_only_channel_configured(monkeypat
)
book = BookMetadata(provider="hardcover", provider_id="ab2", title="Audio Book")
plan = SimpleNamespace(primary_query="Audio Book")
plan = _plan("Audio Book")
source.search(book, plan, content_type="audiobook")
@@ -332,7 +344,7 @@ def test_search_without_search_bot_never_posts_to_channel(monkeypatch):
)
book = BookMetadata(provider="hardcover", provider_id="nobot", title="No Bot")
plan = SimpleNamespace(primary_query="No Bot")
plan = _plan("No Bot")
assert source.search(book, plan) == []
@@ -400,7 +412,7 @@ def test_search_send_budget_blocks_repost_and_returns_cache(monkeypatch):
irc_source._record_message_sent(send_key)
book = BookMetadata(provider="hardcover", provider_id="cd", title="Budget Book")
plan = SimpleNamespace(primary_query="Budget Book")
plan = _plan("Budget Book")
# expand_search=True bypasses the top-level cache, forcing the budget path.
releases = source.search(book, plan, expand_search=True)
+3 -3
View File
@@ -8,14 +8,14 @@ metadata provider's author spelling and the tracker's ("Timothy Ferriss" vs
import pytest
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
from shelfmark.release_sources.prowlarr.utils import (
from shelfmark.core.author_match import (
AUTHOR_MATCH,
AUTHOR_MISMATCH,
AUTHOR_UNKNOWN,
author_affinity,
)
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
MAM_INDEXER_ID = 1