diff --git a/shelfmark/core/author_match.py b/shelfmark/core/author_match.py new file mode 100644 index 00000000..d8e82153 --- /dev/null +++ b/shelfmark/core/author_match.py @@ -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] diff --git a/shelfmark/release_sources/irc/source.py b/shelfmark/release_sources/irc/source.py index 9e9b51f6..3820eee3 100644 --- a/shelfmark/release_sources/irc/source.py +++ b/shelfmark/release_sources/irc/source.py @@ -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, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index a7c23921..51cb9116 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -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, diff --git a/shelfmark/release_sources/prowlarr/utils.py b/shelfmark/release_sources/prowlarr/utils.py index f6ca58d0..a6330e03 100644 --- a/shelfmark/release_sources/prowlarr/utils.py +++ b/shelfmark/release_sources/prowlarr/utils.py @@ -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. diff --git a/tests/core/test_search_author_narrowing.py b/tests/core/test_search_author_narrowing.py index d1326bdc..e85491fa 100644 --- a/tests/core/test_search_author_narrowing.py +++ b/tests/core/test_search_author_narrowing.py @@ -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(): diff --git a/tests/irc/test_search_query.py b/tests/irc/test_search_query.py new file mode 100644 index 00000000..8021a371 --- /dev/null +++ b/tests/irc/test_search_query.py @@ -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"] diff --git a/tests/irc/test_source.py b/tests/irc/test_source.py index afce5e99..f8f059f3 100644 --- a/tests/irc/test_source.py +++ b/tests/irc/test_source.py @@ -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) diff --git a/tests/prowlarr/test_author_matching.py b/tests/prowlarr/test_author_matching.py index 0b92382f..d782a4e9 100644 --- a/tests/prowlarr/test_author_matching.py +++ b/tests/prowlarr/test_author_matching.py @@ -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