diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 7838d0cd..963e2899 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1315,8 +1315,9 @@ Apply per-indexer seed time and ratio preferences from Prowlarr when sending tor | Variable | Description | Type | Default | |----------|-------------|------|---------| | `NEWZNAB_ENABLED` | Enable searching for books via a Newznab-compatible indexer | boolean | `false` | -| `NEWZNAB_URL` | Base URL of your Newznab indexer or aggregator | string | _none_ | -| `NEWZNAB_API_KEY` | Your Newznab API key (leave blank if not required) | string (secret) | _none_ | +| `NEWZNAB_INDEXERS` | Named Newznab connections. Each row accepts `name`, `url`, and `api_key`. | JSON array | `[]` | +| `NEWZNAB_URL` | Legacy single-indexer URL, used when `NEWZNAB_INDEXERS` is empty | string | _none_ | +| `NEWZNAB_API_KEY` | Legacy single-indexer API key | string (secret) | _none_ | | `NEWZNAB_EBOOK_CATEGORIES` | Newznab category IDs searched for ebooks. Most indexers use the standard 7000, but some use custom IDs. Leave empty to use 7000. | string (comma-separated) | `7000` | | `NEWZNAB_AUDIOBOOK_CATEGORIES` | Newznab category IDs searched for audiobooks. Most indexers use the standard 3030, but some use custom IDs. Leave empty to use 3030. | string (comma-separated) | `3030` | | `NEWZNAB_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` | @@ -1333,21 +1334,36 @@ Enable searching for books via a Newznab-compatible indexer - **Type:** boolean - **Default:** `false` +#### `NEWZNAB_INDEXERS` + +**Named Indexers** + +Configure multiple named Newznab-compatible indexers. The name is shown beside each search result. For environment-based configuration, provide a JSON array: + +```json +[ + {"name":"NZBGeek","url":"https://api.nzbgeek.info","api_key":"..."}, + {"name":"DrunkenSlug","url":"https://drunkenslug.com","api_key":"..."} +] +``` + +- **Type:** JSON array +- **Default:** `[]` + #### `NEWZNAB_URL` -**Newznab URL** +**Legacy Newznab URL** -Base URL of your Newznab indexer or aggregator +Single-indexer fallback used only when `NEWZNAB_INDEXERS` is empty. - **Type:** string - **Default:** _none_ -- **Required:** Yes #### `NEWZNAB_API_KEY` -**API Key** +**Legacy API Key** -Your Newznab API key (leave blank if not required) +API key for the legacy Newznab URL. - **Type:** string (secret) - **Default:** _none_ diff --git a/shelfmark/download/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py index db3ca1e8..73f02923 100644 --- a/shelfmark/download/clients/sabnzbd.py +++ b/shelfmark/download/clients/sabnzbd.py @@ -248,6 +248,15 @@ class SABnzbdClient(DownloadClient): if trusted_url and _url_origin(trusted_url) == target_origin: return True + named_indexers = config.get("NEWZNAB_INDEXERS", []) + if isinstance(named_indexers, list): + for row in named_indexers: + if not isinstance(row, dict): + continue + trusted_url = normalize_http_config_url(row.get("url")) + if trusted_url and _url_origin(trusted_url) == target_origin: + return True + return False def _get_prowlarr_headers(self, url: str) -> dict: diff --git a/shelfmark/release_sources/newznab/settings.py b/shelfmark/release_sources/newznab/settings.py index 3450781f..4f764528 100644 --- a/shelfmark/release_sources/newznab/settings.py +++ b/shelfmark/release_sources/newznab/settings.py @@ -8,6 +8,7 @@ from shelfmark.core.settings_registry import ( HeadingField, PasswordField, SettingsField, + TableField, TagListField, TextField, register_settings, @@ -16,12 +17,36 @@ from shelfmark.core.utils import normalize_http_url def _test_newznab_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: - """Test the Newznab connection using current form values.""" + """Test all named Newznab connections, or the legacy connection as fallback.""" from shelfmark.core.config import config from shelfmark.release_sources.newznab.api import NewznabClient + from shelfmark.release_sources.newznab.source import _parse_indexer_rows current_values = current_values or {} + raw_indexers = current_values.get("NEWZNAB_INDEXERS") + if raw_indexers is None: + raw_indexers = config.get("NEWZNAB_INDEXERS", []) + indexers = _parse_indexer_rows(raw_indexers) + + if indexers: + details: list[str] = [] + all_successful = True + for name, url, api_key in indexers: + try: + success, message = NewznabClient(url, api_key).test_connection() + except Exception as e: # noqa: BLE001 — surface unexpected errors to the UI + success, message = False, f"Connection failed: {e!s}" + all_successful = all_successful and success + details.append(f"{name}: {message}") + + summary = ( + f"Connected to all {len(indexers)} indexers" + if all_successful + else "One or more Newznab indexers failed" + ) + return {"success": all_successful, "message": summary, "details": details} + raw_url = str(current_values.get("NEWZNAB_URL") or config.get("NEWZNAB_URL", "") or "") api_key = str(current_values.get("NEWZNAB_API_KEY") or config.get("NEWZNAB_API_KEY", "") or "") @@ -64,25 +89,60 @@ def newznab_config_settings() -> list[SettingsField]: default=False, description="Enable searching for books via a Newznab-compatible indexer", ), + TableField( + key="NEWZNAB_INDEXERS", + label="Named Indexers", + description=( + "Add each Newznab-compatible indexer separately. The configured name is shown " + "beside every result from that indexer." + ), + columns=[ + { + "key": "name", + "label": "Name", + "type": "text", + "placeholder": "NZBGeek", + }, + { + "key": "url", + "label": "URL", + "type": "text", + "placeholder": "https://api.nzbgeek.info", + }, + { + "key": "api_key", + "label": "API Key", + "type": "password", + "placeholder": "Optional", + }, + ], + default=[], + add_label="Add Indexer", + empty_message=( + "No named indexers configured. The legacy single-indexer fields below are used " + "as a fallback." + ), + show_when={"field": "NEWZNAB_ENABLED", "value": True}, + ), TextField( key="NEWZNAB_URL", - label="Newznab URL", - description="Base URL of your Newznab indexer or aggregator", + label="Legacy Newznab URL", + description="Used only when the named indexer list is empty", placeholder="http://nzbhydra:5076", - required=True, + required=False, show_when={"field": "NEWZNAB_ENABLED", "value": True}, ), PasswordField( key="NEWZNAB_API_KEY", - label="API Key", - description="Your Newznab API key (leave blank if not required)", + label="Legacy API Key", + description="Used only with the legacy Newznab URL", required=False, show_when={"field": "NEWZNAB_ENABLED", "value": True}, ), ActionButton( key="test_newznab", - label="Test Connection", - description="Verify your Newznab configuration", + label="Test Connections", + description="Verify every named indexer, or the legacy connection when the list is empty", style="primary", callback=_test_newznab_connection, show_when={"field": "NEWZNAB_ENABLED", "value": True}, diff --git a/shelfmark/release_sources/newznab/source.py b/shelfmark/release_sources/newznab/source.py index 8f258545..43425406 100644 --- a/shelfmark/release_sources/newznab/source.py +++ b/shelfmark/release_sources/newznab/source.py @@ -4,7 +4,10 @@ from __future__ import annotations import re import time +from dataclasses import dataclass +from hashlib import sha256 from typing import TYPE_CHECKING, ClassVar +from urllib.parse import urlparse if TYPE_CHECKING: from shelfmark.core.search_plan import ReleaseSearchPlan @@ -48,6 +51,50 @@ _DEFAULT_BOOK_CATS = [7000] NEWZNAB_SEARCH_TIMEOUT_SECONDS = _SEARCH_TIMEOUT +@dataclass(frozen=True) +class _NamedClient: + """A configured Newznab connection and its stable cache namespace.""" + + name: str + connection_id: str + client: NewznabClient + + +def _parse_indexer_rows(raw: object) -> list[tuple[str, str, str]]: + """Normalize structured Newznab indexer settings. + + Invalid/incomplete rows are ignored so one partially edited row cannot disable + the other configured indexers. + """ + if not isinstance(raw, list): + return [] + + indexers: list[tuple[str, str, str]] = [] + seen_connections: set[tuple[str, str]] = set() + for row in raw: + if not isinstance(row, dict): + continue + raw_url = str(row.get("url") or "").strip() + url = normalize_http_url(raw_url) + if not url: + if raw_url: + logger.warning("Newznab: ignoring indexer row with invalid URL '%s'", raw_url) + continue + + api_key = str(row.get("api_key") or "").strip() + connection_key = (url, api_key) + if connection_key in seen_connections: + continue + seen_connections.add(connection_key) + + configured_name = str(row.get("name") or "").strip() + hostname = urlparse(url).hostname or "" + name = configured_name or hostname or "Newznab" + indexers.append((name, url, api_key)) + + return indexers + + def _parse_category_ids(raw: object) -> list[int]: """Parse a configured category setting into Newznab category IDs. @@ -146,8 +193,11 @@ def _newznab_result_to_release( else None ) - # Build source_id from GUID - source_id = result.get("guid") or f"newznab:{hash(raw_title)}" + # Namespace IDs from named connections so identical GUIDs returned by two + # indexers cannot overwrite one another in the private release cache. + raw_source_id = result.get("guid") or f"newznab:{hash(raw_title)}" + connection_id = str(result.get("_newznab_connection_id") or "").strip() + source_id = f"newznab:{connection_id}:{raw_source_id}" if connection_id else raw_source_id # Cache the raw result for the handler cache_release(source_id, result) @@ -272,6 +322,7 @@ class NewznabSource(ReleaseSource): ) def _get_client(self) -> NewznabClient | None: + """Build the legacy single-indexer client.""" raw_url = str(config.get("NEWZNAB_URL", "") or "") api_key = str(config.get("NEWZNAB_API_KEY", "") or "") @@ -284,6 +335,28 @@ class NewznabSource(ReleaseSource): return NewznabClient(url, api_key or "") + def _get_clients(self) -> list[_NamedClient]: + """Build named clients, falling back to the legacy single connection.""" + configured = _parse_indexer_rows(config.get("NEWZNAB_INDEXERS", [])) + if configured: + clients: list[_NamedClient] = [] + for name, url, api_key in configured: + digest = sha256(f"{name}\0{url}\0{api_key}".encode()).hexdigest()[:16] + clients.append( + _NamedClient( + name=name, + connection_id=digest, + client=NewznabClient(url, api_key), + ) + ) + return clients + + legacy_client = self._get_client() + if legacy_client is None: + return [] + legacy_name = str(config.get("NEWZNAB_NAME", "") or "").strip() or "Newznab" + return [_NamedClient(name=legacy_name, connection_id="legacy", client=legacy_client)] + def search( self, book: BookMetadata, @@ -293,8 +366,8 @@ class NewznabSource(ReleaseSource): content_type: str = "ebook", ) -> list[Release]: """Search the Newznab indexer for releases matching the book.""" - client = self._get_client() - if not client: + clients = self._get_clients() + if not clients: logger.warning("Newznab not configured - skipping search") return [] @@ -324,40 +397,60 @@ class NewznabSource(ReleaseSource): all_results: list[dict] = [] try: - for idx, query in enumerate(queries, start=1): - _check_timeout() - if len(queries) > 1: - logger.debug("Newznab query %d/%d: '%s'", idx, len(queries), query) + for connection in clients: + try: + for idx, query in enumerate(queries, start=1): + _check_timeout() + if len(queries) > 1: + logger.debug( + "Newznab [%s] query %d/%d: '%s'", + connection.name, + idx, + len(queries), + query, + ) - raw = client.search(query=query, categories=categories) + raw = connection.client.search(query=query, categories=categories) - # Auto-expand: retry without category filter if no results - if not raw and categories and auto_expand: - _check_timeout() - logger.info( - "Newznab: no results for '%s' with category filter, auto-expanding", - query, - ) - raw = client.search(query=query, categories=None) + # Auto-expand: retry without category filter if no results + if not raw and categories and auto_expand: + _check_timeout() + logger.info( + "Newznab [%s]: no results for '%s' with category filter, " + "auto-expanding", + connection.name, + query, + ) + raw = connection.client.search(query=query, categories=None) - for r in raw: - key = ( - r.get("guid") - or r.get("downloadUrl") - or f"{r.get('indexer')}:{r.get('title')}" - ) - if key in seen_keys: - continue - seen_keys.add(key) - all_results.append(r) + for raw_result in raw: + r = dict(raw_result) + # Aggregators can identify the underlying indexer. Plain feeds + # generally cannot, so use the user-configured connection name. + r["indexer"] = r.get("indexer") or connection.name + r["_newznab_connection_id"] = connection.connection_id + key = ( + connection.connection_id, + r.get("guid") + or r.get("downloadUrl") + or f"{r.get('indexer')}:{r.get('title')}", + ) + if key in seen_keys: + continue + seen_keys.add(key) + all_results.append(r) + except TimeoutError: + raise + except Exception: + logger.exception("Newznab search failed for %s", connection.name) except TimeoutError as e: logger.warning("Newznab search timed out: %s", e) - except Exception: - logger.exception("Newznab search failed") - return [] results = [_newznab_result_to_release(r, content_type, categories) for r in all_results] + if plan.indexers: + selected_indexers = set(plan.indexers) + results = [r for r in results if r.indexer in selected_indexers] if results: nzb_count = sum(1 for r in results if r.protocol == ReleaseProtocol.NZB) @@ -379,5 +472,7 @@ class NewznabSource(ReleaseSource): def is_available(self) -> bool: if not config.get("NEWZNAB_ENABLED", False): return False + if _parse_indexer_rows(config.get("NEWZNAB_INDEXERS", [])): + return True url = normalize_http_url(str(config.get("NEWZNAB_URL", "") or "")) return bool(url) diff --git a/src/frontend/src/components/settings/fields/TableField.tsx b/src/frontend/src/components/settings/fields/TableField.tsx index 4e35fda0..4f47191e 100644 --- a/src/frontend/src/components/settings/fields/TableField.tsx +++ b/src/frontend/src/components/settings/fields/TableField.tsx @@ -353,12 +353,12 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps ); } - // text/path + // text/password/path return (