Files
Zoltán Szabó 35037b35fd 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.
2026-09-11 22:14:37 -04:00

422 lines
15 KiB
Python

import time
from types import SimpleNamespace
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import Release
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()
results = [
SearchResult(
server="AudioBot",
author="Author Name",
title="Archive Release",
format="zip",
size="1.2GB",
full_line="!AudioBot Author Name - Archive Release.zip ::INFO:: 1.2GB",
),
SearchResult(
server="AudioBot",
author="Author Name",
title="Direct Release",
format="m4b",
size="900MB",
full_line="!AudioBot Author Name - Direct Release.m4b ::INFO:: 900MB",
),
]
releases = source._convert_to_releases(results, content_type="audiobook")
assert [release.format for release in releases] == ["m4b", "zip"]
assert all(release.content_type == "audiobook" for release in releases)
def test_search_uses_cached_results_without_opening_a_connection(monkeypatch):
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
cached_release = Release(
source="irc",
source_id="cached-line",
title="Cached Result",
)
monkeypatch.setattr(source, "is_available", lambda: True)
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, ""),
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: {
"releases": [cached_release],
"online_servers": ["AudioBot"],
},
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("cache hit should skip IRC connection")
),
)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
book = BookMetadata(provider="hardcover", provider_id="123", title="Cached Book")
plan = _plan("Cached Book")
releases = source.search(book, plan)
assert releases == [cached_release]
assert source._online_servers == {"AudioBot"}
def test_search_no_dcc_offer_releases_connection_and_caches_empty_result(monkeypatch):
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
cache_calls: list[dict[str, object]] = []
released_clients: list[object] = []
class FakeClient:
online_servers = {"AudioBot"}
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
# A search bot is required; make config report one so the channel send path runs.
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, ""),
)
# Ensure no leftover send budget from a previous test blocks the send.
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.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda cache_key, title, releases, *, online_servers=None: cache_calls.append(
{
"cache_key": cache_key,
"title": title,
"releases": releases,
"online_servers": online_servers,
}
),
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: client,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda released_client: released_clients.append(released_client),
)
book = BookMetadata(provider="hardcover", provider_id="abc", title="Missing Result")
plan = _plan("Missing Result")
releases = source.search(book, plan, content_type="audiobook")
assert releases == []
assert released_clients == [client]
# One query maps to one cache entry (the whole, empty answer), keyed by server:channel:query.
assert cache_calls == [
{
"cache_key": "irc.example.net:ebooks:missing result",
"title": "Missing Result",
"releases": [],
"online_servers": ["AudioBot"],
}
]
def test_audiobook_search_routes_to_configured_audiobook_channel_and_bot(monkeypatch):
"""An audiobook request uses the audiobook channel/bot when configured."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
connection_kwargs: dict[str, object] = {}
cache_calls: list[dict[str, object]] = []
class FakeClient:
online_servers: set[str] = set()
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
"IRC_AUDIOBOOK_CHANNEL": "bookz",
"IRC_AUDIOBOOK_SEARCH_BOT": "audiosearch",
}.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.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda cache_key, title, releases, *, online_servers=None: cache_calls.append(
{"cache_key": cache_key}
),
)
def fake_get_connection(**kwargs: object) -> FakeClient:
connection_kwargs.update(kwargs)
return client
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
fake_get_connection,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
book = BookMetadata(provider="hardcover", provider_id="ab", title="Audio Book")
plan = _plan("Audio Book")
source.search(book, plan, content_type="audiobook")
# Joined the audiobook channel, addressed the audiobook search bot, and the cache
# key is namespaced to the audiobook channel (so it won't collide with the ebook one).
assert connection_kwargs["channel"] == "bookz"
assert client.channel == "#bookz"
assert client.message == "@audiosearch Audio Book"
assert cache_calls == [{"cache_key": "irc.example.net:bookz:audio book"}]
def test_audiobook_search_reuses_main_bot_when_only_channel_configured(monkeypatch):
"""With an audiobook channel but no audiobook bot, reuse the main search bot."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
class FakeClient:
online_servers: set[str] = set()
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
"IRC_AUDIOBOOK_CHANNEL": "bookz",
"IRC_AUDIOBOOK_SEARCH_BOT": "",
}.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.get_cached_results",
lambda cache_key, *_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: client,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
book = BookMetadata(provider="hardcover", provider_id="ab2", title="Audio Book")
plan = _plan("Audio Book")
source.search(book, plan, content_type="audiobook")
assert client.channel == "#bookz"
assert client.message == "@search Audio Book"
def test_search_without_search_bot_never_posts_to_channel(monkeypatch):
"""A bare (unaddressed) query must never reach the channel; refuse to connect."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
# Force is_available True so we exercise the in-search guard (defense in depth).
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "", # not configured
}.get(key, ""),
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("must not connect/post without a search bot")
),
)
book = BookMetadata(provider="hardcover", provider_id="nobot", title="No Bot")
plan = _plan("No Bot")
assert source.search(book, plan) == []
def test_recent_send_count_caps_and_windows():
"""The send budget counts identical queries and prunes entries outside the window."""
import shelfmark.release_sources.irc.source as irc_source
irc_source._recent_message_sends.clear()
key = irc_source._query_identity("irc.example.net", "ebooks", "Dubliners")
other = irc_source._query_identity("irc.example.net", "ebooks", "Ulysses")
assert irc_source._recent_send_count(key) == 0
for expected in range(1, irc_source.MAX_IDENTICAL_SENDS + 1):
irc_source._record_message_sent(key)
assert irc_source._recent_send_count(key) == expected
# A different query has its own independent budget.
assert irc_source._recent_send_count(other) == 0
# Timestamps older than the window are pruned and don't count.
stale = time.time() - irc_source.IDENTICAL_SEND_WINDOW_SECONDS - 10
irc_source._recent_message_sends[key] = [stale, stale]
assert irc_source._recent_send_count(key) == 0
def test_search_send_budget_blocks_repost_and_returns_cache(monkeypatch):
"""Once the exact query hit its per-window send limit, don't re-post; serve cache."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
cached_release = Release(source="irc", source_id="cached-line", title="Cached Result")
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: 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, ""),
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: {
"releases": [cached_release],
"online_servers": ["AudioBot"],
},
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("send budget should skip IRC connection")
),
)
# Exhaust the budget for this exact query on this server-channel.
irc_source._recent_message_sends.clear()
send_key = irc_source._query_identity("irc.example.net", "ebooks", "Budget Book")
for _ in range(irc_source.MAX_IDENTICAL_SENDS):
irc_source._record_message_sent(send_key)
book = BookMetadata(provider="hardcover", provider_id="cd", title="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)
assert releases == [cached_release]
assert source._online_servers == {"AudioBot"}