diff --git a/docs/environment-variables.md b/docs/environment-variables.md index de509f8b..f9ceed48 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1348,6 +1348,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480 | `PROWLARR_EBOOK_MAX_SIZE_MB` | Hide ebook results larger than this. Expanded searches and general indexers can return TV series, courses and software next to the books. 0 shows everything, and a result with no reported size is always shown. Audiobook searches are not limited. | number | `0` | | `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` | | `PROWLARR_COLLAPSE_DUPLICATES` | Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible. | boolean | `true` | +| `PROWLARR_HIDE_UNSUPPORTED_FORMATS` | Hide a result whose title ends in a file extension that is not in Supported Book Formats, such as a .doc or .rtf file. Downloading it would only fail at post-processing. Titles without an extension, archives and audiobook searches are never hidden. | boolean | `true` | | `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` | | `PROWLARR_MAM_ID` | The mam_id value MyAnonamouse shows when you create a session. MAM locks each session to one IP or ASN, so reusing Prowlarr's or another client's session often fails with a 403: you will likely need a separate session for Shelfmark if it reaches MAM from another IP (different host, VPN or proxy) or the existing session is ASN-locked to another network. Leave empty to turn enrichment off. | string (secret) | _none_ | @@ -1430,6 +1431,15 @@ Collapse a release that several indexer entries returned down to a single row, k - **Type:** boolean - **Default:** `true` +#### `PROWLARR_HIDE_UNSUPPORTED_FORMATS` + +**Hide results in formats you did not enable** + +Hide a result whose title ends in a file extension that is not in Supported Book Formats, such as a .doc or .rtf file. Downloading it would only fail at post-processing. Titles without an extension, archives and audiobook searches are never hidden. + +- **Type:** boolean +- **Default:** `true` + #### `PROWLARR_USE_SEED_PREFERENCES` **Use Prowlarr seed preferences** diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 41c1159f..b1608139 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -259,6 +259,18 @@ def prowlarr_config_settings() -> list[SettingsField]: ), show_when={"field": "PROWLARR_ENABLED", "value": True}, ), + CheckboxField( + key="PROWLARR_HIDE_UNSUPPORTED_FORMATS", + label="Hide results in formats you did not enable", + default=True, + description=( + "Hide a result whose title ends in a file extension that is not in Supported " + "Book Formats, such as a .doc or .rtf file. Downloading it would only fail at " + "post-processing. Titles without an extension, archives and audiobook " + "searches are never hidden." + ), + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), CheckboxField( key="PROWLARR_USE_SEED_PREFERENCES", label="Use Prowlarr seed preferences", diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index dc6fc3b3..ad12e69a 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -348,6 +348,64 @@ AUDIOBOOK_FORMATS = list(CORE_AUDIOBOOK_FORMATS) # Combined list for format detection (audiobook formats first for priority) ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS +# A trailing file extension on a release title, e.g. "Author - Title.doc". +_TITLE_EXTENSION_PATTERN = re.compile(r"\.([a-z][a-z0-9]{1,4})\s*$") + +# Extensions that are known to be files. Only these are hidden, so dotted titles +# like "Jane.Eyre.by.Bronte" are not read as a ".bronte" file, and archive +# containers (zip, rar, 7z) are never hidden. +_KNOWN_FILE_EXTENSIONS = frozenset(ALL_BOOK_FORMATS) | { + "azw4", + "chm", + "djv", + "doc", + "docx", + "htm", + "html", + "kfx", + "lrf", + "lrx", + "md", + "odt", + "oxps", + "prc", + "rtf", + "snb", + "tcr", + "tpz", + "wps", + "xps", +} + +# Same default as the Supported Book Formats setting and post-processing. +_DEFAULT_SUPPORTED_FORMATS = ("epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr") + + +def _unsupported_title_extension(title: str, content_type: str) -> str | None: + """Return the trailing extension of a title when it is a format the user did not enable. + + Indexers that are not enriched return plain titles, so a single-file release + such as "Author - Title.doc" is only recognisable by its extension. Picking it + would download a file that post-processing then discards. + """ + if content_type == "audiobook": + return None + match = _TITLE_EXTENSION_PATTERN.search(str(title or "").lower()) + if not match: + return None + extension = match.group(1) + if extension not in _KNOWN_FILE_EXTENSIONS: + return None + configured = config.get("SUPPORTED_FORMATS", None) + if isinstance(configured, str): + configured = configured.split(",") + if not isinstance(configured, (list, tuple, set)) or not configured: + configured = _DEFAULT_SUPPORTED_FORMATS + supported = {str(fmt).strip().lower() for fmt in configured} + if extension in supported: + return None + return extension + # Backend safeguard: cap total Prowlarr search time per request. PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0 @@ -1298,6 +1356,8 @@ class ProwlarrSource(ReleaseSource): # 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 + hide_unsupported_formats = config.get("PROWLARR_HIDE_UNSUPPORTED_FORMATS", True) + hidden_unsupported = 0 max_bytes = _max_release_bytes(content_type) oversized = 0 @@ -1319,6 +1379,18 @@ class ProwlarrSource(ReleaseSource): content_type, enable_format_detection=is_enriched, ) + if hide_unsupported_formats: + unsupported_extension = _unsupported_title_extension( + release.title, content_type + ) + if unsupported_extension: + hidden_unsupported += 1 + logger.debug( + "Prowlarr: hiding '%s', .%s is not a supported format", + release.title, + unsupported_extension, + ) + continue if idx_id_int is not None and idx_id_int in indexer_priority: release.extra["indexer_priority"] = indexer_priority[idx_id_int] results.append(release) @@ -1332,6 +1404,11 @@ class ProwlarrSource(ReleaseSource): mam_releases.append(release) mam_indexer = mam_indexer or mam_indexers[idx_id_int] + if hidden_unsupported: + logger.info( + "Prowlarr: hid %s result(s) in a format that is not enabled", + hidden_unsupported, + ) if oversized: logger.debug("Prowlarr: hid %s result(s) over the ebook size limit", oversized) diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 45e1ed66..c661b256 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -26,6 +26,7 @@ from shelfmark.release_sources.prowlarr.source import ( _release_identity, _result_dedup_key, _search_budget_seconds, + _unsupported_title_extension, ) from shelfmark.release_sources.prowlarr.utils import ( build_source_id, @@ -1537,6 +1538,89 @@ class TestProwlarrStaleIndexerSelection: assert fake_client.searched_indexer_ids == [] +class TestHideUnsupportedFormats: + """Plain indexer titles that end in a format the user did not enable are hidden.""" + + SUPPORTED = ["epub", "mobi", "azw3"] + + def _patch_config(self, monkeypatch, overrides=None): + import shelfmark.release_sources.prowlarr.source as prowlarr_source + + values = { + "PROWLARR_INDEXERS": "", + "PROWLARR_AUTO_EXPAND": False, + "SUPPORTED_FORMATS": self.SUPPORTED, + **(overrides or {}), + } + monkeypatch.setattr( + prowlarr_source.config, "get", lambda key, default=None: values.get(key, default) + ) + + @pytest.mark.parametrize( + ("title", "expected"), + [ + ("Иван Вазов - Под игото.doc", "doc"), + ("Author - Title.RTF", "rtf"), + ("Author - Title.pdf ", "pdf"), + ("Author - Title.epub", None), + ("Author - Title [EPUB]", None), + ("Jane.Eyre.by.Bronte", None), + ("Author - Title.rar", None), + ("Author - Title.zip", None), + ("Author - Title", None), + ("", None), + ], + ) + def test_unsupported_title_extension(self, monkeypatch, title, expected): + self._patch_config(monkeypatch) + assert _unsupported_title_extension(title, "ebook") == expected + + def test_audiobook_searches_are_not_filtered(self, monkeypatch): + self._patch_config(monkeypatch) + assert _unsupported_title_extension("Author - Title.doc", "audiobook") is None + + def test_comma_separated_supported_formats(self, monkeypatch): + self._patch_config(monkeypatch, {"SUPPORTED_FORMATS": "epub, doc"}) + assert _unsupported_title_extension("Author - Title.doc", "ebook") is None + assert _unsupported_title_extension("Author - Title.rtf", "ebook") == "rtf" + + def _search(self, monkeypatch, overrides=None): + self._patch_config(monkeypatch, overrides) + fake_client = FakeTorznabClient( + search_results=[ + { + "guid": f"result-{index}", + "protocol": "torrent", + "title": title, + "magnetUrl": f"magnet:?xt=urn:btih:abc{index}", + "indexerId": 1, + "indexer": "Zamunda", + } + for index, title in enumerate( + ["Иван Вазов - Под игото.epub", "Иван Вазов - Под игото.doc"] + ) + ] + ) + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: fake_client) + book = BookMetadata( + provider="hardcover", provider_id="123", title="Под игото", authors=["Иван Вазов"] + ) + + from shelfmark.core.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["bg"]) + return source.search(book, plan, content_type="ebook") + + def test_search_hides_unsupported_format(self, monkeypatch): + releases = self._search(monkeypatch) + assert [release.title for release in releases] == ["Иван Вазов - Под игото.epub"] + + def test_search_keeps_unsupported_format_when_disabled(self, monkeypatch): + releases = self._search(monkeypatch, {"PROWLARR_HIDE_UNSUPPORTED_FORMATS": False}) + assert len(releases) == 2 + + class TestEbookMaxSize: """PROWLARR_EBOOK_MAX_SIZE_MB hides oversized ebook results and nothing else (#844)."""