feat(newznab): support multiple named indexers (#1271)

## Summary

- add a named Newznab indexer table with per-indexer URL and API key
settings
- search every configured indexer and retain the originating indexer
name on each result
- namespace cached release IDs across connections and isolate individual
indexer failures
- preserve the legacy single-indexer settings as a fallback
- support masked API-key cells and trusted SABnzbd prefetching for named
indexers

## Validation

- 121 Newznab and SABnzbd backend tests passed on Python 3.14
- Ruff passed for all changed Python files
- frontend TypeScript and strict lint checks passed
- all 134 frontend unit tests passed
- frontend formatting check passed

## Compatibility

Existing `NEWZNAB_URL` and `NEWZNAB_API_KEY` configurations continue to
work whenever `NEWZNAB_INDEXERS` is empty.

Co-authored-by: Ryan <zab1996@users.noreply.github.com>
This commit is contained in:
zab1996
2026-08-27 00:29:58 -04:00
committed by GitHub
co-authored by Ryan
parent ff06a1a581
commit 02b7e9d958
9 changed files with 480 additions and 48 deletions
+78
View File
@@ -0,0 +1,78 @@
"""Tests for named Newznab indexer settings."""
from shelfmark.core.settings_registry import TableField
from shelfmark.release_sources.newznab.settings import (
_test_newznab_connection,
newznab_config_settings,
)
def test_settings_include_named_indexer_table():
field = next(
field
for field in newznab_config_settings()
if getattr(field, "key", None) == "NEWZNAB_INDEXERS"
)
assert isinstance(field, TableField)
assert [column["key"] for column in field.columns] == ["name", "url", "api_key"]
assert field.columns[2]["type"] == "password"
def test_connection_action_tests_every_named_indexer(monkeypatch):
import shelfmark.release_sources.newznab.api as api_module
tested: list[tuple[str, str]] = []
class FakeClient:
def __init__(self, url, api_key):
tested.append((url, api_key))
def test_connection(self):
return True, "Connected"
monkeypatch.setattr(api_module, "NewznabClient", FakeClient)
result = _test_newznab_connection(
{
"NEWZNAB_INDEXERS": [
{"name": "NZBGeek", "url": "https://geek.example", "api_key": "one"},
{"name": "DrunkenSlug", "url": "https://slug.example", "api_key": "two"},
]
}
)
assert result == {
"success": True,
"message": "Connected to all 2 indexers",
"details": ["NZBGeek: Connected", "DrunkenSlug: Connected"],
}
assert tested == [("https://geek.example", "one"), ("https://slug.example", "two")]
def test_connection_action_reports_each_failure(monkeypatch):
import shelfmark.release_sources.newznab.api as api_module
class FakeClient:
def __init__(self, url, _api_key):
self.url = url
def test_connection(self):
if "down" in self.url:
return False, "Could not connect"
return True, "Connected"
monkeypatch.setattr(api_module, "NewznabClient", FakeClient)
result = _test_newznab_connection(
{
"NEWZNAB_INDEXERS": [
{"name": "Working", "url": "https://working.example"},
{"name": "Unavailable", "url": "https://down.example"},
]
}
)
assert result["success"] is False
assert result["message"] == "One or more Newznab indexers failed"
assert result["details"] == ["Working: Connected", "Unavailable: Could not connect"]
+134
View File
@@ -1,5 +1,6 @@
"""Unit tests for the Newznab release source."""
from dataclasses import replace
from unittest.mock import MagicMock
import pytest
@@ -11,6 +12,7 @@ from shelfmark.release_sources.newznab.source import (
NewznabSource,
_newznab_result_to_release,
_parse_category_ids,
_parse_indexer_rows,
)
# ── fixtures / helpers ─────────────────────────────────────────────────────────
@@ -221,6 +223,19 @@ class TestIsAvailable:
monkeypatch.setattr(mod.config, "get", self._config(NEWZNAB_URL=""))
assert NewznabSource().is_available() is False
def test_available_with_named_indexer_and_no_legacy_url(self, monkeypatch):
import shelfmark.release_sources.newznab.source as mod
monkeypatch.setattr(
mod.config,
"get",
self._config(
NEWZNAB_URL="",
NEWZNAB_INDEXERS=[{"name": "NZBGeek", "url": "https://geek.example"}],
),
)
assert NewznabSource().is_available() is True
# ── category parsing ───────────────────────────────────────────────────────────
@@ -247,6 +262,42 @@ class TestParseCategoryIds:
assert _parse_category_ids(" ") == []
class TestParseIndexerRows:
def test_parses_named_connections(self):
assert _parse_indexer_rows(
[
{
"name": "NZBGeek",
"url": "https://api.nzbgeek.info/",
"api_key": "geek-key",
},
{
"name": "DrunkenSlug",
"url": "drunkenslug.com",
"api_key": "slug-key",
},
]
) == [
("NZBGeek", "https://api.nzbgeek.info", "geek-key"),
("DrunkenSlug", "http://drunkenslug.com", "slug-key"),
]
def test_uses_hostname_when_name_is_blank(self):
assert _parse_indexer_rows([{"url": "https://indexer.example.com"}]) == [
("indexer.example.com", "https://indexer.example.com", "")
]
def test_ignores_invalid_and_duplicate_connections(self):
assert _parse_indexer_rows(
[
None,
{"name": "Incomplete", "url": ""},
{"name": "First", "url": "https://indexer.example.com", "api_key": "key"},
{"name": "Duplicate", "url": "https://indexer.example.com", "api_key": "key"},
]
) == [("First", "https://indexer.example.com", "key")]
# ── NewznabSource.search ───────────────────────────────────────────────────────
@@ -449,6 +500,89 @@ class TestSearch:
call_kwargs = client.search.call_args
assert call_kwargs[1]["query"] == "9780441013593"
def test_searches_all_named_indexers_and_labels_plain_feed_results(self, monkeypatch):
import shelfmark.release_sources.newznab.source as mod
rows = [
{"name": "NZBGeek", "url": "https://geek.example", "api_key": "one"},
{"name": "DrunkenSlug", "url": "https://slug.example", "api_key": "two"},
]
monkeypatch.setattr(
mod.config,
"get",
self._fake_config(NEWZNAB_INDEXERS=rows),
)
clients = {}
def client_factory(url, api_key):
client = MagicMock()
client.search.return_value = [
_make_result(
guid="shared-guid",
downloadUrl=f"{url}/download?apikey={api_key}",
indexer=None,
)
]
clients[url] = client
return client
monkeypatch.setattr(mod, "NewznabClient", client_factory)
results = NewznabSource().search(_make_book(), _make_plan(_make_book()))
assert {release.indexer for release in results} == {"NZBGeek", "DrunkenSlug"}
assert len({release.source_id for release in results}) == 2
assert all(release.source_id.startswith("newznab:") for release in results)
assert set(clients) == {"https://geek.example", "https://slug.example"}
def test_indexer_filter_is_applied_to_named_results(self, monkeypatch):
import shelfmark.release_sources.newznab.source as mod
rows = [
{"name": "NZBGeek", "url": "https://geek.example"},
{"name": "DrunkenSlug", "url": "https://slug.example"},
]
monkeypatch.setattr(mod.config, "get", self._fake_config(NEWZNAB_INDEXERS=rows))
def client_factory(url, _api_key):
client = MagicMock()
client.search.return_value = [
_make_result(guid=f"{url}/guid", indexer=None)
]
return client
monkeypatch.setattr(mod, "NewznabClient", client_factory)
book = _make_book()
plan = replace(_make_plan(book), indexers=["DrunkenSlug"])
results = NewznabSource().search(book, plan)
assert [release.indexer for release in results] == ["DrunkenSlug"]
def test_one_named_indexer_failure_does_not_hide_other_results(self, monkeypatch):
import shelfmark.release_sources.newznab.source as mod
rows = [
{"name": "Unavailable", "url": "https://down.example"},
{"name": "Working", "url": "https://working.example"},
]
monkeypatch.setattr(mod.config, "get", self._fake_config(NEWZNAB_INDEXERS=rows))
def client_factory(url, _api_key):
client = MagicMock()
if "down" in url:
client.search.side_effect = RuntimeError("offline")
else:
client.search.return_value = [_make_result(indexer=None)]
return client
monkeypatch.setattr(mod, "NewznabClient", client_factory)
results = NewznabSource().search(_make_book(), _make_plan(_make_book()))
assert [release.indexer for release in results] == ["Working"]
def test_exception_in_client_returns_empty(self, monkeypatch):
client = MagicMock()
client.search.side_effect = RuntimeError("boom")
+34
View File
@@ -654,6 +654,40 @@ class TestSABnzbdClientAddDownload:
assert "https://attacker.example/download.nzb" not in called_urls
assert called_urls == ["http://localhost:8080/api"]
def test_add_download_prefetches_named_newznab_indexer_url(self, monkeypatch):
"""Named Newznab origins should receive the same backend prefetch as legacy URLs."""
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "books",
"NEWZNAB_INDEXERS": [
{"name": "NZBGeek", "url": "https://geek.example", "api_key": "secret"}
],
}
monkeypatch.setattr(
"shelfmark.download.clients.sabnzbd.config.get",
lambda key, default="": config_values.get(key, default),
)
from shelfmark.download.clients.sabnzbd import SABnzbdClient
with (
patch.object(SABnzbdClient, "_fetch_nzb_content", return_value=b"nzbdata") as fetch,
patch.object(
SABnzbdClient,
"_api_post_file",
return_value={"status": True, "nzo_ids": ["SABnzbd_nzo_named"]},
),
):
client = SABnzbdClient()
result = client.add_download(
"https://geek.example/download.nzb?apikey=secret",
"Test Book",
)
assert result == "SABnzbd_nzo_named"
fetch.assert_called_once_with("https://geek.example/download.nzb?apikey=secret")
class TestSABnzbdClientRemove:
"""Tests for SABnzbdClient.remove()."""