mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:50:22 +01:00
Newznab capability (#867)
I've added a plugin using the same architecture as the prowlarr plugin to enable Newznab as a source. I've tested locally with nzbhydra2 and it all seems to work as intended. I've added some unit tests for this feature, and found that a couple of other unit tests weren't behaving so fixed those up while I was at it. I also ran all of the linters in the makefile against it and fixed those up, too, so hopefully this should be as clean and as compatible as it can be.
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
# - Transmission: http://localhost:9091 (admin / admin)
|
# - Transmission: http://localhost:9091 (admin / admin)
|
||||||
# - Deluge: http://localhost:8112 (password: deluge)
|
# - Deluge: http://localhost:8112 (password: deluge)
|
||||||
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
|
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
|
||||||
|
# - NZBHydra: http://localhost:5076 (no auth by default)
|
||||||
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
|
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
|
||||||
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
|
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
|
||||||
#
|
#
|
||||||
@@ -35,6 +36,7 @@ services:
|
|||||||
# - Transmission: http://transmission:9091
|
# - Transmission: http://transmission:9091
|
||||||
# - Deluge Web UI: http://deluge:8112
|
# - Deluge Web UI: http://deluge:8112
|
||||||
# - NZBGet: http://nzbget:6789
|
# - NZBGet: http://nzbget:6789
|
||||||
|
# - NZBHydra: http://nzbhydra:5076
|
||||||
# - SABnzbd: http://sabnzbd:8080
|
# - SABnzbd: http://sabnzbd:8080
|
||||||
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
|
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
|
||||||
ports:
|
ports:
|
||||||
@@ -60,6 +62,7 @@ services:
|
|||||||
- ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro
|
- ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- nzbget
|
- nzbget
|
||||||
|
- nzbhydra
|
||||||
- sabnzbd
|
- sabnzbd
|
||||||
- qbittorrent
|
- qbittorrent
|
||||||
- transmission
|
- transmission
|
||||||
@@ -180,3 +183,18 @@ services:
|
|||||||
- "50000:50000" # Incoming connections
|
- "50000:50000" # Incoming connections
|
||||||
- "6881:6881/udp"
|
- "6881:6881/udp"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nzbhydra:
|
||||||
|
image: lscr.io/linuxserver/nzbhydra2:latest
|
||||||
|
container_name: nzbhydra
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Europe/London
|
||||||
|
volumes:
|
||||||
|
- ./.local/test-clients/nzbhydra/config:/config
|
||||||
|
- ./.local/test-clients/downloads:/downloads
|
||||||
|
ports:
|
||||||
|
- 5076:5076
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ _BUILTIN_SOURCE_MODULES = (
|
|||||||
"shelfmark.release_sources.audiobookbay",
|
"shelfmark.release_sources.audiobookbay",
|
||||||
"shelfmark.release_sources.direct_download",
|
"shelfmark.release_sources.direct_download",
|
||||||
"shelfmark.release_sources.irc",
|
"shelfmark.release_sources.irc",
|
||||||
|
"shelfmark.release_sources.newznab",
|
||||||
"shelfmark.release_sources.prowlarr",
|
"shelfmark.release_sources.prowlarr",
|
||||||
)
|
)
|
||||||
_builtin_source_state = {"loaded": False}
|
_builtin_source_state = {"loaded": False}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Newznab release source plugin.
|
||||||
|
|
||||||
|
Integrates with any Newznab-compatible indexer or aggregator (e.g. NZBHydra2,
|
||||||
|
NZBGeek, Drunkenslug) to search for book releases via the standard Newznab API.
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
- NewznabSource: Search integration
|
||||||
|
- NewznabHandler: Download handling via configured usenet/torrent client
|
||||||
|
"""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
# Import submodules to trigger decorator registration
|
||||||
|
from shelfmark.release_sources.newznab import (
|
||||||
|
handler as handler,
|
||||||
|
)
|
||||||
|
from shelfmark.release_sources.newznab import (
|
||||||
|
settings as settings,
|
||||||
|
)
|
||||||
|
from shelfmark.release_sources.newznab import (
|
||||||
|
source as source,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import shared download clients/settings to trigger registration.
|
||||||
|
try:
|
||||||
|
import_module("shelfmark.download.clients")
|
||||||
|
import_module("shelfmark.download.clients.settings")
|
||||||
|
except ImportError as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger(__name__).debug("Download clients not loaded: %s", e)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Newznab API client - connects to any Newznab-compatible indexer or aggregator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from shelfmark.core.logger import setup_logger
|
||||||
|
from shelfmark.core.utils import normalize_http_url
|
||||||
|
from shelfmark.download.network import get_ssl_verify
|
||||||
|
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
# Newznab standard book category IDs
|
||||||
|
NEWZNAB_BOOKS = 7000
|
||||||
|
NEWZNAB_AUDIOBOOKS = 3030
|
||||||
|
|
||||||
|
|
||||||
|
class NewznabClient:
|
||||||
|
"""Client for any Newznab-compatible indexer API."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, api_key: str, timeout: int = 30) -> None:
|
||||||
|
self.base_url = normalize_http_url(url)
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self._session = requests.Session()
|
||||||
|
|
||||||
|
def _api_url(self) -> str:
|
||||||
|
"""Return the Newznab API endpoint URL."""
|
||||||
|
base = self.base_url.rstrip("/")
|
||||||
|
# Many indexers expose the API at /api; others at the root with ?page=rss.
|
||||||
|
# Prefer /api if the base URL doesn't already end with it.
|
||||||
|
if not base.endswith("/api"):
|
||||||
|
return base + "/api"
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _get(
|
||||||
|
self,
|
||||||
|
params: dict[str, Any],
|
||||||
|
*,
|
||||||
|
accept_xml: bool = False,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Make a GET request to the Newznab API endpoint."""
|
||||||
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
|
if self.api_key:
|
||||||
|
params["apikey"] = self.api_key
|
||||||
|
|
||||||
|
url = self._api_url()
|
||||||
|
logger.debug("Newznab API: GET %s params=%s", url, *params)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if accept_xml:
|
||||||
|
headers["Accept"] = "application/rss+xml, application/xml;q=0.9, */*;q=0.8"
|
||||||
|
|
||||||
|
response = self._session.get(
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=get_ssl_verify(url),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_connection(self) -> tuple[bool, str]:
|
||||||
|
"""Test connection via the capabilities endpoint. Returns (success, message)."""
|
||||||
|
logger.info("Testing Newznab connection to: %s", self.base_url)
|
||||||
|
try:
|
||||||
|
response = self._get({"t": "caps"})
|
||||||
|
# Caps endpoint returns XML; a 200 is sufficient to confirm connectivity.
|
||||||
|
# Try to extract the server title from the XML for a friendly message.
|
||||||
|
text = response.text or ""
|
||||||
|
title = "Newznab indexer"
|
||||||
|
# Try <server title="..."/> attribute (NZBHydra2 style), then <title> element
|
||||||
|
m = re.search(r'<server[^>]+title="([^"]+)"', text, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r"<title>([^<]+)</title>", text, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
title = m.group(1).strip()
|
||||||
|
logger.info("Newznab connection successful: %s", title)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, "Could not connect. Check the URL."
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
status = e.response.status_code if e.response is not None else "unknown"
|
||||||
|
if e.response is not None and e.response.status_code == 401:
|
||||||
|
return False, "Invalid API key"
|
||||||
|
return False, f"HTTP error {status}"
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return False, f"Connection failed: {e!s}"
|
||||||
|
else:
|
||||||
|
return True, f"Connected to {title}"
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
categories: list[int] | None = None,
|
||||||
|
search_type: str = "search",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Search the Newznab indexer and return parsed results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search string.
|
||||||
|
categories: Optional list of Newznab category IDs (e.g. [7000, 3030]).
|
||||||
|
search_type: Newznab search type ("search", "book", "audio").
|
||||||
|
limit: Max results to return.
|
||||||
|
offset: Result page offset.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result dicts shaped like Prowlarr JSON search results so that
|
||||||
|
the shared ``_prowlarr_result_to_release`` converter can process them.
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"t": search_type,
|
||||||
|
"q": query,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
if categories:
|
||||||
|
params["cat"] = ",".join(str(c) for c in categories)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._get(params, accept_xml=True)
|
||||||
|
results = parse_torznab_xml(response.text)
|
||||||
|
logger.debug("Newznab search '%s': %d results", query, len(results))
|
||||||
|
if not results:
|
||||||
|
preview = response.text[:300].strip() if response.text else "<empty>"
|
||||||
|
logger.debug("Newznab empty response body: %s", preview)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
logger.exception("Newznab search request failed")
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Newznab search failed")
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return results
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Newznab release cache.
|
||||||
|
|
||||||
|
Stores search results so the handler can look up releases by source_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from shelfmark.core.logger import setup_logger
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
RELEASE_CACHE_TTL = 3600 # 1 hour
|
||||||
|
|
||||||
|
_cache: dict[str, tuple] = {}
|
||||||
|
_cache_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def cache_release(source_id: str, release_data: dict) -> None:
|
||||||
|
with _cache_lock:
|
||||||
|
_cache[source_id] = (release_data, time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def get_release(source_id: str) -> dict | None:
|
||||||
|
with _cache_lock:
|
||||||
|
if source_id not in _cache:
|
||||||
|
logger.debug("Newznab release not in cache: %s", source_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
release_data, cached_at = _cache[source_id]
|
||||||
|
if time.time() - cached_at > RELEASE_CACHE_TTL:
|
||||||
|
del _cache[source_id]
|
||||||
|
logger.debug("Newznab release expired: %s", source_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return release_data
|
||||||
|
|
||||||
|
|
||||||
|
def remove_release(source_id: str) -> None:
|
||||||
|
with _cache_lock:
|
||||||
|
if source_id in _cache:
|
||||||
|
del _cache[source_id]
|
||||||
|
logger.debug("Removed Newznab release from cache: %s", source_id)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired() -> int:
|
||||||
|
current_time = time.time()
|
||||||
|
removed = 0
|
||||||
|
with _cache_lock:
|
||||||
|
expired_ids = [
|
||||||
|
sid
|
||||||
|
for sid, (_, cached_at) in _cache.items()
|
||||||
|
if current_time - cached_at > RELEASE_CACHE_TTL
|
||||||
|
]
|
||||||
|
for sid in expired_ids:
|
||||||
|
del _cache[sid]
|
||||||
|
removed += 1
|
||||||
|
if removed:
|
||||||
|
logger.debug("Cleaned up %d expired Newznab cache entries", removed)
|
||||||
|
return removed
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Newznab download handler - resolves releases and delegates to shared clients."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from shelfmark.core.models import DownloadTask
|
||||||
|
|
||||||
|
from shelfmark.core.logger import setup_logger
|
||||||
|
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
||||||
|
from shelfmark.download.clients.base_handler import (
|
||||||
|
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
|
||||||
|
)
|
||||||
|
from shelfmark.download.clients.base_handler import (
|
||||||
|
COMPLETED_PATH_RETRY_INTERVAL as _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL,
|
||||||
|
)
|
||||||
|
from shelfmark.download.clients.base_handler import (
|
||||||
|
POLL_INTERVAL as _DEFAULT_POLL_INTERVAL,
|
||||||
|
)
|
||||||
|
from shelfmark.download.clients.base_handler import (
|
||||||
|
DownloadRequest,
|
||||||
|
ExternalClientHandler,
|
||||||
|
)
|
||||||
|
from shelfmark.release_sources import register_handler
|
||||||
|
from shelfmark.release_sources.newznab.cache import get_release, remove_release
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
# Backwards-compat constants for tests patching this module.
|
||||||
|
POLL_INTERVAL = _DEFAULT_POLL_INTERVAL
|
||||||
|
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
||||||
|
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def _get_protocol(result: dict) -> str:
|
||||||
|
"""Infer download protocol from a Newznab result dict."""
|
||||||
|
protocol = str(result.get("protocol", "")).lower()
|
||||||
|
if protocol in ("torrent", "usenet"):
|
||||||
|
return protocol
|
||||||
|
|
||||||
|
download_url = str(result.get("downloadUrl") or "").lower()
|
||||||
|
magnet_url = str(result.get("magnetUrl") or "").lower()
|
||||||
|
|
||||||
|
if magnet_url.startswith("magnet:"):
|
||||||
|
return "torrent"
|
||||||
|
if download_url.startswith("magnet:") or ".torrent" in download_url:
|
||||||
|
return "torrent"
|
||||||
|
if ".nzb" in download_url:
|
||||||
|
return "usenet"
|
||||||
|
|
||||||
|
# Newznab indexers are usenet-native; default to usenet when ambiguous.
|
||||||
|
return "usenet"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_download_url(result: dict) -> str:
|
||||||
|
"""Pick the best URL to hand to a download client."""
|
||||||
|
protocol = _get_protocol(result)
|
||||||
|
magnet_url = str(result.get("magnetUrl") or "").strip()
|
||||||
|
download_url = str(result.get("downloadUrl") or "").strip()
|
||||||
|
|
||||||
|
if protocol == "torrent":
|
||||||
|
return magnet_url or download_url
|
||||||
|
return download_url or magnet_url
|
||||||
|
|
||||||
|
|
||||||
|
@register_handler("newznab")
|
||||||
|
class NewznabHandler(ExternalClientHandler):
|
||||||
|
"""Handler for Newznab downloads via configured usenet/torrent client."""
|
||||||
|
|
||||||
|
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||||
|
return get_client(protocol)
|
||||||
|
|
||||||
|
def _list_configured_clients(self) -> list[str]:
|
||||||
|
return list_configured_clients()
|
||||||
|
|
||||||
|
def _poll_interval(self) -> float:
|
||||||
|
return POLL_INTERVAL
|
||||||
|
|
||||||
|
def _completed_path_retry_interval(self) -> float:
|
||||||
|
return COMPLETED_PATH_RETRY_INTERVAL
|
||||||
|
|
||||||
|
def _completed_path_max_attempts(self) -> int:
|
||||||
|
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
def _resolve_download(
|
||||||
|
self,
|
||||||
|
task: DownloadTask,
|
||||||
|
status_callback: Callable[[str, str | None], None],
|
||||||
|
) -> DownloadRequest | None:
|
||||||
|
result = get_release(task.task_id)
|
||||||
|
if not result:
|
||||||
|
logger.warning("Newznab release cache miss: %s", task.task_id)
|
||||||
|
status_callback("error", "Release not found in cache (may have expired)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
download_url = _get_download_url(result)
|
||||||
|
if not download_url:
|
||||||
|
status_callback("error", "No download URL available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
protocol = _get_protocol(result)
|
||||||
|
if protocol not in ("torrent", "usenet"):
|
||||||
|
status_callback("error", "Could not determine download protocol")
|
||||||
|
return None
|
||||||
|
|
||||||
|
release_name = result.get("title") or task.title or "Unknown"
|
||||||
|
expected_hash = str(result.get("infoHash") or "").strip() or None
|
||||||
|
|
||||||
|
return DownloadRequest(
|
||||||
|
url=download_url,
|
||||||
|
protocol=protocol,
|
||||||
|
release_name=release_name,
|
||||||
|
expected_hash=expected_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||||
|
remove_release(task.task_id)
|
||||||
|
|
||||||
|
def cancel(self, task_id: str) -> bool:
|
||||||
|
logger.debug("Cancel requested for Newznab task: %s", task_id)
|
||||||
|
remove_release(task_id)
|
||||||
|
return super().cancel(task_id)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Newznab settings registration."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shelfmark.core.settings_registry import (
|
||||||
|
ActionButton,
|
||||||
|
CheckboxField,
|
||||||
|
HeadingField,
|
||||||
|
PasswordField,
|
||||||
|
SettingsField,
|
||||||
|
TextField,
|
||||||
|
register_settings,
|
||||||
|
)
|
||||||
|
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."""
|
||||||
|
from shelfmark.core.config import config
|
||||||
|
from shelfmark.release_sources.newznab.api import NewznabClient
|
||||||
|
|
||||||
|
current_values = current_values or {}
|
||||||
|
|
||||||
|
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 "")
|
||||||
|
|
||||||
|
if not raw_url:
|
||||||
|
return {"success": False, "message": "Newznab URL is required"}
|
||||||
|
|
||||||
|
url = normalize_http_url(raw_url)
|
||||||
|
if not url:
|
||||||
|
return {"success": False, "message": "Newznab URL is invalid"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = NewznabClient(url, api_key)
|
||||||
|
success, message = client.test_connection()
|
||||||
|
except Exception as e: # noqa: BLE001 — surface any unexpected error to the UI
|
||||||
|
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||||
|
else:
|
||||||
|
return {"success": success, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
@register_settings(
|
||||||
|
name="newznab_config",
|
||||||
|
display_name="Newznab",
|
||||||
|
icon="download",
|
||||||
|
order=42,
|
||||||
|
)
|
||||||
|
def newznab_config_settings() -> list[SettingsField]:
|
||||||
|
"""Newznab connection settings."""
|
||||||
|
return [
|
||||||
|
HeadingField(
|
||||||
|
key="newznab_heading",
|
||||||
|
title="Newznab Integration",
|
||||||
|
description=(
|
||||||
|
"Search for books via any Newznab-compatible indexer or aggregator "
|
||||||
|
"(e.g. NZBHydra2, NZBGeek, Drunkenslug)."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckboxField(
|
||||||
|
key="NEWZNAB_ENABLED",
|
||||||
|
label="Enable Newznab source",
|
||||||
|
default=False,
|
||||||
|
description="Enable searching for books via a Newznab-compatible indexer",
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
key="NEWZNAB_URL",
|
||||||
|
label="Newznab URL",
|
||||||
|
description="Base URL of your Newznab indexer or aggregator",
|
||||||
|
placeholder="http://nzbhydra:5076",
|
||||||
|
required=True,
|
||||||
|
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)",
|
||||||
|
required=False,
|
||||||
|
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||||
|
),
|
||||||
|
ActionButton(
|
||||||
|
key="test_newznab",
|
||||||
|
label="Test Connection",
|
||||||
|
description="Verify your Newznab configuration",
|
||||||
|
style="primary",
|
||||||
|
callback=_test_newznab_connection,
|
||||||
|
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||||
|
),
|
||||||
|
CheckboxField(
|
||||||
|
key="NEWZNAB_AUTO_EXPAND",
|
||||||
|
label="Auto-expand search on no results",
|
||||||
|
default=False,
|
||||||
|
description="Automatically retry search without category filtering if no results are found",
|
||||||
|
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Newznab release source - searches a Newznab-compatible indexer for book releases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||||
|
from shelfmark.metadata_providers import BookMetadata
|
||||||
|
|
||||||
|
from shelfmark.core.config import config
|
||||||
|
from shelfmark.core.logger import setup_logger
|
||||||
|
from shelfmark.core.utils import normalize_http_url
|
||||||
|
from shelfmark.release_sources import (
|
||||||
|
ColumnAlign,
|
||||||
|
ColumnColorHint,
|
||||||
|
ColumnRenderType,
|
||||||
|
ColumnSchema,
|
||||||
|
LeadingCellConfig,
|
||||||
|
LeadingCellType,
|
||||||
|
Release,
|
||||||
|
ReleaseColumnConfig,
|
||||||
|
ReleaseProtocol,
|
||||||
|
ReleaseSource,
|
||||||
|
register_source,
|
||||||
|
)
|
||||||
|
from shelfmark.release_sources.newznab.api import NewznabClient
|
||||||
|
from shelfmark.release_sources.newznab.cache import cache_release
|
||||||
|
from shelfmark.release_sources.prowlarr.source import (
|
||||||
|
PROWLARR_SEARCH_TIMEOUT_SECONDS as _SEARCH_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-use the Prowlarr source helpers — they operate on generic result dicts.
|
||||||
|
from shelfmark.release_sources.prowlarr.source import (
|
||||||
|
_detect_content_type_from_categories,
|
||||||
|
_parse_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
# Newznab category IDs
|
||||||
|
_AUDIOBOOK_CATS = [3030]
|
||||||
|
_BOOK_CATS = [7000]
|
||||||
|
|
||||||
|
# Reuse the same timeout constant as Prowlarr.
|
||||||
|
NEWZNAB_SEARCH_TIMEOUT_SECONDS = _SEARCH_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Release:
|
||||||
|
"""Convert a parsed Newznab XML result dict to a Release object."""
|
||||||
|
raw_title = result.get("title", "Unknown")
|
||||||
|
size_bytes = result.get("size")
|
||||||
|
indexer = result.get("indexer") or "Newznab"
|
||||||
|
categories = result.get("categories", [])
|
||||||
|
|
||||||
|
protocol_str = str(result.get("protocol", "usenet")).lower()
|
||||||
|
protocol = ReleaseProtocol.TORRENT if protocol_str == "torrent" else ReleaseProtocol.NZB
|
||||||
|
|
||||||
|
seeders = result.get("seeders")
|
||||||
|
leechers = result.get("leechers")
|
||||||
|
is_torrent = protocol == ReleaseProtocol.TORRENT
|
||||||
|
|
||||||
|
peers_display = (
|
||||||
|
f"{seeders} / {leechers}"
|
||||||
|
if is_torrent and seeders is not None and leechers is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build source_id from GUID
|
||||||
|
source_id = result.get("guid") or f"newznab:{hash(raw_title)}"
|
||||||
|
|
||||||
|
# Cache the raw result for the handler
|
||||||
|
cache_release(source_id, result)
|
||||||
|
|
||||||
|
# Freeleech / VIP detection
|
||||||
|
raw_indexer_flags = result.get("indexerFlags") or []
|
||||||
|
indexer_flags: list[str] = []
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
def add_flag(flag: object) -> None:
|
||||||
|
if flag is None:
|
||||||
|
return
|
||||||
|
s = str(flag).strip()
|
||||||
|
if s and s.lower() not in seen:
|
||||||
|
seen.add(s.lower())
|
||||||
|
indexer_flags.append(s)
|
||||||
|
|
||||||
|
if isinstance(raw_indexer_flags, list):
|
||||||
|
for f in raw_indexer_flags:
|
||||||
|
add_flag(f)
|
||||||
|
elif raw_indexer_flags:
|
||||||
|
add_flag(raw_indexer_flags)
|
||||||
|
|
||||||
|
download_volume_factor = result.get("downloadVolumeFactor")
|
||||||
|
is_freeleech = False
|
||||||
|
try:
|
||||||
|
if download_volume_factor is not None and float(download_volume_factor) == 0.0:
|
||||||
|
is_freeleech = True
|
||||||
|
except TypeError, ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if any(f.lower() in {"freeleech", "fl"} for f in indexer_flags):
|
||||||
|
is_freeleech = True
|
||||||
|
|
||||||
|
is_vip = "[vip]" in str(raw_title).lower()
|
||||||
|
if is_vip:
|
||||||
|
add_flag("VIP")
|
||||||
|
if is_freeleech:
|
||||||
|
add_flag("FreeLeech")
|
||||||
|
|
||||||
|
download_url = str(result.get("downloadUrl") or "").strip()
|
||||||
|
info_url = result.get("infoUrl") or result.get("guid")
|
||||||
|
|
||||||
|
return Release(
|
||||||
|
source="newznab",
|
||||||
|
source_id=source_id,
|
||||||
|
title=raw_title,
|
||||||
|
format=None,
|
||||||
|
language=None,
|
||||||
|
size=_parse_size(size_bytes),
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
download_url=download_url or None,
|
||||||
|
info_url=info_url,
|
||||||
|
protocol=protocol,
|
||||||
|
indexer=indexer,
|
||||||
|
seeders=seeders if is_torrent else None,
|
||||||
|
peers=peers_display,
|
||||||
|
content_type=_detect_content_type_from_categories(categories, content_type),
|
||||||
|
extra={
|
||||||
|
"publish_date": result.get("publishDate"),
|
||||||
|
"categories": categories,
|
||||||
|
"indexer_flags": indexer_flags,
|
||||||
|
"vip": is_vip,
|
||||||
|
"freeleech": is_freeleech,
|
||||||
|
"download_volume_factor": download_volume_factor,
|
||||||
|
"upload_volume_factor": result.get("uploadVolumeFactor"),
|
||||||
|
"minimum_ratio": result.get("minimumRatio"),
|
||||||
|
"minimum_seed_time": result.get("minimumSeedTime"),
|
||||||
|
"info_hash": result.get("infoHash"),
|
||||||
|
"files": result.get("files"),
|
||||||
|
"grabs": result.get("grabs"),
|
||||||
|
"author": result.get("author"),
|
||||||
|
"book_title": result.get("bookTitle"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_source("newznab")
|
||||||
|
class NewznabSource(ReleaseSource):
|
||||||
|
"""Release source for any Newznab-compatible indexer or aggregator."""
|
||||||
|
|
||||||
|
name = "newznab"
|
||||||
|
display_name = "Newznab"
|
||||||
|
supported_content_types: ClassVar[list[str]] = ["ebook", "audiobook"]
|
||||||
|
|
||||||
|
def get_column_config(self) -> ReleaseColumnConfig:
|
||||||
|
return ReleaseColumnConfig(
|
||||||
|
columns=[
|
||||||
|
ColumnSchema(
|
||||||
|
key="indexer",
|
||||||
|
label="Indexer",
|
||||||
|
render_type=ColumnRenderType.INDEXER_PROTOCOL,
|
||||||
|
align=ColumnAlign.LEFT,
|
||||||
|
width="minmax(140px, 1fr)",
|
||||||
|
hide_mobile=False,
|
||||||
|
sortable=True,
|
||||||
|
),
|
||||||
|
ColumnSchema(
|
||||||
|
key="extra.indexer_flags",
|
||||||
|
label="Flags",
|
||||||
|
render_type=ColumnRenderType.TAGS,
|
||||||
|
align=ColumnAlign.CENTER,
|
||||||
|
width="50px",
|
||||||
|
hide_mobile=False,
|
||||||
|
color_hint=ColumnColorHint(type="map", value="flags"),
|
||||||
|
fallback="",
|
||||||
|
uppercase=True,
|
||||||
|
),
|
||||||
|
ColumnSchema(
|
||||||
|
key="size",
|
||||||
|
label="Size",
|
||||||
|
render_type=ColumnRenderType.SIZE,
|
||||||
|
align=ColumnAlign.CENTER,
|
||||||
|
width="80px",
|
||||||
|
hide_mobile=False,
|
||||||
|
sortable=True,
|
||||||
|
sort_key="size_bytes",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 80px",
|
||||||
|
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE),
|
||||||
|
supported_filters=["indexer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_client(self) -> NewznabClient | None:
|
||||||
|
raw_url = str(config.get("NEWZNAB_URL", "") or "")
|
||||||
|
api_key = str(config.get("NEWZNAB_API_KEY", "") or "")
|
||||||
|
|
||||||
|
if not raw_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = normalize_http_url(raw_url)
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return NewznabClient(url, api_key or "")
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
book: BookMetadata,
|
||||||
|
plan: ReleaseSearchPlan,
|
||||||
|
*,
|
||||||
|
expand_search: bool = False,
|
||||||
|
content_type: str = "ebook",
|
||||||
|
) -> list[Release]:
|
||||||
|
"""Search the Newznab indexer for releases matching the book."""
|
||||||
|
client = self._get_client()
|
||||||
|
if not client:
|
||||||
|
logger.warning("Newznab not configured - skipping search")
|
||||||
|
return []
|
||||||
|
|
||||||
|
queries = [v.title for v in plan.title_variants if v.title]
|
||||||
|
queries = [q for q in queries if q]
|
||||||
|
|
||||||
|
if not queries and plan.isbn_candidates:
|
||||||
|
queries = list(plan.isbn_candidates)
|
||||||
|
|
||||||
|
if not queries:
|
||||||
|
logger.warning("Newznab: no search query available")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Category selection — omit categories when expanding search
|
||||||
|
if expand_search:
|
||||||
|
categories = None
|
||||||
|
elif content_type == "audiobook":
|
||||||
|
categories = [3030]
|
||||||
|
else:
|
||||||
|
categories = [7000]
|
||||||
|
|
||||||
|
auto_expand = config.get("NEWZNAB_AUTO_EXPAND", False)
|
||||||
|
deadline = time.monotonic() + NEWZNAB_SEARCH_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
def _check_timeout() -> None:
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Newznab search timed out after {int(NEWZNAB_SEARCH_TIMEOUT_SECONDS)}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_keys: set = set()
|
||||||
|
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)
|
||||||
|
|
||||||
|
raw = 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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) for r in all_results]
|
||||||
|
|
||||||
|
if results:
|
||||||
|
nzb_count = sum(1 for r in results if r.protocol == ReleaseProtocol.NZB)
|
||||||
|
torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT)
|
||||||
|
indexers = sorted({r.indexer for r in results if r.indexer})
|
||||||
|
indexer_str = ", ".join(indexers) if indexers else "unknown"
|
||||||
|
logger.info(
|
||||||
|
"Newznab: %d results (%d nzb, %d torrent) from %s",
|
||||||
|
len(results),
|
||||||
|
nzb_count,
|
||||||
|
torrent_count,
|
||||||
|
indexer_str,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("Newznab: no results found")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
if not config.get("NEWZNAB_ENABLED", False):
|
||||||
|
return False
|
||||||
|
url = normalize_http_url(str(config.get("NEWZNAB_URL", "") or ""))
|
||||||
|
return bool(url)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Newznab test configuration.
|
||||||
|
|
||||||
|
Stubs out optional heavy dependencies (flask_socketio) that are not available
|
||||||
|
in lightweight dev/CI environments. These are only needed by the IRC source,
|
||||||
|
which is unrelated to the newznab plugin under test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_module(name: str) -> None:
|
||||||
|
"""Insert a minimal stub module into sys.modules if not already present."""
|
||||||
|
if name not in sys.modules:
|
||||||
|
stub = types.ModuleType(name)
|
||||||
|
# Provide no-op stand-ins for the symbols IRC source imports.
|
||||||
|
stub.SocketIO = object
|
||||||
|
stub.join_room = lambda *a, **kw: None
|
||||||
|
stub.leave_room = lambda *a, **kw: None
|
||||||
|
sys.modules[name] = stub
|
||||||
|
|
||||||
|
|
||||||
|
_stub_module("flask_socketio")
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Unit tests for the Newznab API client."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from shelfmark.release_sources.newznab.api import NewznabClient
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
NZB_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"
|
||||||
|
xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/">
|
||||||
|
<channel>
|
||||||
|
<title>My Indexer</title>
|
||||||
|
<item>
|
||||||
|
<title>Some Book (2024) [EPUB]</title>
|
||||||
|
<guid>https://indexer.example.com/nzb/1</guid>
|
||||||
|
<link>https://indexer.example.com/nzb/1?apikey=secret</link>
|
||||||
|
<pubDate>Sun, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||||
|
<size>2097152</size>
|
||||||
|
<enclosure url="https://indexer.example.com/nzb/1?apikey=secret"
|
||||||
|
type="application/x-nzb" />
|
||||||
|
<category>7000</category>
|
||||||
|
<newznab:attr name="grabs" value="12" />
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>"""
|
||||||
|
|
||||||
|
CAPS_XML = """<?xml version="1.0"?>
|
||||||
|
<caps>
|
||||||
|
<server title="NZBHydra2" version="5.0.0"/>
|
||||||
|
</caps>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _make_response(text: str, status: int = 200) -> MagicMock:
|
||||||
|
r = MagicMock(spec=requests.Response)
|
||||||
|
r.status_code = status
|
||||||
|
r.text = text
|
||||||
|
r.ok = status < 400
|
||||||
|
r.raise_for_status = MagicMock()
|
||||||
|
if status >= 400:
|
||||||
|
r.raise_for_status.side_effect = requests.exceptions.HTTPError(response=r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ── URL construction ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiUrl:
|
||||||
|
def test_appends_api_path(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
assert client._api_url() == "http://nzbhydra:5076/api"
|
||||||
|
|
||||||
|
def test_does_not_double_append(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076/api", "key")
|
||||||
|
assert client._api_url() == "http://nzbhydra:5076/api"
|
||||||
|
|
||||||
|
def test_strips_trailing_slash(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076/", "key")
|
||||||
|
assert client._api_url() == "http://nzbhydra:5076/api"
|
||||||
|
|
||||||
|
|
||||||
|
# ── test_connection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestConnection:
|
||||||
|
def test_success_returns_true_with_title(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
with patch.object(client, "_get", return_value=_make_response(CAPS_XML)):
|
||||||
|
ok, msg = client.test_connection()
|
||||||
|
assert ok is True
|
||||||
|
assert "NZBHydra2" in msg
|
||||||
|
|
||||||
|
def test_connection_error_returns_false(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"_get",
|
||||||
|
side_effect=requests.exceptions.ConnectionError("refused"),
|
||||||
|
):
|
||||||
|
ok, msg = client.test_connection()
|
||||||
|
assert ok is False
|
||||||
|
assert "connect" in msg.lower()
|
||||||
|
|
||||||
|
def test_401_returns_api_key_error(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
fake_resp = _make_response("", status=401)
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"_get",
|
||||||
|
side_effect=requests.exceptions.HTTPError(response=fake_resp),
|
||||||
|
):
|
||||||
|
ok, msg = client.test_connection()
|
||||||
|
assert ok is False
|
||||||
|
assert "api key" in msg.lower()
|
||||||
|
|
||||||
|
def test_generic_exception_returns_false(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"_get",
|
||||||
|
side_effect=requests.exceptions.Timeout("oops"),
|
||||||
|
):
|
||||||
|
ok, msg = client.test_connection()
|
||||||
|
assert ok is False
|
||||||
|
assert "oops" in msg.lower()
|
||||||
|
|
||||||
|
def test_caps_without_title_still_succeeds(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
caps_no_title = "<?xml version='1.0'?><caps/>"
|
||||||
|
with patch.object(client, "_get", return_value=_make_response(caps_no_title)):
|
||||||
|
ok, msg = client.test_connection()
|
||||||
|
assert ok is True
|
||||||
|
assert msg # some non-empty message
|
||||||
|
|
||||||
|
|
||||||
|
# ── search ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearch:
|
||||||
|
def test_empty_query_returns_empty(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
results = client.search(query="")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_parses_nzb_xml(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
with patch.object(client, "_get", return_value=_make_response(NZB_XML)):
|
||||||
|
results = client.search(query="Some Book")
|
||||||
|
assert len(results) == 1
|
||||||
|
r = results[0]
|
||||||
|
assert r["title"] == "Some Book (2024) [EPUB]"
|
||||||
|
assert r["protocol"] == "usenet"
|
||||||
|
assert r["size"] == 2097152
|
||||||
|
assert r["downloadUrl"] == "https://indexer.example.com/nzb/1?apikey=secret"
|
||||||
|
|
||||||
|
def test_sends_category_param(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
captured: list = []
|
||||||
|
|
||||||
|
def fake_get(params, accept_xml=False):
|
||||||
|
captured.append(params.copy())
|
||||||
|
return _make_response(NZB_XML)
|
||||||
|
|
||||||
|
with patch.object(client, "_get", side_effect=fake_get):
|
||||||
|
client.search(query="book", categories=[7000, 3030])
|
||||||
|
|
||||||
|
assert len(captured) == 1
|
||||||
|
assert captured[0]["cat"] == "7000,3030"
|
||||||
|
|
||||||
|
def test_omits_category_when_none(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
captured: list = []
|
||||||
|
|
||||||
|
def fake_get(params, accept_xml=False):
|
||||||
|
captured.append(params.copy())
|
||||||
|
return _make_response(NZB_XML)
|
||||||
|
|
||||||
|
with patch.object(client, "_get", side_effect=fake_get):
|
||||||
|
client.search(query="book", categories=None)
|
||||||
|
|
||||||
|
assert "cat" not in captured[0]
|
||||||
|
|
||||||
|
def test_returns_empty_on_request_error(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"_get",
|
||||||
|
side_effect=requests.exceptions.ConnectionError("down"),
|
||||||
|
):
|
||||||
|
results = client.search(query="book")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_returns_empty_on_malformed_xml(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
with patch.object(client, "_get", return_value=_make_response("not xml at all")):
|
||||||
|
results = client.search(query="book")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_includes_apikey_in_request(self):
|
||||||
|
"""The apikey is injected by _get() into the outgoing HTTP request."""
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "mykey")
|
||||||
|
captured_params: list = []
|
||||||
|
|
||||||
|
def fake_session_get(url, params=None, **kwargs):
|
||||||
|
captured_params.append(dict(params or {}))
|
||||||
|
r = _make_response(NZB_XML)
|
||||||
|
return r
|
||||||
|
|
||||||
|
with patch.object(client._session, "get", side_effect=fake_session_get):
|
||||||
|
client.search(query="test")
|
||||||
|
|
||||||
|
assert len(captured_params) == 1
|
||||||
|
assert captured_params[0].get("apikey") == "mykey"
|
||||||
|
|
||||||
|
def test_uses_book_search_type_when_specified(self):
|
||||||
|
client = NewznabClient("http://nzbhydra:5076", "key")
|
||||||
|
captured: list = []
|
||||||
|
|
||||||
|
def fake_get(params, accept_xml=False):
|
||||||
|
captured.append(params.copy())
|
||||||
|
return _make_response(NZB_XML)
|
||||||
|
|
||||||
|
with patch.object(client, "_get", side_effect=fake_get):
|
||||||
|
client.search(query="book", search_type="book")
|
||||||
|
|
||||||
|
assert captured[0]["t"] == "book"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Unit tests for the Newznab release cache."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shelfmark.release_sources.newznab import cache as cache_module
|
||||||
|
from shelfmark.release_sources.newznab.cache import (
|
||||||
|
cache_release,
|
||||||
|
cleanup_expired,
|
||||||
|
get_release,
|
||||||
|
remove_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_cache():
|
||||||
|
"""Ensure the cache is empty before and after each test."""
|
||||||
|
cache_module._cache.clear()
|
||||||
|
yield
|
||||||
|
cache_module._cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheRelease:
|
||||||
|
def test_stores_and_retrieves_release(self):
|
||||||
|
data = {"title": "My Book", "downloadUrl": "https://example.com/nzb/1"}
|
||||||
|
cache_release("id-1", data)
|
||||||
|
assert get_release("id-1") == data
|
||||||
|
|
||||||
|
def test_overwrites_existing_entry(self):
|
||||||
|
cache_release("id-1", {"title": "Old"})
|
||||||
|
cache_release("id-1", {"title": "New"})
|
||||||
|
assert get_release("id-1")["title"] == "New"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRelease:
|
||||||
|
def test_returns_none_for_unknown_id(self):
|
||||||
|
assert get_release("no-such-id") is None
|
||||||
|
|
||||||
|
def test_returns_none_after_ttl_expires(self):
|
||||||
|
cache_release("id-ttl", {"title": "Expiring"})
|
||||||
|
past = time.time() - cache_module.RELEASE_CACHE_TTL - 1
|
||||||
|
cache_module._cache["id-ttl"] = (cache_module._cache["id-ttl"][0], past)
|
||||||
|
assert get_release("id-ttl") is None
|
||||||
|
|
||||||
|
def test_expired_entry_is_removed(self):
|
||||||
|
cache_release("id-evict", {"title": "Gone"})
|
||||||
|
past = time.time() - cache_module.RELEASE_CACHE_TTL - 1
|
||||||
|
cache_module._cache["id-evict"] = (cache_module._cache["id-evict"][0], past)
|
||||||
|
get_release("id-evict")
|
||||||
|
assert "id-evict" not in cache_module._cache
|
||||||
|
|
||||||
|
def test_fresh_entry_not_expired(self):
|
||||||
|
cache_release("id-fresh", {"title": "Fresh"})
|
||||||
|
# Advance time by less than TTL
|
||||||
|
future = time.time() + cache_module.RELEASE_CACHE_TTL - 60
|
||||||
|
with patch("shelfmark.release_sources.newznab.cache.time") as mock_time:
|
||||||
|
mock_time.time.return_value = future
|
||||||
|
result = get_release("id-fresh")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveRelease:
|
||||||
|
def test_removes_existing_entry(self):
|
||||||
|
cache_release("id-remove", {"title": "To Remove"})
|
||||||
|
remove_release("id-remove")
|
||||||
|
assert get_release("id-remove") is None
|
||||||
|
|
||||||
|
def test_no_error_when_removing_absent_entry(self):
|
||||||
|
remove_release("no-such-id") # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupExpired:
|
||||||
|
def test_removes_only_expired_entries(self):
|
||||||
|
cache_release("fresh", {"title": "Fresh"})
|
||||||
|
cache_release("stale", {"title": "Stale"})
|
||||||
|
|
||||||
|
past = time.time() - cache_module.RELEASE_CACHE_TTL - 1
|
||||||
|
cache_module._cache["stale"] = (cache_module._cache["stale"][0], past)
|
||||||
|
|
||||||
|
removed = cleanup_expired()
|
||||||
|
assert removed == 1
|
||||||
|
assert get_release("fresh") is not None
|
||||||
|
assert "stale" not in cache_module._cache
|
||||||
|
|
||||||
|
def test_returns_zero_when_nothing_expired(self):
|
||||||
|
cache_release("a", {})
|
||||||
|
cache_release("b", {})
|
||||||
|
assert cleanup_expired() == 0
|
||||||
|
|
||||||
|
def test_returns_zero_when_cache_empty(self):
|
||||||
|
assert cleanup_expired() == 0
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Unit tests for the Newznab download handler."""
|
||||||
|
|
||||||
|
from threading import Event
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from shelfmark.core.models import DownloadTask
|
||||||
|
from shelfmark.release_sources.newznab.handler import (
|
||||||
|
NewznabHandler,
|
||||||
|
_get_download_url,
|
||||||
|
_get_protocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressRecorder:
|
||||||
|
def __init__(self):
|
||||||
|
self.progress_values: list[float] = []
|
||||||
|
self.status_updates: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
|
def progress_callback(self, v: float):
|
||||||
|
self.progress_values.append(v)
|
||||||
|
|
||||||
|
def status_callback(self, status: str, message: str | None):
|
||||||
|
self.status_updates.append((status, message))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_status(self) -> str | None:
|
||||||
|
return self.status_updates[-1][0] if self.status_updates else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_message(self) -> str | None:
|
||||||
|
return self.status_updates[-1][1] if self.status_updates else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def statuses(self) -> list[str]:
|
||||||
|
return [s[0] for s in self.status_updates]
|
||||||
|
|
||||||
|
|
||||||
|
# ── _get_protocol ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProtocol:
|
||||||
|
def test_explicit_usenet(self):
|
||||||
|
assert _get_protocol({"protocol": "usenet"}) == "usenet"
|
||||||
|
|
||||||
|
def test_explicit_torrent(self):
|
||||||
|
assert _get_protocol({"protocol": "torrent"}) == "torrent"
|
||||||
|
|
||||||
|
def test_magnet_url_infers_torrent(self):
|
||||||
|
assert _get_protocol({"magnetUrl": "magnet:?xt=urn:btih:abc"}) == "torrent"
|
||||||
|
|
||||||
|
def test_torrent_extension_infers_torrent(self):
|
||||||
|
assert _get_protocol({"downloadUrl": "https://example.com/file.torrent"}) == "torrent"
|
||||||
|
|
||||||
|
def test_nzb_extension_infers_usenet(self):
|
||||||
|
assert _get_protocol({"downloadUrl": "https://example.com/file.nzb"}) == "usenet"
|
||||||
|
|
||||||
|
def test_defaults_to_usenet_when_ambiguous(self):
|
||||||
|
# Newznab is usenet-native; ambiguous URLs default to usenet.
|
||||||
|
assert _get_protocol({"downloadUrl": "https://example.com/download/123"}) == "usenet"
|
||||||
|
|
||||||
|
def test_empty_dict_defaults_to_usenet(self):
|
||||||
|
assert _get_protocol({}) == "usenet"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _get_download_url ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDownloadUrl:
|
||||||
|
def test_usenet_prefers_download_url(self):
|
||||||
|
result = {
|
||||||
|
"protocol": "usenet",
|
||||||
|
"downloadUrl": "https://example.com/nzb",
|
||||||
|
"magnetUrl": "magnet:?xt=urn:btih:abc",
|
||||||
|
}
|
||||||
|
assert _get_download_url(result) == "https://example.com/nzb"
|
||||||
|
|
||||||
|
def test_torrent_prefers_magnet(self):
|
||||||
|
result = {
|
||||||
|
"protocol": "torrent",
|
||||||
|
"downloadUrl": "https://example.com/file.torrent",
|
||||||
|
"magnetUrl": "magnet:?xt=urn:btih:abc",
|
||||||
|
}
|
||||||
|
assert _get_download_url(result) == "magnet:?xt=urn:btih:abc"
|
||||||
|
|
||||||
|
def test_falls_back_to_download_url_when_no_magnet(self):
|
||||||
|
result = {
|
||||||
|
"protocol": "torrent",
|
||||||
|
"downloadUrl": "https://example.com/file.torrent",
|
||||||
|
}
|
||||||
|
assert _get_download_url(result) == "https://example.com/file.torrent"
|
||||||
|
|
||||||
|
|
||||||
|
# ── error paths ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandlerErrors:
|
||||||
|
def test_cache_miss_returns_error(self):
|
||||||
|
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
|
||||||
|
handler = NewznabHandler()
|
||||||
|
task = DownloadTask(task_id="missing", source="newznab", title="Book")
|
||||||
|
recorder = ProgressRecorder()
|
||||||
|
result = handler.download(
|
||||||
|
task=task,
|
||||||
|
cancel_flag=Event(),
|
||||||
|
progress_callback=recorder.progress_callback,
|
||||||
|
status_callback=recorder.status_callback,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
assert recorder.last_status == "error"
|
||||||
|
assert "cache" in (recorder.last_message or "").lower()
|
||||||
|
|
||||||
|
def test_no_download_url_returns_error(self):
|
||||||
|
with patch(
|
||||||
|
"shelfmark.release_sources.newznab.handler.get_release",
|
||||||
|
return_value={"protocol": "usenet", "title": "Book"},
|
||||||
|
):
|
||||||
|
handler = NewznabHandler()
|
||||||
|
task = DownloadTask(task_id="no-url", source="newznab", title="Book")
|
||||||
|
recorder = ProgressRecorder()
|
||||||
|
result = handler.download(
|
||||||
|
task=task,
|
||||||
|
cancel_flag=Event(),
|
||||||
|
progress_callback=recorder.progress_callback,
|
||||||
|
status_callback=recorder.status_callback,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
assert recorder.last_status == "error"
|
||||||
|
assert "url" in (recorder.last_message or "").lower()
|
||||||
|
|
||||||
|
def test_no_client_configured_returns_error(self):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"shelfmark.release_sources.newznab.handler.get_release",
|
||||||
|
return_value={
|
||||||
|
"protocol": "usenet",
|
||||||
|
"downloadUrl": "https://example.com/nzb/1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch("shelfmark.release_sources.newznab.handler.get_client", return_value=None),
|
||||||
|
patch(
|
||||||
|
"shelfmark.release_sources.newznab.handler.list_configured_clients",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
handler = NewznabHandler()
|
||||||
|
task = DownloadTask(task_id="no-client", source="newznab", title="Book")
|
||||||
|
recorder = ProgressRecorder()
|
||||||
|
result = handler.download(
|
||||||
|
task=task,
|
||||||
|
cancel_flag=Event(),
|
||||||
|
progress_callback=recorder.progress_callback,
|
||||||
|
status_callback=recorder.status_callback,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
assert recorder.last_status == "error"
|
||||||
|
assert "client" in (recorder.last_message or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── cancel ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandlerCancel:
|
||||||
|
def test_cancel_removes_from_cache(self):
|
||||||
|
with patch("shelfmark.release_sources.newznab.handler.remove_release") as mock_remove:
|
||||||
|
result = NewznabHandler().cancel("task-123")
|
||||||
|
assert result is True
|
||||||
|
mock_remove.assert_called_once_with("task-123")
|
||||||
|
|
||||||
|
def test_cancel_handles_absent_task(self):
|
||||||
|
with patch("shelfmark.release_sources.newznab.handler.remove_release"):
|
||||||
|
result = NewznabHandler().cancel("no-such-task")
|
||||||
|
assert result is True
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""Unit tests for the Newznab release source."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
|
||||||
|
from shelfmark.metadata_providers import BookMetadata
|
||||||
|
from shelfmark.release_sources import ReleaseProtocol
|
||||||
|
from shelfmark.release_sources.newznab.source import (
|
||||||
|
NewznabSource,
|
||||||
|
_newznab_result_to_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── fixtures / helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_book(**kwargs) -> BookMetadata:
|
||||||
|
defaults = {
|
||||||
|
"provider": "hardcover",
|
||||||
|
"provider_id": "1",
|
||||||
|
"title": "Dune",
|
||||||
|
"authors": ["Frank Herbert"],
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return BookMetadata(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_result(**kwargs) -> dict:
|
||||||
|
"""Minimal Newznab-like result dict."""
|
||||||
|
base = {
|
||||||
|
"title": "Dune (2024) [EPUB]",
|
||||||
|
"guid": "https://indexer.example.com/nzb/42",
|
||||||
|
"downloadUrl": "https://indexer.example.com/nzb/42?apikey=secret",
|
||||||
|
"protocol": "usenet",
|
||||||
|
"size": 2097152,
|
||||||
|
"indexer": "MyIndexer",
|
||||||
|
"categories": [7000],
|
||||||
|
"indexerFlags": [],
|
||||||
|
"publishDate": "2024-01-01",
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _make_plan(book: BookMetadata, *, manual_query: str | None = None):
|
||||||
|
from shelfmark.core.search_plan import build_release_search_plan
|
||||||
|
|
||||||
|
return build_release_search_plan(book, languages=["en"], manual_query=manual_query)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _newznab_result_to_release ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResultToRelease:
|
||||||
|
def test_basic_usenet_result(self):
|
||||||
|
r = _newznab_result_to_release(_make_result())
|
||||||
|
assert r.source == "newznab"
|
||||||
|
assert r.title == "Dune (2024) [EPUB]"
|
||||||
|
assert r.protocol == ReleaseProtocol.NZB
|
||||||
|
assert r.size == "2.0 MB"
|
||||||
|
assert r.size_bytes == 2097152
|
||||||
|
assert r.indexer == "MyIndexer"
|
||||||
|
assert r.source_id == "https://indexer.example.com/nzb/42"
|
||||||
|
assert r.download_url == "https://indexer.example.com/nzb/42?apikey=secret"
|
||||||
|
|
||||||
|
def test_torrent_result_has_torrent_protocol(self):
|
||||||
|
r = _newznab_result_to_release(
|
||||||
|
_make_result(
|
||||||
|
protocol="torrent",
|
||||||
|
magnetUrl="magnet:?xt=urn:btih:abc",
|
||||||
|
categories=[3030],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r.protocol == ReleaseProtocol.TORRENT
|
||||||
|
|
||||||
|
def test_audiobook_category_detected(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(categories=[3030]), "ebook")
|
||||||
|
assert r.content_type == "audiobook"
|
||||||
|
|
||||||
|
def test_book_category_detected(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(categories=[7000]))
|
||||||
|
assert r.content_type == "book"
|
||||||
|
|
||||||
|
def test_no_categories_uses_content_type_fallback(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(categories=[]), "audiobook")
|
||||||
|
assert r.content_type == "audiobook"
|
||||||
|
|
||||||
|
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
|
||||||
|
assert "FreeLeech" in r.extra["indexer_flags"]
|
||||||
|
|
||||||
|
def test_freeleech_flag_detected_via_indexer_flags(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(indexerFlags=["freeleech"]))
|
||||||
|
assert r.extra["freeleech"] is True
|
||||||
|
|
||||||
|
def test_vip_detected_from_title(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(title="Dune [VIP] [EPUB]"))
|
||||||
|
assert r.extra["vip"] is True
|
||||||
|
assert "VIP" in r.extra["indexer_flags"]
|
||||||
|
|
||||||
|
def test_duplicate_flags_deduplicated(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(indexerFlags=["FreeLeech", "freeleech", "FL"]))
|
||||||
|
lower_flags = [f.lower() for f in r.extra["indexer_flags"]]
|
||||||
|
assert lower_flags.count("freeleech") == 1
|
||||||
|
|
||||||
|
def test_seeders_only_set_for_torrents(self):
|
||||||
|
usenet = _newznab_result_to_release(_make_result(protocol="usenet", seeders=10))
|
||||||
|
assert usenet.seeders is None
|
||||||
|
|
||||||
|
torrent = _newznab_result_to_release(
|
||||||
|
_make_result(protocol="torrent", seeders=10, leechers=2)
|
||||||
|
)
|
||||||
|
assert torrent.seeders == 10
|
||||||
|
assert torrent.peers == "10 / 2"
|
||||||
|
|
||||||
|
def test_fallback_source_id_when_no_guid(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(guid=None))
|
||||||
|
assert r.source_id.startswith("newznab:")
|
||||||
|
|
||||||
|
def test_none_size_returns_none(self):
|
||||||
|
r = _newznab_result_to_release(_make_result(size=None))
|
||||||
|
assert r.size is None
|
||||||
|
assert r.size_bytes is None
|
||||||
|
|
||||||
|
def test_extra_fields_preserved(self):
|
||||||
|
r = _newznab_result_to_release(
|
||||||
|
_make_result(
|
||||||
|
author="Frank Herbert",
|
||||||
|
bookTitle="Dune",
|
||||||
|
infoHash="abc123",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r.extra["author"] == "Frank Herbert"
|
||||||
|
assert r.extra["book_title"] == "Dune"
|
||||||
|
assert r.extra["info_hash"] == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
# ── NewznabSource.is_available ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAvailable:
|
||||||
|
def _config(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"NEWZNAB_ENABLED": True,
|
||||||
|
"NEWZNAB_URL": "http://nzbhydra:5076",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return lambda k, default=None: values.get(k, default)
|
||||||
|
|
||||||
|
def test_available_when_enabled_and_url_set(self, monkeypatch):
|
||||||
|
import shelfmark.release_sources.newznab.source as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.config, "get", self._config())
|
||||||
|
assert NewznabSource().is_available() is True
|
||||||
|
|
||||||
|
def test_unavailable_when_disabled(self, monkeypatch):
|
||||||
|
import shelfmark.release_sources.newznab.source as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.config, "get", self._config(NEWZNAB_ENABLED=False))
|
||||||
|
assert NewznabSource().is_available() is False
|
||||||
|
|
||||||
|
def test_unavailable_when_no_url(self, monkeypatch):
|
||||||
|
import shelfmark.release_sources.newznab.source as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.config, "get", self._config(NEWZNAB_URL=""))
|
||||||
|
assert NewznabSource().is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── NewznabSource.search ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearch:
|
||||||
|
def _fake_config(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"NEWZNAB_AUTO_EXPAND": False,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return lambda k, default=None: values.get(k, default)
|
||||||
|
|
||||||
|
def _patched_source(self, monkeypatch, client, config_overrides=None):
|
||||||
|
import shelfmark.release_sources.newznab.source as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.config, "get", self._fake_config(**(config_overrides or {})))
|
||||||
|
src = NewznabSource()
|
||||||
|
monkeypatch.setattr(src, "_get_client", lambda: client)
|
||||||
|
return src
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_client(self, monkeypatch):
|
||||||
|
import shelfmark.release_sources.newznab.source as mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.config, "get", self._fake_config())
|
||||||
|
src = NewznabSource()
|
||||||
|
monkeypatch.setattr(src, "_get_client", lambda: None)
|
||||||
|
book = _make_book()
|
||||||
|
results = src.search(book, _make_plan(book))
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_returns_empty_with_no_queries(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = []
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = BookMetadata(provider="test", provider_id="1", title="", authors=[])
|
||||||
|
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||||
|
|
||||||
|
empty_plan = ReleaseSearchPlan(
|
||||||
|
languages=["en"],
|
||||||
|
isbn_candidates=[],
|
||||||
|
author="",
|
||||||
|
title_variants=[],
|
||||||
|
grouped_title_variants=[],
|
||||||
|
manual_query=None,
|
||||||
|
indexers=[],
|
||||||
|
)
|
||||||
|
results = src.search(book, empty_plan)
|
||||||
|
assert results == []
|
||||||
|
client.search.assert_not_called()
|
||||||
|
|
||||||
|
def test_searches_with_ebook_category(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = []
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = _make_book()
|
||||||
|
src.search(book, _make_plan(book), content_type="ebook")
|
||||||
|
_, kwargs = client.search.call_args
|
||||||
|
assert kwargs["categories"] == [7000]
|
||||||
|
|
||||||
|
def test_searches_with_audiobook_category(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = []
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
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 = []
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = _make_book()
|
||||||
|
src.search(book, _make_plan(book), expand_search=True, content_type="ebook")
|
||||||
|
_, kwargs = client.search.call_args
|
||||||
|
assert kwargs["categories"] is None
|
||||||
|
|
||||||
|
def test_deduplicates_results_by_guid(self, monkeypatch):
|
||||||
|
dup = _make_result(guid="same-guid")
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.side_effect = [[dup], [dup]] # two queries, same result each
|
||||||
|
book = _make_book()
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
plan_two = ReleaseSearchPlan(
|
||||||
|
languages=["en"],
|
||||||
|
isbn_candidates=[],
|
||||||
|
author="Frank Herbert",
|
||||||
|
title_variants=[
|
||||||
|
ReleaseSearchVariant("Dune", "Frank Herbert"),
|
||||||
|
ReleaseSearchVariant("Düne", "Frank Herbert"),
|
||||||
|
],
|
||||||
|
grouped_title_variants=[],
|
||||||
|
manual_query=None,
|
||||||
|
indexers=[],
|
||||||
|
)
|
||||||
|
results = src.search(book, plan_two)
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
def test_auto_expand_retries_without_categories(self, monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def fake_search(query, categories=None):
|
||||||
|
calls.append(categories)
|
||||||
|
return [] if categories else [_make_result()]
|
||||||
|
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.side_effect = fake_search
|
||||||
|
src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUTO_EXPAND": True})
|
||||||
|
book = _make_book()
|
||||||
|
results = src.search(book, _make_plan(book), content_type="ebook")
|
||||||
|
|
||||||
|
assert [7000] in calls # first call with category
|
||||||
|
assert None in calls # auto-expanded call without
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
def test_no_auto_expand_when_disabled(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = []
|
||||||
|
src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUTO_EXPAND": False})
|
||||||
|
book = _make_book()
|
||||||
|
src.search(book, _make_plan(book))
|
||||||
|
# Only one call per query, no retry
|
||||||
|
assert client.search.call_count == 1
|
||||||
|
|
||||||
|
def test_converts_results_to_releases(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = [_make_result()]
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = _make_book()
|
||||||
|
results = src.search(book, _make_plan(book))
|
||||||
|
assert len(results) == 1
|
||||||
|
r = results[0]
|
||||||
|
assert r.source == "newznab"
|
||||||
|
assert r.protocol == ReleaseProtocol.NZB
|
||||||
|
|
||||||
|
def test_manual_query_overrides_title_variants(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = []
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = _make_book()
|
||||||
|
plan = _make_plan(book, manual_query="custom search term")
|
||||||
|
src.search(book, plan)
|
||||||
|
call_kwargs = client.search.call_args
|
||||||
|
assert call_kwargs[1]["query"] == "custom search term"
|
||||||
|
|
||||||
|
def test_isbn_used_when_no_title_variants(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.return_value = []
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = _make_book()
|
||||||
|
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||||
|
|
||||||
|
isbn_plan = ReleaseSearchPlan(
|
||||||
|
languages=["en"],
|
||||||
|
isbn_candidates=["9780441013593"],
|
||||||
|
author="Frank Herbert",
|
||||||
|
title_variants=[],
|
||||||
|
grouped_title_variants=[],
|
||||||
|
manual_query=None,
|
||||||
|
indexers=[],
|
||||||
|
)
|
||||||
|
src.search(book, isbn_plan)
|
||||||
|
call_kwargs = client.search.call_args
|
||||||
|
assert call_kwargs[1]["query"] == "9780441013593"
|
||||||
|
|
||||||
|
def test_exception_in_client_returns_empty(self, monkeypatch):
|
||||||
|
client = MagicMock()
|
||||||
|
client.search.side_effect = RuntimeError("boom")
|
||||||
|
src = self._patched_source(monkeypatch, client)
|
||||||
|
book = _make_book()
|
||||||
|
results = src.search(book, _make_plan(book))
|
||||||
|
assert results == []
|
||||||
Reference in New Issue
Block a user