From b7002a6eca25b3fbb0c8091f1306c34768522a07 Mon Sep 17 00:00:00 2001 From: Marcelo Rodrigo Date: Sun, 20 Sep 2026 05:15:57 +0200 Subject: [PATCH] feat: Add TorBox client support and settings integration (#1342) Add **TorBox** as a torrent download client for Prowlarr releases. Users can select `TorBox` in the download client settings, configure it with the new `TORBOX_API_KEY` environment variable, and verify their credentials with the connection test button. The integration supports both magnet links and `.torrent` files. It tracks the torrent lifecycle through TorBox, downloads supported book and audiobook files from the TorBox CDN, preserves safe nested file paths, and cleans up remote and local download state. Important: Shared HTTP download logs omit full download URLs and URL-bearing exception text to avoid exposing credentials, following best practices. This applies to all clients that use the shared `download_url()` path; URLs remain available to the HTTP operations themselves. --- There is already related work in progress in #1173, which includes both torrent and direct-download support for TorBox. This PR is not intended to replace or compete with that contribution. It offers the tested torrent client functionality as a smaller, focused change that can make TorBox available to the community sooner. The direct-download integration proposed in #1173 remains valuable and could be reviewed or introduced separately. Automated tests cover configuration, connection validation, magnet and torrent-file submission, API errors, status and progress handling, file retrieval, path traversal protection, cancellation, cleanup, and sensitive URL redaction. I also validated the complete flow locally with several magnet links and `.torrent` downloads. TorBox processed the torrents and Shelfmark downloaded the resulting files as expected. AI was used to help with the implementation, with human validation. This PR and long description? Took me some good minutes at night after work, but gives me joy to open this PR to share with the community this improvement. --- docs/environment-variables.md | 12 +- shelfmark/download/clients/__init__.py | 1 + shelfmark/download/clients/settings.py | 33 ++ shelfmark/download/clients/torbox.py | 618 ++++++++++++++++++++ shelfmark/download/clients/torrent_utils.py | 41 +- shelfmark/download/http.py | 30 +- tests/download/test_http_download_url.py | 180 ++++++ tests/prowlarr/test_debrid_clients.py | 43 ++ tests/prowlarr/test_torbox_client.py | 483 +++++++++++++++ tests/prowlarr/test_torbox_settings.py | 49 ++ 10 files changed, 1465 insertions(+), 25 deletions(-) create mode 100644 shelfmark/download/clients/torbox.py create mode 100644 tests/prowlarr/test_torbox_client.py create mode 100644 tests/prowlarr/test_torbox_settings.py 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", + }