mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:01:05 +01:00
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:
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
@@ -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()."""
|
||||
|
||||
Reference in New Issue
Block a user