mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 08:51:11 +01:00
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:
@@ -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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user