fix(newznab): make indexer book categories configurable (#1214)

Newznab searches hardcoded category 7000 for ebooks and 3030 for
audiobooks,
so indexers using custom IDs returned no results or the wrong ones. Add
NEWZNAB_EBOOK_CATEGORIES and NEWZNAB_AUDIOBOOK_CATEGORIES (tag lists,
defaulting to 7000 and 3030) and resolve the search categories from
config.

Values are parsed leniently — list or comma/whitespace separated,
non-numeric
entries skipped, duplicates dropped — and fall back to the standard IDs
when
empty, so a cleared field can't silently widen the search to every
category.
NEWZNAB_AUTO_EXPAND remains the way to do that on purpose.

Results carrying a custom ID outside the standard 7000-7999 / 3030
ranges were
typed as "other", which routed custom-category audiobooks as ebooks.
Trust the
searched content type when a result carries a category we explicitly
asked for.

Also drop the unused NEWZNAB_BOOKS / NEWZNAB_AUDIOBOOKS constants from
api.py —
a third copy of the same hardcoding.

Closes #1208
This commit is contained in:
CaliBrain
2026-08-15 11:48:10 -04:00
committed by GitHub
parent 58a5b5ed27
commit 2b8b35bb52
5 changed files with 206 additions and 16 deletions
+76
View File
@@ -10,6 +10,7 @@ from shelfmark.release_sources import ReleaseProtocol
from shelfmark.release_sources.newznab.source import (
NewznabSource,
_newznab_result_to_release,
_parse_category_ids,
)
# ── fixtures / helpers ─────────────────────────────────────────────────────────
@@ -100,6 +101,18 @@ class TestResultToRelease:
r = _newznab_result_to_release(_make_result(categories=[]), "audiobook")
assert r.content_type == "audiobook"
def test_custom_searched_category_treated_as_book(self):
r = _newznab_result_to_release(_make_result(categories=[8010]), "ebook", [8010])
assert r.content_type == "book"
def test_custom_searched_category_treated_as_audiobook(self):
r = _newznab_result_to_release(_make_result(categories=[{"id": 3040}]), "audiobook", [3040])
assert r.content_type == "audiobook"
def test_unsearched_out_of_range_category_stays_other(self):
r = _newznab_result_to_release(_make_result(categories=[2000]), "ebook", [8010])
assert r.content_type == "other"
def test_freeleech_flag_detected_via_download_volume(self):
r = _newznab_result_to_release(_make_result(downloadVolumeFactor=0.0))
assert r.extra["freeleech"] is True
@@ -209,6 +222,31 @@ class TestIsAvailable:
assert NewznabSource().is_available() is False
# ── category parsing ───────────────────────────────────────────────────────────
class TestParseCategoryIds:
def test_parses_list_of_strings(self):
assert _parse_category_ids(["7100", "7120"]) == [7100, 7120]
def test_parses_comma_separated_string(self):
assert _parse_category_ids("7100, 7120") == [7100, 7120]
def test_parses_comma_separated_entry_inside_list(self):
assert _parse_category_ids(["7100,7120", "8010"]) == [7100, 7120, 8010]
def test_drops_duplicates_and_keeps_order(self):
assert _parse_category_ids(["7120", "7100", "7120"]) == [7120, 7100]
def test_skips_non_numeric_and_non_positive_values(self):
assert _parse_category_ids(["books", "0", "-7000", "7100"]) == [7100]
def test_returns_empty_for_unset_or_blank(self):
assert _parse_category_ids(None) == []
assert _parse_category_ids([]) == []
assert _parse_category_ids(" ") == []
# ── NewznabSource.search ───────────────────────────────────────────────────────
@@ -276,6 +314,44 @@ class TestSearch:
_, kwargs = client.search.call_args
assert kwargs["categories"] == [3030]
def test_searches_with_configured_ebook_categories(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(
monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": ["7100", "7120"]}
)
book = _make_book()
src.search(book, _make_plan(book), content_type="ebook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [7100, 7120]
def test_searches_with_configured_audiobook_categories(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUDIOBOOK_CATEGORIES": "3040"})
book = _make_book()
src.search(book, _make_plan(book), content_type="audiobook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [3040]
def test_falls_back_to_default_when_categories_cleared(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": []})
book = _make_book()
src.search(book, _make_plan(book), content_type="ebook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [7000]
def test_ebook_categories_do_not_affect_audiobook_search(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": ["7100"]})
book = _make_book()
src.search(book, _make_plan(book), content_type="audiobook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [3030]
def test_expand_search_removes_categories(self, monkeypatch):
client = MagicMock()
client.search.return_value = []