fix(prowlarr): hide results whose title extension is not a supported format (#1490)

Closes #1481

## Problem

Plain (non-enriched) indexers such as Zamunda, Knaben or LimeTorrents
return titles like `Иван Вазов - Под игото.doc`. They show next to the
epubs. Picking one downloads the file, then post-processing discards it
because `doc` is not in `SUPPORTED_FORMATS`.

## Change

- `_unsupported_title_extension()` reads a trailing file extension from
the release title. When it is a known file format that is not in
Supported Book Formats, the result is hidden.
- Kept: titles with no extension, `[EPUB]`-style tags, dotted titles
like `Jane.Eyre.by.Bronte` (only known file extensions count), archives
(`zip`, `rar`, `7z`), and audiobook searches.
- New Prowlarr setting `PROWLARR_HIDE_UNSUPPORTED_FORMATS` ("Hide
results in formats you did not enable"), default on.
- One info log line per search with the hidden count; a debug line per
hidden title.
- `docs/environment-variables.md`: added only the new setting's rows.
Running `scripts/generate_env_docs.py` on current `main` also changes
unrelated lines (`SHELFMARK_API_KEY_READONLY`, the slskd anchor), so I
left those alone.

## Tests

- New `TestHideUnsupportedFormats` in `tests/prowlarr/test_source.py`:
the extension helper (parametrised), audiobook bypass, comma-separated
config, and a full `search()` with the setting on and off.
- `tests/prowlarr`: 784 passed. Ruff lint/format, vulture and
basedpyright on the changed files are clean.
- Full suite on macOS: the same 84 bypass/entrypoint failures as on
`main` (browser extra and Linux permissions), none new.

I ran an earlier version of this as a local patch on v1.4.0 against a
real Prowlarr + Zamunda setup: the `.doc` row is gone and epubs stay.

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
This commit is contained in:
Desislav LesovandCaliBrain authored and GitHub committed 2026-10-21 10:26:02 -04:00
1 parent d42494c696
commit fc8a5b9230
4 files changed
+183

No files matched your search

+10
View File
@@ -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**
@@ -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",
@@ -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)
+84
View File
@@ -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)."""