diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6cae56a..dd98c1c 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1582,6 +1582,7 @@ How long to keep cached search results before they expire. | `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ | | `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ | | `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ | +| `TORBOX_API_KEY` | TorBox API Key from your TorBox account settings | string (secret) | _none_ | | `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ | | `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ | | `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ | @@ -1633,7 +1634,7 @@ Choose which torrent client to use - **Type:** string (choice) - **Default:** _empty string_ -- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) +- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) #### `ALLDEBRID_API_KEY` @@ -1653,6 +1654,15 @@ Real-Debrid API Key (Secret Token) from your Real-Debrid account settings - **Type:** string (secret) - **Default:** _none_ +#### `TORBOX_API_KEY` + +**API Key** + +TorBox API Key from your TorBox account settings + +- **Type:** string (secret) +- **Default:** _none_ + #### `QBITTORRENT_URL` **qBittorrent URL** diff --git a/shelfmark/download/clients/__init__.py b/shelfmark/download/clients/__init__.py index 7dfd58f..cb6a6e3 100644 --- a/shelfmark/download/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -384,6 +384,7 @@ _BUILTIN_CLIENT_MODULES = ( "shelfmark.download.clients.realdebrid", "shelfmark.download.clients.rtorrent", "shelfmark.download.clients.sabnzbd", + "shelfmark.download.clients.torbox", "shelfmark.download.clients.transmission", ) _builtin_client_state = {"loaded": False} diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 9cd1709..451aec8 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -565,6 +565,23 @@ def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) -> return {"success": success, "message": message} +def _test_torbox_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Test the TorBox API connection using current form values.""" + from shelfmark.core.config import config + from shelfmark.download.clients.torbox import TorBoxClient + + current_values = current_values or {} + api_key = _resolve_string_setting(current_values, config.get, "TORBOX_API_KEY") + + if not api_key: + return {"success": False, "message": "TorBox API Key is required"} + + client = TorBoxClient() + client._api_key = api_key + success, message = client.test_connection() + return {"success": success, "message": message} + + # ==================== Download Clients Tab ==================== @@ -593,6 +610,7 @@ def prowlarr_clients_settings() -> list[SettingsField]: {"value": "blackhole", "label": "Blackhole"}, {"value": "qbittorrent", "label": "qBittorrent"}, {"value": "realdebrid", "label": "Real-Debrid"}, + {"value": "torbox", "label": "TorBox"}, {"value": "transmission", "label": "Transmission"}, {"value": "deluge", "label": "Deluge"}, {"value": "rtorrent", "label": "rTorrent"}, @@ -636,6 +654,21 @@ def prowlarr_clients_settings() -> list[SettingsField]: callback=_test_realdebrid_connection, show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"}, ), + # --- TorBox Settings --- + PasswordField( + key="TORBOX_API_KEY", + label="API Key", + description="TorBox API Key from your TorBox account settings", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"}, + ), + ActionButton( + key="test_torbox", + label="Test Connection", + description="Verify your TorBox configuration", + style="primary", + callback=_test_torbox_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"}, + ), # --- qBittorrent Settings --- TextField( key="QBITTORRENT_URL", diff --git a/shelfmark/download/clients/torbox.py b/shelfmark/download/clients/torbox.py new file mode 100644 index 0000000..85e1b74 --- /dev/null +++ b/shelfmark/download/clients/torbox.py @@ -0,0 +1,618 @@ +"""TorBox debrid service client for Shelfmark.""" + +from __future__ import annotations + +import math +import shutil +import threading +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, ClassVar, NoReturn +from urllib.parse import urlparse + +import requests + +from shelfmark.config.env import TMP_DIR +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.download.clients import ( + DownloadClient, + DownloadState, + DownloadStatus, + register_client, +) +from shelfmark.download.clients._coercion import config_text +from shelfmark.download.clients.torrent_utils import ( + DebridMagnet, + DebridUpload, + resolve_debrid_upload, +) +from shelfmark.download.http import download_url +from shelfmark.download.network import get_ssl_verify + +logger = setup_logger(__name__) + +_API_BASE = "https://api.torbox.app/v1/api" +_API_TIMEOUT = 30 +_STATUS_TIMEOUT = 15 +_WORKER_JOIN_TIMEOUT = 5.0 + +_BOOK_EXTENSIONS = ( + ".aac", + ".azw", + ".azw3", + ".cbr", + ".cbz", + ".djvu", + ".doc", + ".docx", + ".epub", + ".fb2", + ".flac", + ".lit", + ".m4a", + ".m4b", + ".mobi", + ".mp3", + ".mp4", + ".ogg", + ".opus", + ".pdf", + ".rtf", + ".txt", + ".wma", +) + +_TERMINAL_STATES = frozenset({"error", "failed", "missingfiles", "dead"}) +_PLAN_NAMES = {0: "Free", 1: "Essential", 2: "Pro", 3: "Standard"} + + +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + +@dataclass +class _DownloadState: + """Internal mutable state for an in-progress TorBox download.""" + + torrent_id: str + name: str + target_dir: Path + phase: str = "waiting_torbox" + error_message: str | None = None + progress: float = 0.0 + download_thread: threading.Thread | None = None + cancel_event: threading.Event = field(default_factory=threading.Event) + lock: threading.Lock = field(default_factory=threading.Lock) + + +@register_client("torrent") +class TorBoxClient(DownloadClient): + """Download torrent content through TorBox and its CDN.""" + + protocol = "torrent" + name = "torbox" + prefers_torrent_file = True + + _downloads: ClassVar[dict[str, _DownloadState]] = {} + _downloads_lock = threading.Lock() + + def __init__(self) -> None: + self._api_key = config_text(config.get("TORBOX_API_KEY", "")) + + def _auth_headers(self) -> dict[str, str]: + """Return the authorization headers used by TorBox API calls.""" + return {"Authorization": f"Bearer {self._api_key}"} + + @staticmethod + def is_configured() -> bool: + """Return True when TorBox is selected and an API key exists.""" + client = config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) + api_key = config_text(config.get("TORBOX_API_KEY", "")) + return client == "torbox" and bool(api_key) + + def test_connection(self) -> tuple[bool, str]: + """Validate the API key and report the connected TorBox plan.""" + if not self._api_key: + return False, "TorBox API Key is required" + + try: + user = self._request_data( + "GET", + "/user/me", + operation="account lookup", + params={"settings": "false"}, + timeout=_STATUS_TIMEOUT, + ) + if not isinstance(user, dict): + _raise_runtime_error("TorBox account lookup returned invalid user data") + except ( + OSError, + requests.exceptions.RequestException, + RuntimeError, + TypeError, + ValueError, + ) as e: + return False, f"Connection failed: {e}" + + plan_value = user.get("plan") + plan = _PLAN_NAMES.get(plan_value, "Unknown") if isinstance(plan_value, int) else "Unknown" + email = user.get("email") + account = f" as '{email}'" if isinstance(email, str) and email else "" + return True, f"Connected to TorBox{account} ({plan} plan)" + + def add_download( + self, + url: str, + name: str, + category: str | None = None, + expected_hash: str | None = None, + **kwargs: Any, + ) -> str: + """Send a magnet or torrent file to TorBox and return its torrent ID.""" + if not self._api_key: + _raise_runtime_error("TorBox API key is not configured") + + try: + upload = resolve_debrid_upload(url, expected_hash=expected_hash) + data = self._send_torrent(upload, name) + torrent_id = self._normalize_torrent_id(data.get("torrent_id")) + + target_dir = TMP_DIR / f"torbox_{torrent_id}" + target_dir.mkdir(parents=True, exist_ok=True) + state = _DownloadState(torrent_id=torrent_id, name=name, target_dir=target_dir) + with self._downloads_lock: + self._downloads[torrent_id] = state + + logger.info( + "Added torrent to TorBox: ID %s", torrent_id, extra={"torrent_id": torrent_id} + ) + except Exception: + logger.exception("Failed to add torrent to TorBox") + raise + else: + return torrent_id + + def _send_torrent(self, upload: DebridUpload, name: str) -> dict[str, Any]: + """Create a TorBox torrent from a magnet link or torrent file.""" + endpoint = "/torrents/createtorrent" + data: dict[str, str] = {"name": name} + files: dict[str, tuple[str, bytes, str]] | None = None + if isinstance(upload, DebridMagnet): + data["magnet"] = upload.magnet_url + else: + files = { + "file": ( + "release.torrent", + upload.torrent_data, + "application/x-bittorrent", + ) + } + + result = self._request_data( + "POST", + endpoint, + operation="torrent creation", + data=data, + files=files, + timeout=_API_TIMEOUT, + ) + if not isinstance(result, dict): + _raise_runtime_error("TorBox torrent creation returned invalid data") + return result + + def get_status(self, download_id: str) -> DownloadStatus: + """Poll TorBox for torrent status and drive local file retrieval.""" + download_id = self._normalize_torrent_id(download_id) + state = self._ensure_state(download_id) + with state.lock: + if state.phase == "error": + return DownloadStatus.error(state.error_message or "TorBox download failed") + if state.phase == "complete": + return DownloadStatus( + progress=100.0, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(state.target_dir), + ) + if state.phase == "downloading_http": + return DownloadStatus( + progress=state.progress, + state=DownloadState.DOWNLOADING, + message="Downloading files via TorBox...", + complete=False, + file_path=None, + ) + + try: + data = self._request_data( + "GET", + "/torrents/mylist", + operation="torrent status lookup", + params={"id": download_id, "bypass_cache": "true"}, + timeout=_STATUS_TIMEOUT, + ) + torrent = self._extract_torrent(data, download_id) + return self._handle_torrent_status(torrent, state) + except Exception as e: + logger.exception( + "Failed to check TorBox torrent status", + extra={"torrent_id": download_id}, + ) + return DownloadStatus.error(f"TorBox status check failed: {e}") + + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: + """Delete the remote torrent and clean up its local temporary directory.""" + download_id = self._normalize_torrent_id(download_id) + remote_removed = True + try: + self._request_data( + "POST", + "/torrents/controltorrent", + operation="torrent deletion", + json={"torrent_id": int(download_id), "operation": "delete"}, + timeout=_STATUS_TIMEOUT, + require_data=False, + ) + except OSError, requests.exceptions.RequestException, RuntimeError, TypeError, ValueError: + remote_removed = False + logger.warning("Failed to delete TorBox torrent", extra={"torrent_id": download_id}) + + with self._downloads_lock: + state = self._downloads.get(download_id) + if state: + with state.lock: + state.cancel_event.set() + if state.download_thread and state.download_thread is not threading.current_thread(): + state.download_thread.join(_WORKER_JOIN_TIMEOUT) + if state.download_thread.is_alive(): + logger.warning( + "TorBox retrieval thread did not stop; deferring cleanup", + extra={"torrent_id": download_id}, + ) + return False + with self._downloads_lock: + state = self._downloads.pop(download_id, None) + target_dir = state.target_dir if state else TMP_DIR / f"torbox_{download_id}" + + local_removed = True + if target_dir.exists(): + try: + shutil.rmtree(target_dir) + except OSError: + local_removed = False + logger.warning( + "Failed to remove TorBox temporary files", + extra={"torrent_id": download_id}, + ) + return remote_removed and local_removed + + def get_download_path(self, download_id: str) -> str | None: + """Return the local directory once TorBox files have been retrieved.""" + download_id = self._normalize_torrent_id(download_id) + with self._downloads_lock: + state = self._downloads.get(download_id) + if state and state.phase == "complete": + return str(state.target_dir) + return None + + def _request_data( + self, + method: str, + endpoint: str, + *, + operation: str, + require_data: bool = True, + **kwargs: object, + ) -> Any: + """Send a TorBox request and validate its JSON response envelope.""" + url = f"{_API_BASE}{endpoint}" + request_kwargs: Any = { + "headers": self._auth_headers(), + "verify": get_ssl_verify(url), + **kwargs, + } + request: Any = requests.get if method == "GET" else requests.post + try: + response = request(url, **request_kwargs) + except requests.exceptions.RequestException as e: + raise RuntimeError(f"TorBox {operation} failed: {type(e).__name__}") from None + + try: + payload = response.json() + except (AttributeError, TypeError, ValueError) as e: + _raise_runtime_error(f"TorBox {operation} returned invalid JSON: {e}") + + if not isinstance(payload, dict): + _raise_runtime_error(f"TorBox {operation} returned an invalid response") + + error = payload.get("error") + detail = payload.get("detail") + status_code = getattr(response, "status_code", 200) + if not isinstance(status_code, int) or not 200 <= status_code < 300: + message = detail if isinstance(detail, str) and detail else f"HTTP {status_code}" + code = f" [{error}]" if isinstance(error, str) and error else "" + _raise_runtime_error(f"TorBox {operation} failed{code}: {message}") + if payload.get("success") is not True or error: + message = detail if isinstance(detail, str) and detail else "Unknown TorBox error" + code = f" [{error}]" if isinstance(error, str) and error else "" + _raise_runtime_error(f"TorBox {operation} failed{code}: {message}") + + data = payload.get("data") + if require_data and data is None: + _raise_runtime_error(f"TorBox {operation} returned no data") + return data + + def _ensure_state(self, download_id: str) -> _DownloadState: + """Get or create download state for a TorBox torrent ID.""" + download_id = self._normalize_torrent_id(download_id) + with self._downloads_lock: + state = self._downloads.get(download_id) + if state is None: + state = _DownloadState( + torrent_id=download_id, + name=f"Download {download_id}", + target_dir=TMP_DIR / f"torbox_{download_id}", + ) + self._downloads[download_id] = state + return state + + @staticmethod + def _normalize_torrent_id(value: object) -> str: + """Return a canonical positive decimal TorBox torrent ID.""" + torrent_id = str(value) if value is not None else "" + if not torrent_id.isascii() or not torrent_id.isdecimal(): + _raise_runtime_error("TorBox returned an invalid torrent ID") + + normalized = str(int(torrent_id)) + if normalized == "0": + _raise_runtime_error("TorBox returned an invalid torrent ID") + return normalized + + @staticmethod + def _extract_torrent(data: Any, download_id: str) -> dict[str, Any]: + """Extract the requested torrent from TorBox's object or list response.""" + if isinstance(data, dict): + return data + if isinstance(data, list): + for torrent in data: + if isinstance(torrent, dict) and str(torrent.get("id", "")) == download_id: + return torrent + _raise_runtime_error(f"TorBox torrent {download_id} was not found") + + def _handle_torrent_status( + self, + torrent: dict[str, Any], + state: _DownloadState, + ) -> DownloadStatus: + """Map a TorBox torrent object into Shelfmark download status.""" + remote_state = str(torrent.get("download_state", "unknown")) + normalized_state = remote_state.lower() + if normalized_state in _TERMINAL_STATES: + message = torrent.get("tracker_message") or f"TorBox status error: {remote_state}" + self._set_error(state, str(message)) + return DownloadStatus.error(str(message)) + + finished = torrent.get("download_finished") is True + present = torrent.get("download_present") is True + if finished and not present: + message = "TorBox finished processing but the download is unavailable" + self._set_error(state, message) + return DownloadStatus.error(message) + if finished and present: + files = torrent.get("files") + if not isinstance(files, list): + message = "TorBox returned no file list for a completed torrent" + self._set_error(state, message) + return DownloadStatus.error(message) + self._maybe_start_download_thread(state, files) + return DownloadStatus( + progress=50.0, + state=DownloadState.DOWNLOADING, + message="TorBox ready, retrieving files...", + complete=False, + file_path=None, + ) + + progress = self._normalize_remote_progress(torrent.get("progress")) * 0.5 + speed = self._integer_value(torrent.get("download_speed")) + eta = self._integer_value(torrent.get("eta")) + name = torrent.get("name") or state.name + return DownloadStatus( + progress=progress, + state=DownloadState.DOWNLOADING, + message=f"TorBox processing torrent ({name}: {remote_state})", + complete=False, + file_path=None, + download_speed=speed, + eta=eta, + ) + + @staticmethod + def _normalize_remote_progress(value: object) -> float: + """Normalize fractional or percentage TorBox progress to 0 through 100.""" + if not isinstance(value, int | float | str): + return 0.0 + try: + progress = float(value) + except TypeError, ValueError: + return 0.0 + if not math.isfinite(progress): + return 0.0 + if 0.0 <= progress <= 1.0: + progress *= 100.0 + return max(0.0, min(100.0, progress)) + + @staticmethod + def _integer_value(value: object) -> int | None: + """Return an integer metric when TorBox provided a numeric value.""" + if not isinstance(value, int | float | str): + return None + try: + return int(value) + except TypeError, ValueError: + return None + + def _maybe_start_download_thread( + self, + state: _DownloadState, + files: list[dict[str, Any]], + ) -> None: + """Start exactly one background worker to retrieve TorBox files.""" + with state.lock: + already_running = state.phase in {"downloading_http", "complete"} + thread_alive = state.download_thread is not None and state.download_thread.is_alive() + if already_running or thread_alive: + return + state.phase = "downloading_http" + state.progress = 50.0 + state.download_thread = threading.Thread( + target=self._process_and_download, + args=(state, files), + daemon=True, + ) + state.download_thread.start() + + def _process_and_download(self, state: _DownloadState, files: list[dict[str, Any]]) -> None: + """Request direct file links from TorBox and download supported content.""" + try: + if state.cancel_event.is_set(): + return + relevant = [ + file_info + for file_info in files + if self._file_name(file_info).lower().endswith(_BOOK_EXTENSIONS) + ] + if not relevant: + _raise_runtime_error("TorBox torrent contains no supported book or audiobook files") + + with state.lock: + if state.cancel_event.is_set(): + return + state.target_dir.mkdir(parents=True, exist_ok=True) + for index, file_info in enumerate(relevant, start=1): + if state.cancel_event.is_set(): + return + file_id = self._file_id(file_info) + relative_path = self._safe_relative_path(file_info, state.target_dir) + direct_url = self._request_download_link(state.torrent_id, file_id) + buffer = download_url( + direct_url, + referer="https://torbox.app/", + cancel_flag=state.cancel_event, + ) + if state.cancel_event.is_set(): + return + if not buffer: + _raise_runtime_error( + f"TorBox file download failed for torrent {state.torrent_id}, file {file_id}" + ) + + destination = state.target_dir / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("wb") as output: + buffer.seek(0) + shutil.copyfileobj(buffer, output) + with state.lock: + state.progress = 50.0 + index / len(relevant) * 50.0 + + with state.lock: + if state.cancel_event.is_set(): + return + state.phase = "complete" + state.progress = 100.0 + logger.info( + "TorBox download complete: ID %s", + state.torrent_id, + extra={"torrent_id": state.torrent_id}, + ) + except Exception as e: + if state.cancel_event.is_set(): + logger.info( + "TorBox file retrieval cancelled", + extra={"torrent_id": state.torrent_id}, + ) + return + logger.exception( + "TorBox file retrieval failed", + extra={"torrent_id": state.torrent_id}, + ) + self._set_error(state, str(e) or "TorBox file retrieval failed") + + def _request_download_link(self, torrent_id: str, file_id: int) -> str: + """Request a temporary direct link without exposing the token in messages.""" + data = self._request_data( + "GET", + "/torrents/requestdl", + operation="file-link request", + params={ + "token": self._api_key, + "torrent_id": torrent_id, + "file_id": file_id, + "redirect": "false", + "append_name": "true", + }, + timeout=_API_TIMEOUT, + ) + if not isinstance(data, str): + _raise_runtime_error( + f"TorBox returned an invalid download link for torrent {torrent_id}, file {file_id}" + ) + parsed = urlparse(data) + if parsed.scheme != "https" or not parsed.hostname: + _raise_runtime_error( + f"TorBox returned an invalid download link for torrent {torrent_id}, file {file_id}" + ) + return data + + @staticmethod + def _file_name(file_info: dict[str, Any]) -> str: + """Return the provider path, falling back to its shortened name.""" + name = file_info.get("name") + if isinstance(name, str) and name.strip(): + return name + short_name = file_info.get("short_name") + return short_name.strip() if isinstance(short_name, str) else "" + + @staticmethod + def _file_id(file_info: dict[str, Any]) -> int: + """Return a validated TorBox file ID.""" + try: + return int(file_info["id"]) + except (KeyError, TypeError, ValueError) as e: + _raise_runtime_error(f"TorBox returned an invalid file ID: {e}") + + @classmethod + def _safe_relative_path(cls, file_info: dict[str, Any], target_dir: Path) -> Path: + """Validate external file metadata before writing below ``target_dir``.""" + name = cls._file_name(file_info) + if not name: + _raise_runtime_error("TorBox returned a file without a name") + + normalized = name.replace("\\", "/") + relative_path = PurePosixPath(normalized) + windows_path = PureWindowsPath(name) + if ( + relative_path.is_absolute() + or windows_path.is_absolute() + or windows_path.drive + or ".." in relative_path.parts + ): + _raise_runtime_error(f"TorBox returned an unsafe file path: {name}") + if relative_path == PurePosixPath("."): + _raise_runtime_error("TorBox returned a file without a usable name") + + destination = (target_dir / Path(*relative_path.parts)).resolve() + try: + destination.relative_to(target_dir.resolve()) + except ValueError: + _raise_runtime_error(f"TorBox returned an unsafe file path: {name}") + return Path(*relative_path.parts) + + @staticmethod + def _set_error(state: _DownloadState, message: str) -> None: + """Record a terminal local error for later polling calls.""" + with state.lock: + state.phase = "error" + state.error_message = message diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index 526d113..6db3608 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -9,7 +9,7 @@ import time from binascii import Error as BinasciiError from dataclasses import dataclass from threading import Lock -from urllib.parse import ParseResult, parse_qs, urljoin, urlparse +from urllib.parse import ParseResult, parse_qs, urljoin, urlparse, urlunparse import requests @@ -51,6 +51,28 @@ _torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {} type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str +def _safe_url(url: str, *, limit: int = 120) -> str: + """Return a log-safe URL: scheme/host/path kept, query and fragment dropped. + + Torrent download URLs commonly carry credentials in their query string + (Prowlarr's ``apikey=...`` proxy links among them), so raw URLs must never + reach logs or exception messages. + """ + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + return f"" + safe = urlunparse(parsed._replace(query="", fragment="")) + return safe[:limit] + + +_URL_IN_TEXT_PATTERN = re.compile(r"https?://\S+") + + +def _redact_urls_in_text(text: str) -> str: + """Scrub credential-bearing URLs out of free-form text such as exception messages.""" + return _URL_IN_TEXT_PATTERN.sub(lambda match: _safe_url(match.group(0)), text) + + @dataclass class TorrentInfo: """Parsed information from a torrent URL.""" @@ -133,7 +155,7 @@ def resolve_debrid_upload(url: str, *, expected_hash: str | None = None) -> Debr return DebridMagnet(magnet_url=f"magnet:?xt=urn:btih:{info.info_hash}") reason = info.fetch_error or "no magnet link, info hash, or torrent file was available" - msg = f"Could not resolve a torrent to send from {url[:120]} ({reason})" + msg = f"Could not resolve a torrent to send from {_safe_url(url)} ({reason})" raise ValueError(msg) @@ -185,7 +207,7 @@ def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None: if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS: del _torrent_fetch_cache[url] return None - logger.debug("Reusing recently fetched torrent data for: %s...", url[:80]) + logger.debug("Reusing recently fetched torrent data for: %s...", _safe_url(url)) return info @@ -231,7 +253,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo: return urljoin(current, location) try: - logger.debug("Fetching torrent file from: %s...", url[:80]) + logger.debug("Fetching torrent file from: %s...", _safe_url(url)) # Redirects are followed manually: some indexers redirect download URLs # to magnet links, and each hop must decide anew whether it may see the @@ -264,7 +286,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo: magnet_url=redirect_url, ) if redirects_remaining <= 0: - logger.warning("Too many redirects fetching torrent file: %s...", url[:80]) + logger.warning("Too many redirects fetching torrent file: %s...", _safe_url(url)) return TorrentInfo( info_hash=None, torrent_data=None, @@ -272,7 +294,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo: fetch_error="too many redirects", ) redirects_remaining -= 1 - logger.debug("Following redirect to: %s...", redirect_url[:80]) + logger.debug("Following redirect to: %s...", _safe_url(redirect_url)) current_url = redirect_url resp.raise_for_status() @@ -298,8 +320,11 @@ def _fetch_torrent_info(url: str) -> TorrentInfo: logger.warning("Could not extract hash from torrent file") return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False) except _TORRENT_FETCH_ERRORS as e: - logger.warning("Could not fetch torrent file: %s", e) - return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=str(e)) + # Exception messages can repeat the source or redirect URL, including its + # credentials; scrub them before logging or storing the reason. + message = _redact_urls_in_text(str(e)) + logger.warning("Could not fetch torrent file: %s: %s", type(e).__name__, message) + return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=message) def _is_trusted_torrent_fetch_url(url: str) -> bool: diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py index dc201a9..106c60c 100644 --- a/shelfmark/download/http.py +++ b/shelfmark/download/http.py @@ -326,10 +326,10 @@ def _try_rotation( ) if action in ("mirror", "dns") and new_base: new_url = selector.rewrite(original_url) - logger.info("[%s] switching to: %s", action, new_url) + logger.info("[%s] switching mirror", action) return new_url elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider(): - logger.info("[dns-rotate] retrying: %s", original_url) + logger.info("[dns-rotate] retrying download") return original_url return None @@ -880,12 +880,7 @@ def download_url( f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})", ) - logger.info( - "Downloading: %s (attempt %s/%s)", - current_url, - attempt + 1, - MAX_DOWNLOAD_RETRIES, - ) + logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES) # Try with CF cookies/UA if available cookies = _apply_cf_bypass(current_url, headers) response = requests.get( @@ -923,7 +918,7 @@ def download_url( and bytes_downloaded < total_size * 0.9 and response.headers.get("content-type", "").startswith("text/html") ): - logger.warning("Received HTML instead of file: %s", current_url) + logger.warning("Received HTML instead of file") return None logger.debug("Download completed: %s bytes", bytes_downloaded) @@ -941,18 +936,21 @@ def download_url( parsed = urlparse(current_url) if _is_configured_zlib_host(parsed.hostname) and referer: zlib_cookie_refresh_attempted = True - logger.info("Z-Library 403 - refreshing cookies via referer: %s", referer) + logger.info("Z-Library 403 - refreshing cookies via referer") try: get_bypassed_page(referer, selector, cancel_flag) time.sleep(0.5) # Retry with fresh cookies (don't increment attempt) continue except _BYPASSER_ERRORS as cookie_err: - logger.warning("Z-Library cookie refresh failed: %s", cookie_err) + logger.warning( + "Z-Library cookie refresh failed: %s", + type(cookie_err).__name__, + ) # Non-retryable errors if status in _HTTP_STATUS_NON_RETRYABLE: - logger.warning("Download failed (%s): %s", status, current_url) + logger.warning("Download failed (%s)", status) return None # Rate limited - skip to next source immediately @@ -966,7 +964,7 @@ def download_url( # Timeout - don't retry, server likely overloaded if isinstance(e, requests.exceptions.Timeout): - logger.warning("Timeout: %s - skipping to next source", current_url) + logger.warning("Timeout - skipping to next source") if status_callback: status_callback("resolving", "Server timed out, trying next") return None @@ -993,14 +991,14 @@ def download_url( attempt += 1 continue - logger.warning("Download error: %s: %s", type(e).__name__, e) + logger.warning("Download error: %s", type(e).__name__) if attempt < MAX_DOWNLOAD_RETRIES - 1: time.sleep(_backoff_delay(attempt + 1)) attempt += 1 else: return buffer - logger.error("Download failed after %s attempts: %s", MAX_DOWNLOAD_RETRIES, link) + logger.error("Download failed after %s attempts", MAX_DOWNLOAD_RETRIES) return None @@ -1090,7 +1088,7 @@ def _try_resume( logger.info("Resume completed: %s bytes", start_byte) except requests.exceptions.RequestException as e: - logger.debug("Resume attempt %s failed: %s", attempt + 1, e) + logger.debug("Resume attempt %s failed: %s", attempt + 1, type(e).__name__) else: return buffer diff --git a/tests/download/test_http_download_url.py b/tests/download/test_http_download_url.py index 539e22e..2de9630 100644 --- a/tests/download/test_http_download_url.py +++ b/tests/download/test_http_download_url.py @@ -1,5 +1,7 @@ """Focused tests for download_url() retry, fallback, and resume behavior.""" +from unittest.mock import MagicMock + import requests @@ -156,3 +158,181 @@ def test_download_url_resumes_partial_download_after_connection_error(monkeypatc assert len(calls) == 2 assert "Range" not in calls[0]["headers"] assert calls[1]["headers"]["Range"] == "bytes=4-" + + +def test_download_does_not_log_a_url_bearing_resume_error(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + monkeypatch.setattr(http, "MAX_DOWNLOAD_RETRIES", 1) + sensitive_url = "https://cdn.example/book.epub?signature=secret" + initial_response = _FakeResponse( + 200, + headers={"content-length": "8"}, + chunks=[b"book"], + iter_error=requests.exceptions.ConnectionError(sensitive_url), + ) + get = MagicMock( + side_effect=[ + initial_response, + requests.exceptions.ConnectionError(sensitive_url), + requests.exceptions.ConnectionError(sensitive_url), + requests.exceptions.ConnectionError(sensitive_url), + ] + ) + monkeypatch.setattr(http.requests, "get", get) + + assert http.download_url(sensitive_url) is None + + assert sensitive_url not in str(logger.mock_calls) + logger.debug.assert_any_call("Resume attempt %s failed: %s", 1, "ConnectionError") + + +def test_download_logs_resume_error_type_without_exception_details(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + monkeypatch.setattr(http, "MAX_DOWNLOAD_RETRIES", 1) + download_url = "https://cdn.example/file.epub?signature=secret" + error = requests.exceptions.ConnectionError(download_url) + initial_response = _FakeResponse( + 200, + headers={"content-length": "8"}, + chunks=[b"book"], + iter_error=error, + ) + get = MagicMock(side_effect=[initial_response, error, error, error]) + monkeypatch.setattr(http.requests, "get", get) + + assert http.download_url(download_url) is None + + logger.debug.assert_any_call("Resume attempt %s failed: %s", 1, "ConnectionError") + + +def test_download_url_does_not_log_a_sensitive_url(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + monkeypatch.setattr( + http.requests, + "get", + lambda url, **_kwargs: _FakeResponse( + 200, + headers={"content-length": "4"}, + chunks=[b"book"], + url=url, + ), + ) + sensitive_url = "https://cdn.example/book.epub?signature=secret" + + assert http.download_url(sensitive_url) is not None + + assert sensitive_url not in str(logger.mock_calls) + + +def test_download_does_not_log_a_url_bearing_request_error(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + sensitive_url = "https://cdn.example/book.epub?signature=secret" + monkeypatch.setattr( + http.requests, + "get", + lambda url, **_kwargs: (_ for _ in ()).throw(requests.exceptions.ConnectionError(url)), + ) + + assert http.download_url(sensitive_url) is None + + assert sensitive_url not in str(logger.mock_calls) + logger.warning.assert_any_call("Download error: %s", "ConnectionError") + + +def test_download_logs_request_error_type_without_exception_details(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + download_url = "https://cdn.example/file.epub?signature=secret" + error = requests.exceptions.ConnectionError(download_url) + monkeypatch.setattr( + http.requests, + "get", + lambda _url, **_kwargs: (_ for _ in ()).throw(error), + ) + + assert http.download_url(download_url) is None + + logger.warning.assert_any_call("Download error: %s", "ConnectionError") + + +def test_download_does_not_log_rotated_url_after_retry(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + monkeypatch.setattr(http, "MAX_DOWNLOAD_RETRIES", 2) + source_url = "https://source.example/file.epub" + rotated_url = "https://rotated.example/file.epub" + error = requests.exceptions.ConnectionError("connection reset") + monkeypatch.setattr(http.requests, "get", MagicMock(side_effect=error)) + monkeypatch.setattr(http, "_try_rotation", MagicMock(side_effect=[rotated_url, None])) + + assert http.download_url(source_url) is None + + assert rotated_url not in str(logger.mock_calls) + logger.info.assert_any_call("Downloading (attempt %s/%s)", 2, 2) + logger.error.assert_called_once_with("Download failed after %s attempts", 2) + + +def test_download_does_not_pass_url_redaction_state_into_rotation(monkeypatch): + http = _prepare_download_test(monkeypatch) + monkeypatch.setattr(http, "MAX_DOWNLOAD_RETRIES", 1) + sensitive_url = "https://cdn.example/book.epub?signature=secret" + monkeypatch.setattr( + http.requests, + "get", + lambda _url, **_kwargs: (_ for _ in ()).throw(requests.exceptions.ConnectionError("x")), + ) + rotation = MagicMock(return_value=None) + monkeypatch.setattr(http, "_try_rotation", rotation) + + assert http.download_url(sensitive_url) is None + + assert rotation.call_args.kwargs == {} + + +def test_download_does_not_log_a_rotated_url(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + monkeypatch.setattr(http, "MAX_DOWNLOAD_RETRIES", 2) + sensitive_url = "https://cdn.example/book.epub?signature=secret" + rotated_url = "https://rotated.example/book.epub?signature=secret" + error = requests.exceptions.ConnectionError("connection reset") + monkeypatch.setattr(http.requests, "get", MagicMock(side_effect=error)) + monkeypatch.setattr(http, "_try_rotation", MagicMock(side_effect=[rotated_url, None])) + + assert http.download_url(sensitive_url) is None + + logged = str(logger.mock_calls) + assert sensitive_url not in logged + assert rotated_url not in logged + assert "" not in logged + + +def test_default_download_does_not_log_the_rotated_url(monkeypatch): + http = _prepare_download_test(monkeypatch) + logger = MagicMock() + monkeypatch.setattr(http, "logger", logger) + monkeypatch.setattr(http, "MAX_DOWNLOAD_RETRIES", 2) + source_url = "https://source.example/file.epub?apikey=k" + rotated_url = "https://rotated.example/file.epub?apikey=k" + error = requests.exceptions.ConnectionError("connection reset") + monkeypatch.setattr(http.requests, "get", MagicMock(side_effect=error)) + + def fake_rotation(original_url, _current, _selector, *, fatal_reason=None): + return rotated_url + + monkeypatch.setattr(http, "_try_rotation", fake_rotation) + + assert http.download_url(source_url) is None + + assert rotated_url not in str(logger.mock_calls) diff --git a/tests/prowlarr/test_debrid_clients.py b/tests/prowlarr/test_debrid_clients.py index 15bb54f..d84be85 100644 --- a/tests/prowlarr/test_debrid_clients.py +++ b/tests/prowlarr/test_debrid_clients.py @@ -91,6 +91,49 @@ class TestResolveDebridUpload: assert "tracker unreachable" in str(excinfo.value) + def test_resolver_does_not_log_the_raw_url_or_exception_text(self, monkeypatch): + import shelfmark.download.clients.torrent_utils as torrent_utils + + logger = MagicMock() + monkeypatch.setattr(torrent_utils, "logger", logger) + _mock_fetch(monkeypatch, error=OSError(f"boom {_PROWLARR_PROXY_URL}")) + + with pytest.raises(ValueError): + resolve_debrid_upload(_PROWLARR_PROXY_URL) + + logged = str(logger.mock_calls) + assert _PROWLARR_PROXY_URL not in logged + assert torrent_utils._safe_url(_PROWLARR_PROXY_URL) in logged + + def test_resolver_does_not_log_a_credential_bearing_redirect(self, monkeypatch): + import shelfmark.download.clients.torrent_utils as torrent_utils + + logger = MagicMock() + monkeypatch.setattr(torrent_utils, "logger", logger) + redirect_url = "https://tracker.example/get/Dune?t=secret&apikey=k" + response = MagicMock( + status_code=302, + headers={"Location": redirect_url}, + content=b"", + ) + response.raise_for_status = MagicMock() + mock_get = MagicMock(return_value=response) + monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get) + + resolve_debrid_upload(_PROWLARR_PROXY_URL, expected_hash="a" * 40) + + logged = str(logger.mock_calls) + assert redirect_url not in logged + assert torrent_utils._safe_url(redirect_url) in logged + + def test_resolver_failure_message_keeps_out_the_source_url(self, monkeypatch): + _mock_fetch(monkeypatch, error=OSError("tracker unreachable")) + + with pytest.raises(ValueError, match="Could not resolve a torrent") as excinfo: + resolve_debrid_upload(_PROWLARR_PROXY_URL) + + assert "apikey=k" not in str(excinfo.value) + class TestRealDebridAdd: @staticmethod diff --git a/tests/prowlarr/test_torbox_client.py b/tests/prowlarr/test_torbox_client.py new file mode 100644 index 0000000..811f942 --- /dev/null +++ b/tests/prowlarr/test_torbox_client.py @@ -0,0 +1,483 @@ +"""TorBox download client lifecycle and API contract tests.""" + +from io import BytesIO +from threading import Event +from unittest.mock import MagicMock + +import pytest +import requests + +from shelfmark.download.clients import DownloadState +from shelfmark.download.clients.torbox import TorBoxClient, _DownloadState +from shelfmark.download.clients.torrent_utils import DebridTorrentFile + +API_KEY = "torbox-api-key" +MAGNET = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Dune" + + +def _response(data, *, success=True, error=None, detail="OK", status_code=200): + response = MagicMock(status_code=status_code) + response.json.return_value = { + "success": success, + "error": error, + "detail": detail, + "data": data, + } + return response + + +def _client(monkeypatch): + monkeypatch.setattr( + "shelfmark.download.clients.torbox.config.get", + lambda key, default="": {"TORBOX_API_KEY": API_KEY}.get(key, default), + ) + return TorBoxClient() + + +def _state(tmp_path): + return _DownloadState(torrent_id="42", name="Dune", target_dir=tmp_path) + + +class TestTorBoxConfiguration: + def test_is_configured_requires_selected_client_and_api_key(self, monkeypatch): + values = {"PROWLARR_TORRENT_CLIENT": "torbox", "TORBOX_API_KEY": API_KEY} + monkeypatch.setattr( + "shelfmark.download.clients.torbox.config.get", + lambda key, default="": values.get(key, default), + ) + + assert TorBoxClient.is_configured() is True + + values["TORBOX_API_KEY"] = "" + assert TorBoxClient.is_configured() is False + + def test_connection_reports_valid_free_plan(self, monkeypatch): + client = _client(monkeypatch) + get = MagicMock(return_value=_response({"email": "reader@example.com", "plan": 0})) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + + assert client.test_connection() == ( + True, + "Connected to TorBox as 'reader@example.com' (Free plan)", + ) + assert get.call_args.kwargs["headers"] == {"Authorization": f"Bearer {API_KEY}"} + assert get.call_args.kwargs["params"] == {"settings": "false"} + + def test_connection_returns_provider_detail_without_api_key(self, monkeypatch): + client = _client(monkeypatch) + get = MagicMock( + return_value=_response( + None, + success=False, + error="BAD_TOKEN", + detail="Your token is invalid or has expired.", + ) + ) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + + success, message = client.test_connection() + + assert success is False + assert "BAD_TOKEN" in message + assert API_KEY not in message + + +class TestTorBoxCreation: + def test_magnet_creation_sends_magnet_and_returns_torrent_id(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + post = MagicMock(return_value=_response({"torrent_id": 42})) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.post", post) + + assert client.add_download(MAGNET, "Dune") == "42" + + assert post.call_args.args[0].endswith("/torrents/createtorrent") + assert post.call_args.kwargs["data"] == {"name": "Dune", "magnet": MAGNET} + assert post.call_args.kwargs["files"] is None + assert (tmp_path / "torbox_42").is_dir() + + def test_torrent_file_creation_sends_binary_multipart_upload(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + monkeypatch.setattr( + "shelfmark.download.clients.torbox.resolve_debrid_upload", + lambda *_args, **_kwargs: DebridTorrentFile(torrent_data=b"torrent"), + ) + post = MagicMock(return_value=_response({"torrent_id": 42})) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.post", post) + + client.add_download("https://prowlarr.example/download", "Dune") + + assert post.call_args.kwargs["data"] == {"name": "Dune"} + assert post.call_args.kwargs["files"] == { + "file": ("release.torrent", b"torrent", "application/x-bittorrent") + } + + def test_creation_surfaces_torbox_error_detail(self, monkeypatch): + client = _client(monkeypatch) + post = MagicMock( + return_value=_response( + None, + success=False, + error="ACTIVE_LIMIT", + detail="You have reached your active download limit.", + ) + ) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.post", post) + + with pytest.raises(RuntimeError, match=r"ACTIVE_LIMIT.*active download limit"): + client.add_download(MAGNET, "Dune") + + def test_creation_rejects_unsafe_torrent_id_before_creating_files(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + post = MagicMock(return_value=_response({"torrent_id": "x/../../escape"})) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.post", post) + + with pytest.raises(RuntimeError, match="invalid torrent ID"): + client.add_download(MAGNET, "Dune") + + assert not (tmp_path.parent / "escape").exists() + + +class TestTorBoxStatus: + def test_fractional_and_percentage_progress_are_normalized(self): + assert TorBoxClient._normalize_remote_progress(0.25) == 25.0 + assert TorBoxClient._normalize_remote_progress(25) == 25.0 + assert TorBoxClient._normalize_remote_progress("invalid") == 0.0 + + def test_completed_state_without_finished_flag_remains_pollable(self, tmp_path): + client = TorBoxClient.__new__(TorBoxClient) + state = _state(tmp_path) + + status = client._handle_torrent_status( + {"download_state": "completed", "progress": 100, "name": "Dune"}, state + ) + + assert status.state == DownloadState.DOWNLOADING + assert status.progress == 50.0 + assert state.phase == "waiting_torbox" + + def test_finished_torrent_starts_retrieval_once(self, monkeypatch, tmp_path): + client = TorBoxClient.__new__(TorBoxClient) + state = _state(tmp_path) + start = MagicMock() + monkeypatch.setattr(client, "_maybe_start_download_thread", start) + torrent = { + "download_state": "cached", + "download_finished": True, + "download_present": True, + "files": [{"id": 1, "name": "Dune.epub"}], + } + + status = client._handle_torrent_status(torrent, state) + + assert status.progress == 50.0 + assert status.state == DownloadState.DOWNLOADING + start.assert_called_once_with(state, torrent["files"]) + + def test_starting_file_retrieval_preserves_completed_torrent_progress( + self, monkeypatch, tmp_path + ): + client = TorBoxClient.__new__(TorBoxClient) + state = _state(tmp_path) + thread = MagicMock() + thread.is_alive.return_value = False + monkeypatch.setattr( + "shelfmark.download.clients.torbox.threading.Thread", lambda **_kwargs: thread + ) + + client._maybe_start_download_thread(state, [{"id": 1, "name": "Dune.epub"}]) + + assert state.phase == "downloading_http" + assert state.progress == 50.0 + thread.start.assert_called_once() + + def test_finished_torrent_without_available_content_is_error(self, tmp_path): + client = TorBoxClient.__new__(TorBoxClient) + state = _state(tmp_path) + + status = client._handle_torrent_status( + {"download_finished": True, "download_present": False}, state + ) + + assert status.state == DownloadState.ERROR + assert "unavailable" in status.message + assert state.phase == "error" + + def test_explicit_error_state_is_terminal(self, tmp_path): + client = TorBoxClient.__new__(TorBoxClient) + state = _state(tmp_path) + + status = client._handle_torrent_status({"download_state": "error"}, state) + + assert status.state == DownloadState.ERROR + assert state.error_message == "TorBox status error: error" + + def test_status_request_bypasses_torbox_cache(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + get = MagicMock( + return_value=_response([{"id": 42, "download_state": "downloading", "progress": 20}]) + ) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + + status = client.get_status("42") + + assert status.progress == 10.0 + assert get.call_args.kwargs["params"] == {"id": "42", "bypass_cache": "true"} + + def test_status_normalizes_torrent_id_before_rehydrating_state(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + get = MagicMock( + return_value=_response([{"id": 42, "download_state": "downloading", "progress": 20}]) + ) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + + try: + client.get_status("0042") + + assert get.call_args.kwargs["params"] == {"id": "42", "bypass_cache": "true"} + assert "42" in TorBoxClient._downloads + finally: + TorBoxClient._downloads.pop("42", None) + + def test_status_rejects_unsafe_torrent_id_before_rehydrating_state(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + get = MagicMock() + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + + with pytest.raises(RuntimeError, match="invalid torrent ID"): + client.get_status("x/../../escape") + + get.assert_not_called() + assert not (tmp_path.parent / "escape").exists() + + +class TestTorBoxFileRetrieval: + def test_safe_relative_path_rejects_absolute_and_traversal_paths(self, tmp_path): + with pytest.raises(RuntimeError, match="unsafe"): + TorBoxClient._safe_relative_path({"name": "/Dune.epub"}, tmp_path) + with pytest.raises(RuntimeError, match="unsafe"): + TorBoxClient._safe_relative_path({"name": r"books\..\Dune.epub"}, tmp_path) + with pytest.raises(RuntimeError, match="unsafe"): + TorBoxClient._safe_relative_path({"name": r"C:\books\Dune.epub"}, tmp_path) + with pytest.raises(RuntimeError, match="unsafe"): + TorBoxClient._safe_relative_path({"name": r"C:books\Dune.epub"}, tmp_path) + + def test_process_downloads_supported_file_into_safe_relative_path(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + state = _state(tmp_path) + + class StreamingBuffer(BytesIO): + def getvalue(self): + raise AssertionError("file retrieval must stream the download buffer") + + monkeypatch.setattr( + client, "_request_download_link", lambda *_args: "https://cdn.example/Dune" + ) + buffer = StreamingBuffer(b"book content") + buffer.seek(0, 2) + download = MagicMock(return_value=buffer) + monkeypatch.setattr( + "shelfmark.download.clients.torbox.download_url", + download, + ) + + client._process_and_download(state, [{"id": 1, "name": "books/Dune.EPUB"}]) + + assert (tmp_path / "books" / "Dune.EPUB").read_bytes() == b"book content" + assert state.phase == "complete" + assert state.progress == 100.0 + assert download.call_args.kwargs["referer"] == "https://torbox.app/" + + def test_remove_cancels_active_retrieval_before_removing_files(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + target_dir = tmp_path / "torbox_42" + target_dir.mkdir() + state = _DownloadState(torrent_id="42", name="Dune", target_dir=target_dir) + started = Event() + + monkeypatch.setattr( + client, "_request_download_link", lambda *_args: "https://cdn.example/Dune" + ) + + def wait_for_cancellation(_url, *, cancel_flag, **_kwargs): + started.set() + assert cancel_flag.wait(timeout=1) + return None + + monkeypatch.setattr("shelfmark.download.clients.torbox.download_url", wait_for_cancellation) + monkeypatch.setattr( + "shelfmark.download.clients.torbox.requests.post", + MagicMock(return_value=_response(None)), + ) + TorBoxClient._downloads["42"] = state + + try: + client._maybe_start_download_thread(state, [{"id": 1, "name": "Dune.epub"}]) + assert started.wait(timeout=1) + + assert client.remove("42") is True + finally: + TorBoxClient._downloads.pop("42", None) + + assert not target_dir.exists() + assert not state.download_thread or not state.download_thread.is_alive() + + def test_file_link_request_keeps_token_out_of_error_message(self, monkeypatch): + client = _client(monkeypatch) + get = MagicMock( + return_value=_response( + None, + success=False, + error="BAD_TOKEN", + detail="Token rejected", + ) + ) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + + with pytest.raises(RuntimeError) as excinfo: + client._request_download_link("42", 7) + + assert API_KEY not in str(excinfo.value) + assert get.call_args.kwargs["params"]["token"] == API_KEY + + def test_file_link_accepts_an_https_url(self, monkeypatch): + client = _client(monkeypatch) + signed_url = "HtTpS://cdn.example/Dune?token=signed" + monkeypatch.setattr( + "shelfmark.download.clients.torbox.requests.get", + MagicMock(return_value=_response(signed_url)), + ) + + assert client._request_download_link("42", 7) == signed_url + + @staticmethod + def _link_request(monkeypatch, data: object) -> MagicMock: + get = MagicMock(return_value=_response(data)) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.get", get) + return get + + def test_file_link_rejects_http_urls(self, monkeypatch): + client = _client(monkeypatch) + self._link_request(monkeypatch, "http://cdn.example/Dune?token=signed") + + with pytest.raises(RuntimeError, match="invalid download link"): + client._request_download_link("42", 7) + + def test_file_link_rejects_non_url_data(self, monkeypatch): + client = _client(monkeypatch) + self._link_request(monkeypatch, "not a link") + + with pytest.raises(RuntimeError, match="invalid download link"): + client._request_download_link("42", 7) + + def test_file_link_rejects_hostless_urls(self, monkeypatch): + client = _client(monkeypatch) + self._link_request(monkeypatch, "https:///Dune") + + with pytest.raises(RuntimeError, match="invalid download link"): + client._request_download_link("42", 7) + + def test_file_link_transport_failure_keeps_token_out_of_state_and_logs( + self, monkeypatch, tmp_path + ): + client = _client(monkeypatch) + logger = MagicMock() + monkeypatch.setattr("shelfmark.download.clients.torbox.logger", logger) + monkeypatch.setattr( + "shelfmark.download.clients.torbox.requests.get", + MagicMock(side_effect=requests.exceptions.ConnectionError(f"token={API_KEY}")), + ) + state = _state(tmp_path) + + client._process_and_download(state, [{"id": 1, "name": "Dune.epub"}]) + + assert state.error_message == "TorBox file-link request failed: ConnectionError" + assert API_KEY not in str(logger.mock_calls) + + +class TestTorBoxCleanup: + def test_remove_cleans_local_state_when_torbox_rejects_deletion(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + target_dir = tmp_path / "torbox_42" + target_dir.mkdir() + state = _DownloadState(torrent_id="42", name="Dune", target_dir=target_dir) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + monkeypatch.setattr( + "shelfmark.download.clients.torbox.requests.post", + MagicMock( + return_value=_response( + None, success=False, error="NOT_OWNER", detail="You are not the owner." + ) + ), + ) + TorBoxClient._downloads["42"] = state + + try: + assert client.remove("42") is False + finally: + TorBoxClient._downloads.pop("42", None) + + assert not target_dir.exists() + + def test_remove_rejects_unsafe_torrent_id_before_deleting_files(self, monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("shelfmark.download.clients.torbox.TMP_DIR", tmp_path) + escaped_dir = tmp_path.parent / "escape" + escaped_dir.mkdir() + post = MagicMock() + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.post", post) + + with pytest.raises(RuntimeError, match="invalid torrent ID"): + client.remove("x/../../escape") + + post.assert_not_called() + assert escaped_dir.is_dir() + + +class TestTorBoxTorrentSelection: + def test_list_entries_must_match_the_requested_torrent_id(self): + torrent = {"id": 42, "download_state": "downloading"} + + assert TorBoxClient._extract_torrent([torrent], "42") is torrent + + def test_list_entry_with_a_different_id_is_rejected(self): + with pytest.raises(RuntimeError, match="torrent 42 was not found"): + TorBoxClient._extract_torrent([{"id": 99, "download_state": "downloading"}], "42") + + def test_sole_list_entry_without_an_id_is_rejected(self): + with pytest.raises(RuntimeError, match="torrent 42 was not found"): + TorBoxClient._extract_torrent([{"download_state": "downloading"}], "42") + + +class TestTorBoxRemoveWorkerTimeout: + def test_remove_defers_cleanup_when_worker_thread_survives_join_timeout( + self, monkeypatch, tmp_path + ): + client = _client(monkeypatch) + target_dir = tmp_path / "torbox_42" + target_dir.mkdir() + (target_dir / "Dune.epub").write_bytes(b"book") + state = _DownloadState(torrent_id="42", name="Dune", target_dir=target_dir) + thread = MagicMock() + thread.is_alive.return_value = True + state.download_thread = thread + post = MagicMock(return_value=_response(None)) + monkeypatch.setattr("shelfmark.download.clients.torbox.requests.post", post) + monkeypatch.setattr("shelfmark.download.clients.torbox._WORKER_JOIN_TIMEOUT", 0.01) + TorBoxClient._downloads["42"] = state + + try: + assert client.remove("42") is False + assert state.cancel_event.is_set() + thread.join.assert_called_once() + (timeout_arg,) = thread.join.call_args.args + assert timeout_arg > 0 + assert "42" in TorBoxClient._downloads + assert target_dir.exists() + finally: + TorBoxClient._downloads.pop("42", None) diff --git a/tests/prowlarr/test_torbox_settings.py b/tests/prowlarr/test_torbox_settings.py new file mode 100644 index 0000000..b9d436c --- /dev/null +++ b/tests/prowlarr/test_torbox_settings.py @@ -0,0 +1,49 @@ +"""TorBox Download Client settings and connection-action tests.""" + +from unittest.mock import MagicMock + +from shelfmark.core.settings_registry import ActionButton, PasswordField + + +def _field(fields, key): + return next(field for field in fields if field.key == key) + + +def test_torbox_fields_are_registered_with_conditional_visibility(): + from shelfmark.download.clients.settings import prowlarr_clients_settings + + fields = prowlarr_clients_settings() + client_field = _field(fields, "PROWLARR_TORRENT_CLIENT") + api_key_field = _field(fields, "TORBOX_API_KEY") + test_action = _field(fields, "test_torbox") + + assert {option["value"] for option in client_field.options} >= {"torbox"} + assert isinstance(api_key_field, PasswordField) + assert api_key_field.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"} + assert isinstance(test_action, ActionButton) + assert test_action.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"} + + +def test_torbox_connection_action_uses_unsaved_api_key(monkeypatch): + from shelfmark.core.config import config + from shelfmark.download.clients import settings as settings_module + + client = MagicMock() + client.test_connection.return_value = (True, "Connected") + client_type = MagicMock(return_value=client) + monkeypatch.setattr("shelfmark.download.clients.torbox.TorBoxClient", client_type) + monkeypatch.setattr(config, "get", lambda _key, default="": "saved-key") + + result = settings_module._test_torbox_connection({"TORBOX_API_KEY": "unsaved-key"}) + + assert result == {"success": True, "message": "Connected"} + assert client._api_key == "unsaved-key" + + +def test_torbox_connection_action_requires_api_key(): + from shelfmark.download.clients.settings import _test_torbox_connection + + assert _test_torbox_connection({"TORBOX_API_KEY": ""}) == { + "success": False, + "message": "TorBox API Key is required", + }