mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-28 16:05:03 +01:00
"David Petrie" as "D. Petrie", then ranked the answer by the full name to recover the precision the surname gave up. The two halves disagreed. author_affinity needs two agreeing tokens before it calls a name the same person, so "Petrie" - the name on the filenames a surname search exists to reach - matched one and came back AUTHOR_MISMATCH. It therefore sorted below "Unknown" and level with "Gordon Petrie", a different author who merely shares the surname. The widened query pulled those rows in and the ranker buried them. Falling short of agreement is now separated from disagreeing with it. A name whose every token fits the one asked for is an abbreviation of it and ranks AUTHOR_PARTIAL, between agreement and "no author reported"; a name carrying a token that fits nothing still ranks AUTHOR_MISMATCH. Nothing that agreed before changes tier - "Homer"/"Homer Simpson" is still a match, since the extra token must not demote a mononym that already met its one-token requirement - so Prowlarr's #1293 ordering is unchanged except that a tracker listing a bare surname stops being read as the wrong author. Measured on the issue's own case, wanted "David Petrie": before: D Petrie, Unknown, Petrie, Gordon Petrie after: D Petrie, Petrie, Unknown, Gordon Petrie Second fix, same release: a book with no title posted the surname on its own. _build_query fell back to book.search_title or book.title, which is empty on exactly the path where the plan has no title variants, so the line reaching the channel was "@search Petrie" - not a search for anything, and the kind of bare over-broad post is_available refuses unaddressed queries to avoid. It now returns "" and the existing "No search query could be built" guard takes it. Tested with make python-lint, python-format, python-dead-code, python-typecheck and python-test.
221 lines
8.4 KiB
Python
221 lines
8.4 KiB
Python
"""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"
|
|
|
|
def test_a_book_with_no_title_is_not_searched_by_surname_alone(self):
|
|
# "@search Petrie" asks the bot for every Petrie it holds. It is not a
|
|
# search for anything, and a bare over-broad line posted to a public
|
|
# channel is what gets the nick banned - so no query is built at all.
|
|
book = BookMetadata(provider="hardcover", provider_id="1", title="", authors=["D. Petrie"])
|
|
plan = build_release_search_plan(book)
|
|
|
|
assert plan.title_variants == []
|
|
assert plan.author == "D. Petrie"
|
|
assert IRCReleaseSource()._build_query(book, plan) == ""
|
|
|
|
|
|
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"),
|
|
# The shape the surname query exists to reach: the channel filed this
|
|
# under the surname alone, so it agrees with everything known about
|
|
# "David Petrie" and contradicts none of it.
|
|
_release("Petrie"),
|
|
_release("D Petrie"),
|
|
]
|
|
|
|
ranked = source._rank_by_author(releases, "David Petrie")
|
|
|
|
assert [release.extra["author"] for release in ranked] == [
|
|
"D Petrie",
|
|
"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"]
|