diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 9d13fb09..6ef1ea55 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1703,6 +1703,7 @@ How long to keep cached search results before they expire. | `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ | | `BLACKHOLE_DIRECTORY` | Directory where Shelfmark saves .torrent files for another downloader | string | _none_ | | `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ | +| `DEBRIDLINK_API_KEY` | Debrid-Link API Key from your Debrid-Link 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_ | @@ -1756,7 +1757,7 @@ Choose which torrent client to use - **Type:** string (choice) - **Default:** _empty string_ -- **Options:** `""` (None), `alldebrid` (AllDebrid), `blackhole` (Blackhole), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) +- **Options:** `""` (None), `alldebrid` (AllDebrid), `blackhole` (Blackhole), `debridlink` (Debrid-Link), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) #### `BLACKHOLE_DIRECTORY` @@ -1776,6 +1777,15 @@ AllDebrid API Key (apiv4) from your AllDebrid account settings - **Type:** string (secret) - **Default:** _none_ +#### `DEBRIDLINK_API_KEY` + +**API Key** + +Debrid-Link API Key from your Debrid-Link account settings + +- **Type:** string (secret) +- **Default:** _none_ + #### `REALDEBRID_API_KEY` **API Key** diff --git a/shelfmark/download/clients/__init__.py b/shelfmark/download/clients/__init__.py index cb6a6e3d..9f765917 100644 --- a/shelfmark/download/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -378,6 +378,7 @@ ClientType = TypeVar("ClientType", bound=DownloadClient) _BUILTIN_CLIENT_MODULES = ( "shelfmark.download.clients.alldebrid", "shelfmark.download.clients.blackhole", + "shelfmark.download.clients.debridlink", "shelfmark.download.clients.deluge", "shelfmark.download.clients.nzbget", "shelfmark.download.clients.qbittorrent", diff --git a/shelfmark/download/clients/debridlink.py b/shelfmark/download/clients/debridlink.py new file mode 100644 index 00000000..19854878 --- /dev/null +++ b/shelfmark/download/clients/debridlink.py @@ -0,0 +1,811 @@ +"""Debrid-Link debrid service client for Shelfmark. + +Routes magnet links through the Debrid-Link REST API (v2) to download torrent +content via Debrid-Link's seedbox infrastructure. + +Unlike the other debrid services, a completed Debrid-Link torrent already +carries a direct ``downloadUrl`` on every file, so there is no per-file +unrestrict or link-request round trip. + +Everything here follows the documented v2 fields only: ``status``, +``downloadPercent``, ``wait``, ``isZip``, ``srvMaint`` and the per-file +``downloadPercent``. Values of ``status`` outside the documented set (the docs' +own example shows 6) are tolerated and fall back to ``downloadPercent``. +""" + +from __future__ import annotations + +import shutil +import threading +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, NoReturn + +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, + safe_relative_path, +) +from shelfmark.download.http import download_url +from shelfmark.download.network import get_ssl_verify + +if TYPE_CHECKING: + from pathlib import Path + +logger = setup_logger(__name__) + +_API_BASE = "https://debrid-link.com/api/v2" + +_DEBRIDLINK_CLIENT_ERRORS = ( + AttributeError, + OSError, + requests.exceptions.RequestException, + RuntimeError, + TypeError, + ValueError, +) + +# Timeouts for API calls. +_API_TIMEOUT = 30 +_STATUS_TIMEOUT = 15 + +# A torrent or file reports 0-100 in downloadPercent; 100 means it is ready. +_COMPLETE_PERCENT = 100 + +# Documented torrent status values (seedbox-list): 0 paused, 1 queued, +# 2 verification, 4 downloading, 8 seeding, 100 finished. +_STATUS_PAUSED = 0 +_STATUS_QUEUED = 1 +_STATUS_VERIFYING = 2 +_STATUS_SEEDING = 8 +_STATUS_FINISHED = 100 +_DONE_STATUSES = frozenset({_STATUS_SEEDING, _STATUS_FINISHED}) + +# The download handler polls every couple of seconds. Debrid-Link rate-limits per +# endpoint and answers a breach with floodDetected, which blocks the API for an hour, +# so the seedbox is asked at most this often per torrent and cached status is served +# in between. +_MIN_REFRESH_SECONDS = 15.0 +_FLOOD_BACKOFF_SECONDS = 3600.0 + +# Errors Debrid-Link documents as temporary. They, network failures and 5xx +# responses are retried until the torrent has been failing for this long. +_TRANSIENT_ERROR_CODES = frozenset({"internalError", "server_error", "freeServerOverload"}) +_TRANSIENT_GRACE_SECONDS = 600.0 + +# A torrent whose progress has not moved for this long is treated as dead (no seeds, +# or left paused), because the API documents no error status for a torrent. +_STALL_TIMEOUT_SECONDS = 3600.0 + +# How long remove() waits for the retrieval thread to notice a cancel. +_WORKER_JOIN_TIMEOUT = 10.0 + +# File extensions recognised as book or audiobook content. +_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", + # A torrent with many files can come back as a single ZIP of all of them; the + # post-processing step extracts it. + ".zip", +) + + +class DebridLinkAPIError(RuntimeError): + """An error Debrid-Link reported, carrying its documented error code.""" + + def __init__(self, message: str, code: str | None = None) -> None: + super().__init__(message) + self.code = code + + +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + +@dataclass +class _DownloadState: + """Internal mutable state for an in-progress Debrid-Link download.""" + + torrent_id: str + name: str + target_dir: Path + phase: str = "uploading" + error_message: str | None = None + progress: float = 0.0 + download_thread: threading.Thread | None = None + lock: threading.Lock = field(default_factory=threading.Lock) + cancel_event: threading.Event = field(default_factory=threading.Event) + # Throttling: the last seedbox record and when it was fetched. + last_info: dict[str, Any] | None = None + last_fetch_at: float = 0.0 + # Transient-failure tracking: when the current run of failures started. + failing_since: float | None = None + # Stall tracking: the highest progress seen and when it last moved. + best_percent: float = -1.0 + # Set once a torrent held for file selection (``wait``) has been told to start. + start_requested: bool = False + progress_moved_at: float = field(default_factory=time.monotonic) + + +@register_client("torrent") +class DebridLinkClient(DownloadClient): + """Debrid-Link debrid service client. + + Downloads torrent content by handing a magnet to Debrid-Link's seedbox, + polling until it holds every file, then fetching each file over HTTP from + the ``downloadUrl`` the seedbox already returned. + + API documentation: https://debrid-link.com/api_doc/v2/introduction + """ + + protocol = "torrent" + name = "debridlink" + + _downloads: ClassVar[dict[str, _DownloadState]] = {} + _downloads_lock = threading.Lock() + # floodDetected blocks the whole account's API use, so the back-off is shared. + _flood_until: ClassVar[float] = 0.0 + + def __init__(self) -> None: + self._api_key = config_text(config.get("DEBRIDLINK_API_KEY", "")) + + def _auth_headers(self) -> dict[str, str]: + """Return Authorization header dict for API requests.""" + return {"Authorization": f"Bearer {self._api_key}"} + + # ------------------------------------------------------------------ + # DownloadClient interface + # ------------------------------------------------------------------ + + @staticmethod + def is_configured() -> bool: + """Return True when Debrid-Link is selected and an API key exists.""" + client = config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) + api_key = config_text(config.get("DEBRIDLINK_API_KEY", "")) + return client == "debridlink" and bool(api_key) + + def test_connection(self) -> tuple[bool, str]: + """Validate the API key and check the account still has premium time.""" + if not self._api_key: + return False, "Debrid-Link API Key is required" + try: + account = self._request_value( + "GET", "/account/infos", operation="account check", timeout=_STATUS_TIMEOUT + ) + if not isinstance(account, dict): + return False, "Unexpected response from Debrid-Link account endpoint" + username = str(account.get("username") or account.get("email") or "Unknown") + # premiumLeft is seconds of premium remaining; 0 means the account expired. + if not account.get("premiumLeft"): + return ( + False, + f"Debrid-Link user '{username}' does not have an active Premium subscription", + ) + except _DEBRIDLINK_CLIENT_ERRORS as e: + return False, f"Connection failed: {e}" + else: + return True, f"Connected to Debrid-Link as '{username}' (Premium)" + + def add_download( + self, + url: str, + name: str, + category: str | None = None, + expected_hash: str | None = None, + **kwargs: object, + ) -> str: + """Send a torrent to Debrid-Link's seedbox. + + Accepts a magnet link, a .torrent URL, or an indexer proxy URL. Anything + that is not already a magnet is resolved first, then posted as a file, + because the seedbox endpoint takes a magnet or hash in ``url`` and a + .torrent only as multipart form data. + """ + if not self._api_key: + msg = "Debrid-Link API key is not configured" + raise RuntimeError(msg) + + try: + upload = resolve_debrid_upload(url, expected_hash=expected_hash) + data = self._send_torrent(upload) + + torrent_id = str(data.get("id", "")) + if not torrent_id: + msg = "No torrent ID returned from Debrid-Link" + _raise_runtime_error(msg) + + target_dir = TMP_DIR / f"debridlink_{torrent_id}" + target_dir.mkdir(parents=True, exist_ok=True) + + state = _DownloadState( + torrent_id=torrent_id, + name=name, + target_dir=target_dir, + phase="waiting_dl", + ) + with self._downloads_lock: + self._downloads[torrent_id] = state + + logger.info( + "Added torrent to Debrid-Link: ID %s (%s)", + torrent_id, + name, + ) + + except Exception: + logger.exception("Failed to add torrent to Debrid-Link") + raise + + else: + return torrent_id + + def _send_torrent(self, upload: DebridUpload) -> dict[str, Any]: + """Hand the torrent to the seedbox, as a magnet or as a file upload.""" + if isinstance(upload, DebridMagnet): + data = self._request_value( + "POST", + "/seedbox/add", + operation="torrent upload", + json={"url": upload.magnet_url}, + timeout=_API_TIMEOUT, + ) + else: + # A .torrent must go as multipart/form-data under the "file" field; + # the JSON body only accepts a magnet, a hash or a torrent URL. + data = self._request_value( + "POST", + "/seedbox/add", + operation="torrent upload", + files={"file": ("upload.torrent", upload.torrent_data, "application/x-bittorrent")}, + timeout=_API_TIMEOUT, + ) + + if not isinstance(data, dict): + msg = "Unexpected response when adding a torrent to Debrid-Link" + _raise_runtime_error(msg) + return data + + def get_status(self, download_id: str) -> DownloadStatus: + """Poll Debrid-Link for torrent status and drive the download.""" + state = self._ensure_state(download_id) + + # Return cached terminal / in-flight states immediately. + with state.lock: + if state.phase == "error": + return DownloadStatus.error(state.error_message or "Debrid-Link error") + 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 HTTP...", + complete=False, + file_path=None, + ) + cached = state.last_info + fresh = time.monotonic() - state.last_fetch_at < _MIN_REFRESH_SECONDS + + now = time.time() + if now < type(self)._flood_until: + resume = time.strftime("%H:%M", time.localtime(type(self)._flood_until)) + return self._waiting_status( + cached, f"Debrid-Link rate limit reached, retrying at {resume}" + ) + if fresh and cached is not None: + return self._handle_torrent_info(cached, state, observed=False) + + try: + torrent = self._fetch_torrent(download_id) + except _DEBRIDLINK_CLIENT_ERRORS as e: + return self._handle_fetch_failure(e, state) + + with state.lock: + state.failing_since = None + state.last_fetch_at = time.monotonic() + state.last_info = torrent + + if torrent is None: + return self._set_error(state, f"Torrent {download_id} is no longer on Debrid-Link") + return self._handle_torrent_info(torrent, state) + + def remove( + self, + download_id: str, + *, + delete_files: bool = False, + ) -> bool: + """Stop any retrieval, delete the torrent from Debrid-Link and clean up locally.""" + remote_removed = True + try: + self._request_value( + "DELETE", + f"/seedbox/{download_id}/remove", + operation="torrent deletion", + timeout=_STATUS_TIMEOUT, + ) + except _DEBRIDLINK_CLIENT_ERRORS as e: + remote_removed = False + logger.warning("Failed to delete torrent from Debrid-Link: %s", e) + + with self._downloads_lock: + state = self._downloads.get(download_id) + if state: + with state.lock: + state.cancel_event.set() + thread = state.download_thread + if thread and thread is not threading.current_thread(): + thread.join(_WORKER_JOIN_TIMEOUT) + if thread.is_alive(): + logger.warning( + "Debrid-Link retrieval thread did not stop; deferring cleanup of %s", + download_id, + ) + return False + with self._downloads_lock: + self._downloads.pop(download_id, None) + target_dir = state.target_dir if state else TMP_DIR / f"debridlink_{download_id}" + + local_removed = True + if target_dir.exists(): + try: + shutil.rmtree(target_dir) + except OSError: + local_removed = False + logger.warning("Failed to remove Debrid-Link temporary files for %s", download_id) + return remote_removed and local_removed + + def get_download_path(self, download_id: str) -> str | None: + """Return the local directory containing downloaded files.""" + with self._downloads_lock: + state = self._downloads.get(download_id) + if state and state.phase == "complete": + return str(state.target_dir) + target_dir = TMP_DIR / f"debridlink_{download_id}" + if target_dir.exists(): + return str(target_dir) + return None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _request_value( + self, + method: str, + path: str, + *, + operation: str, + timeout: int = _API_TIMEOUT, + **kwargs: Any, + ) -> Any: + """Call the API and unwrap Debrid-Link's ``{success, value}`` envelope. + + Errors arrive as a 4xx or 5xx with ``{success: false, error: }``, so + the body is read before the status code: raising on the status alone would + turn ``badToken`` into a bare "401 Client Error". Messages never include the + request URL or its parameters. + """ + url = f"{_API_BASE}{path}" + try: + resp = requests.request( + method, + url, + headers=self._auth_headers(), + timeout=timeout, + verify=get_ssl_verify(url), + **kwargs, + ) + except requests.exceptions.RequestException as e: + msg = f"Debrid-Link {operation} failed: {type(e).__name__}" + raise RuntimeError(msg) from None + + status_code = getattr(resp, "status_code", 200) + ok_status = isinstance(status_code, int) and 200 <= status_code < 300 + try: + payload = resp.json() + except TypeError, ValueError: + payload = None + + if not isinstance(payload, dict): + detail = "an invalid response" if ok_status else f"HTTP {status_code}" + msg = f"Debrid-Link {operation} failed: {detail}" + raise DebridLinkAPIError(msg, None if ok_status else f"http{status_code}") + + if not ok_status or payload.get("success") is not True: + # The documented error body is just {success: false, error: }. + code = payload.get("error") + code = code if isinstance(code, str) and code else None + if code == "floodDetected": + type(self)._flood_until = time.time() + _FLOOD_BACKOFF_SECONDS + detail = code or ("an unsuccessful response" if ok_status else f"HTTP {status_code}") + msg = f"Debrid-Link {operation} failed: {detail}" + raise DebridLinkAPIError(msg, code or (None if ok_status else f"http{status_code}")) + + return payload.get("value") + + def _fetch_torrent(self, download_id: str) -> dict[str, Any] | None: + """Return the seedbox record for one torrent, or None when it is gone.""" + try: + value = self._request_value( + "GET", + "/seedbox/list", + operation="status check", + params={"ids": download_id}, + timeout=_STATUS_TIMEOUT, + ) + except DebridLinkAPIError as e: + if e.code == "badId": + return None + raise + return self._pick_torrent(value, download_id) + + def _fetch_file_list(self, download_id: str) -> list[dict[str, Any]]: + """Return the torrent's individual files. + + A torrent with many files is listed as one ZIP (``isZip``) under ``ids``; + Debrid-Link documents ``?id=TORRENT_ID`` as the way to see the files + themselves. Fetched once, after the torrent is complete, so the list is + final rather than the partial one an early status check can return. + """ + value = self._request_value( + "GET", + "/seedbox/list", + operation="file listing", + params={"id": download_id}, + timeout=_STATUS_TIMEOUT, + ) + torrent = self._pick_torrent(value, download_id) + if torrent is None: + msg = f"Torrent {download_id} is no longer on Debrid-Link" + _raise_runtime_error(msg) + return [f for f in torrent.get("files", []) if isinstance(f, dict)] + + @staticmethod + def _pick_torrent(value: object, download_id: str) -> dict[str, Any] | None: + entries = value if isinstance(value, list) else [value] + for entry in entries: + if isinstance(entry, dict) and str(entry.get("id", "")) == download_id: + return entry + return None + + def _handle_fetch_failure(self, error: Exception, state: _DownloadState) -> DownloadStatus: + """Fail on a permanent error; ride out a temporary one for a grace period.""" + code = getattr(error, "code", None) + if code == "floodDetected": + resume = time.strftime("%H:%M", time.localtime(type(self)._flood_until)) + logger.warning("Debrid-Link rate limit reached; pausing status checks until %s", resume) + return self._waiting_status( + state.last_info, f"Debrid-Link rate limit reached, retrying at {resume}" + ) + + transient = ( + not isinstance(error, DebridLinkAPIError) + or code in _TRANSIENT_ERROR_CODES + or (isinstance(code, str) and code.startswith("http5")) + ) + if not transient: + return self._set_error(state, str(error)) + + now = time.monotonic() + with state.lock: + if state.failing_since is None: + state.failing_since = now + failing_for = now - state.failing_since + state.last_fetch_at = now + if failing_for >= _TRANSIENT_GRACE_SECONDS: + minutes = int(_TRANSIENT_GRACE_SECONDS // 60) + return self._set_error(state, f"{error} (still failing after {minutes} minutes)") + logger.warning("Debrid-Link status check failed, will retry: %s", error) + return self._waiting_status(state.last_info, "Debrid-Link unreachable, retrying") + + @staticmethod + def _waiting_status(info: dict[str, Any] | None, message: str) -> DownloadStatus: + percent = _as_float(info.get("downloadPercent")) if info else 0.0 + return DownloadStatus( + progress=min(percent, _COMPLETE_PERCENT) * 0.5, + state=DownloadState.DOWNLOADING, + message=message, + complete=False, + file_path=None, + ) + + @staticmethod + def _set_error(state: _DownloadState, message: str) -> DownloadStatus: + with state.lock: + state.phase = "error" + state.error_message = message + return DownloadStatus.error(message) + + def _ensure_state(self, download_id: str) -> _DownloadState: + """Get or create download state for the given torrent ID.""" + with self._downloads_lock: + state = self._downloads.get(download_id) + if state: + return state + + target_dir = TMP_DIR / f"debridlink_{download_id}" + state = _DownloadState( + torrent_id=download_id, + name=f"Download {download_id}", + target_dir=target_dir, + phase="waiting_dl", + ) + with self._downloads_lock: + self._downloads[download_id] = state + return state + + def _handle_torrent_info( + self, + info: dict[str, Any], + state: _DownloadState, + *, + observed: bool = True, + ) -> DownloadStatus: + """Map a Debrid-Link seedbox torrent to a DownloadStatus. + + ``observed`` is False when ``info`` is a cached record being re-served + between throttled fetches, so it doesn't count toward stall detection. + """ + percent = _as_float(info.get("downloadPercent")) + name = str(info.get("name") or state.name) + files = [f for f in info.get("files", []) if isinstance(f, dict)] + status = _as_status(info.get("status")) + + ready = ( + (status in _DONE_STATUSES or percent >= _COMPLETE_PERCENT) + and bool(files) + and all(_as_float(f.get("downloadPercent", 100)) >= _COMPLETE_PERCENT for f in files) + ) + if ready: + self._maybe_start_download_thread(state) + return DownloadStatus( + progress=50.0, + state=DownloadState.DOWNLOADING, + message="Debrid-Link ready, retrieving files...", + complete=False, + file_path=None, + ) + + now = time.monotonic() + with state.lock: + if percent > state.best_percent: + state.best_percent = percent + state.progress_moved_at = now + stalled_for = now - state.progress_moved_at + if observed and stalled_for >= _STALL_TIMEOUT_SECONDS: + minutes = int(_STALL_TIMEOUT_SECONDS // 60) + return self._set_error( + state, + f"Debrid-Link made no progress on {name} for {minutes} minutes " + f"({int(_as_float(info.get('peersConnected')))} peers connected)", + ) + + if info.get("wait"): + # Held for file selection. Shelfmark wants every file, so start it. + self._start_waiting_torrent(state) + message = f"Starting on Debrid-Link ({name})" + elif info.get("srvMaint"): + message = "Debrid-Link server maintenance, waiting" + elif status == _STATUS_PAUSED: + message = f"Paused on Debrid-Link ({name})" + elif status == _STATUS_QUEUED: + message = f"Queued on Debrid-Link ({name})" + elif status == _STATUS_VERIFYING: + message = f"Debrid-Link verifying torrent ({name})" + else: + message = f"Debrid-Link downloading torrent ({name})" + + # Still being fetched by the seedbox: the first half of the progress bar. + return DownloadStatus( + progress=min(percent, _COMPLETE_PERCENT) * 0.5, + state=DownloadState.DOWNLOADING, + message=message, + complete=False, + file_path=None, + download_speed=int(_as_float(info.get("downloadSpeed"))), + ) + + def _start_waiting_torrent(self, state: _DownloadState) -> None: + """Release a torrent held for file selection, keeping every file. + + Shelfmark never asks for ``wait``, but a torrent reported that way would + otherwise never start. The config endpoint with no unwanted files starts it. + """ + with state.lock: + if state.start_requested: + return + state.start_requested = True + try: + self._request_value( + "POST", + f"/seedbox/{state.torrent_id}/config", + operation="torrent start", + json={"files-unwanted": []}, + timeout=_STATUS_TIMEOUT, + ) + except _DEBRIDLINK_CLIENT_ERRORS as e: + logger.warning( + "Could not start waiting Debrid-Link torrent %s: %s", state.torrent_id, e + ) + with state.lock: + state.start_requested = False + + def _maybe_start_download_thread(self, state: _DownloadState) -> None: + """Spawn a background thread to download the seedbox's 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 or state.cancel_event.is_set(): + return + state.phase = "downloading_http" + t = threading.Thread( + target=self._process_and_download, + args=(state,), + daemon=True, + ) + state.download_thread = t + t.start() + + # ------------------------------------------------------------------ + # File download pipeline + # ------------------------------------------------------------------ + + def _process_and_download( + self, + state: _DownloadState, + files: list[dict[str, Any]] | None = None, + ) -> None: + """Download each file over HTTP. + + Runs in a background thread spawned by ``_maybe_start_download_thread``. + Debrid-Link puts a ready-to-use ``downloadUrl`` on every file, so unlike + the other debrid clients there is nothing to unrestrict here. ``files`` is + fetched fresh when not given. + """ + try: + if files is None: + files = self._fetch_file_list(state.torrent_id) + if state.cancel_event.is_set(): + return + + downloadable = [f for f in files if f.get("downloadUrl")] + if not downloadable: + msg = "No download links returned by Debrid-Link" + _raise_runtime_error(msg) + + relevant = [ + f for f in downloadable if str(f.get("name", "")).lower().endswith(_BOOK_EXTENSIONS) + ] + if not relevant: + relevant = downloadable + + state.target_dir.mkdir(parents=True, exist_ok=True) + used: set[Path] = set() + total = len(relevant) + for idx, file_info in enumerate(relevant): + if state.cancel_event.is_set(): + return + name = str(file_info.get("name") or f"file_{idx + 1}") + rel_path = _unique_path( + safe_relative_path(name, state.target_dir, "Debrid-Link"), used + ) + + dest = state.target_dir / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + + logger.info("Downloading Debrid-Link file %d/%d: %s", idx + 1, total, rel_path) + + buf = download_url( + str(file_info.get("downloadUrl", "")), + referer="https://debrid-link.com/", + cancel_flag=state.cancel_event, + ) + if state.cancel_event.is_set(): + return + if not buf: + # The URL is signed and stays out of the message, which ends up + # in logs and the UI. + msg = f"Failed to download {rel_path} from Debrid-Link" + _raise_runtime_error(msg) + + with dest.open("wb") as fh: + buf.seek(0) + shutil.copyfileobj(buf, fh) + + with state.lock: + state.progress = 50.0 + (idx + 1) / total * 50.0 + + with state.lock: + if state.cancel_event.is_set(): + return + state.phase = "complete" + state.progress = 100.0 + + logger.info( + "Debrid-Link download complete for ID %s at %s", + state.torrent_id, + state.target_dir, + ) + + except Exception as e: + logger.exception("Error in Debrid-Link download for ID %s", state.torrent_id) + with state.lock: + state.phase = "error" + state.error_message = str(e) or "Download failed" + + +def _unique_path(path: Path, used: set[Path]) -> Path: + """Keep two files with the same name from overwriting each other.""" + candidate = path + counter = 2 + while candidate in used: + candidate = path.with_name(f"{path.stem} ({counter}){path.suffix}") + counter += 1 + used.add(candidate) + return candidate + + +def _as_status(value: object) -> int | None: + """The torrent's numeric status, or None when it is missing or not a number.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value) + return None + + +def _as_float(value: object) -> float: + """Coerce an API numeric field to a float, treating anything odd as zero.""" + if isinstance(value, bool): + return 0.0 + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return 0.0 + return 0.0 diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 271e88f1..07dc6e27 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -550,6 +550,23 @@ def _test_alldebrid_connection(current_values: dict[str, Any] | None = None) -> return {"success": success, "message": message} +def _test_debridlink_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Test the Debrid-Link API connection using current form values.""" + from shelfmark.core.config import config + from shelfmark.download.clients.debridlink import DebridLinkClient + + current_values = current_values or {} + api_key = _resolve_string_setting(current_values, config.get, "DEBRIDLINK_API_KEY") + + if not api_key: + return {"success": False, "message": "Debrid-Link API Key is required"} + + client = DebridLinkClient() + client._api_key = api_key + success, message = client.test_connection() + return {"success": success, "message": message} + + def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the Real-Debrid API connection using current form values.""" from shelfmark.core.config import config @@ -610,6 +627,7 @@ def prowlarr_clients_settings() -> list[SettingsField]: {"value": "", "label": "None"}, {"value": "alldebrid", "label": "AllDebrid"}, {"value": "blackhole", "label": "Blackhole"}, + {"value": "debridlink", "label": "Debrid-Link"}, {"value": "qbittorrent", "label": "qBittorrent"}, {"value": "realdebrid", "label": "Real-Debrid"}, {"value": "torbox", "label": "TorBox"}, @@ -641,6 +659,21 @@ def prowlarr_clients_settings() -> list[SettingsField]: callback=_test_alldebrid_connection, show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"}, ), + # --- Debrid-Link Settings --- + PasswordField( + key="DEBRIDLINK_API_KEY", + label="API Key", + description="Debrid-Link API Key from your Debrid-Link account settings", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "debridlink"}, + ), + ActionButton( + key="test_debridlink", + label="Test Connection", + description="Verify your Debrid-Link configuration", + style="primary", + callback=_test_debridlink_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "debridlink"}, + ), # --- Real-Debrid Settings --- PasswordField( key="REALDEBRID_API_KEY", diff --git a/shelfmark/download/clients/torbox.py b/shelfmark/download/clients/torbox.py index 4ab689de..9127c65c 100644 --- a/shelfmark/download/clients/torbox.py +++ b/shelfmark/download/clients/torbox.py @@ -7,8 +7,7 @@ import shutil import threading import time from dataclasses import dataclass, field -from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Any, ClassVar, NoReturn +from typing import TYPE_CHECKING, Any, ClassVar, NoReturn from urllib.parse import urlparse import requests @@ -27,10 +26,14 @@ from shelfmark.download.clients.torrent_utils import ( DebridMagnet, DebridUpload, resolve_debrid_upload, + safe_relative_path, ) from shelfmark.download.http import download_url from shelfmark.download.network import get_ssl_verify +if TYPE_CHECKING: + from pathlib import Path + logger = setup_logger(__name__) _API_BASE = "https://api.torbox.app/v1/api" @@ -605,29 +608,7 @@ class TorBoxClient(DownloadClient): @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) + return safe_relative_path(cls._file_name(file_info), target_dir, "TorBox") @staticmethod def _set_error(state: _DownloadState, message: str) -> None: diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index 6db3608d..55a79c14 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -8,6 +8,7 @@ import re import time from binascii import Error as BinasciiError from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath from threading import Lock from urllib.parse import ParseResult, parse_qs, urljoin, urlparse, urlunparse @@ -588,3 +589,37 @@ def extract_hash_from_magnet(magnet_url: str) -> str | None: return btmh_hash return None + + +def safe_relative_path(name: str, target_dir: Path, service: str) -> Path: + """Validate a service-supplied file name before anything is written below ``target_dir``. + + Debrid services pass torrent file names straight through, and a torrent can name a + file ``../../etc/cron.d/x``. Absolute paths, drive letters and ``..`` segments are + refused, and the resolved destination must stay inside ``target_dir``. + """ + if not name: + msg = f"{service} returned a file without a name" + raise RuntimeError(msg) + + relative_path = PurePosixPath(name.replace("\\", "/")) + windows_path = PureWindowsPath(name) + if ( + relative_path.is_absolute() + or windows_path.is_absolute() + or windows_path.drive + or ".." in relative_path.parts + ): + msg = f"{service} returned an unsafe file path: {name}" + raise RuntimeError(msg) + if relative_path == PurePosixPath("."): + msg = f"{service} returned a file without a usable name" + raise RuntimeError(msg) + + destination = (target_dir / Path(*relative_path.parts)).resolve() + try: + destination.relative_to(target_dir.resolve()) + except ValueError: + msg = f"{service} returned an unsafe file path: {name}" + raise RuntimeError(msg) from None + return Path(*relative_path.parts) diff --git a/tests/prowlarr/test_debridlink_client.py b/tests/prowlarr/test_debridlink_client.py new file mode 100644 index 00000000..22e5f363 --- /dev/null +++ b/tests/prowlarr/test_debridlink_client.py @@ -0,0 +1,727 @@ +"""Debrid-Link seedbox client tests. + +Two things set this client apart from the other debrid services and both are +covered here: every v2 endpoint wraps its payload in a ``{success, value}`` +envelope that can report failure over an HTTP 200, and a completed torrent +already carries a direct ``downloadUrl`` per file, so there is no unrestrict +step to mock. +""" + +import hashlib +import io +from unittest.mock import MagicMock + +import pytest +import requests + +from shelfmark.download.clients import DownloadState, debridlink +from shelfmark.download.clients.debridlink import ( + DebridLinkAPIError, + DebridLinkClient, + _as_float, + _DownloadState, +) +from shelfmark.download.clients.torrent_utils import bencode_encode + +_PROWLARR_PROXY_URL = "https://prowlarr.example/api/v1/indexer/1/download?apikey=k&link=abc" +_MAGNET = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Dune" + + +def _valid_torrent() -> tuple[bytes, str]: + info_dict = { + b"name": b"book.epub", + b"length": 100, + b"piece length": 16384, + b"pieces": b"\x00" * 20, + } + return ( + bencode_encode({b"info": info_dict}), + hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower(), + ) + + +def _mock_fetch(monkeypatch, *, content=b"", status_code=200, error=None): + """Stand in for the .torrent prefetch inside extract_torrent_info.""" + if error is not None: + mock_get = MagicMock(side_effect=error) + else: + response = MagicMock(status_code=status_code, content=content) + response.raise_for_status = MagicMock() + mock_get = MagicMock(return_value=response) + monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get) + return mock_get + + +def _mock_request(monkeypatch, payload, *, status_code=200): + """Patch requests.request with a single canned Debrid-Link envelope.""" + response = MagicMock(status_code=status_code) + response.raise_for_status = MagicMock() + response.json = MagicMock(return_value=payload) + request = MagicMock(return_value=response) + monkeypatch.setattr("shelfmark.download.clients.debridlink.requests.request", request) + return request + + +@pytest.fixture(autouse=True) +def _reset_shared_state(): + """The flood back-off and the download table are shared across instances.""" + DebridLinkClient._flood_until = 0.0 + DebridLinkClient._downloads.clear() + yield + DebridLinkClient._flood_until = 0.0 + DebridLinkClient._downloads.clear() + + +def _state(tmp_path, phase="waiting_dl"): + return _DownloadState(torrent_id="DL1", name="Dune", target_dir=tmp_path, phase=phase) + + +def _buffer(data=b"data"): + return io.BytesIO(data) + + +def _client(monkeypatch, api_key="dl-key"): + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.config.get", + lambda key, default="": {"DEBRIDLINK_API_KEY": api_key}.get(key, default), + ) + return DebridLinkClient() + + +class TestEnvelope: + """The body decides the outcome, and error codes survive a 4xx or 5xx.""" + + def test_success_false_raises_even_on_http_200(self, monkeypatch): + _mock_request(monkeypatch, {"success": False, "error": "badToken"}) + client = _client(monkeypatch) + + with pytest.raises(DebridLinkAPIError, match="failed: badToken") as err: + client._request_value("GET", "/account/infos", operation="account check") + assert err.value.code == "badToken" + + def test_the_error_code_is_read_from_a_4xx_body(self, monkeypatch): + _mock_request(monkeypatch, {"success": False, "error": "badToken"}, status_code=401) + client = _client(monkeypatch) + + with pytest.raises(DebridLinkAPIError, match="badToken") as err: + client._request_value("GET", "/account/infos", operation="account check") + assert err.value.code == "badToken" + + def test_a_5xx_without_a_body_keeps_the_status(self, monkeypatch): + response = MagicMock(status_code=503) + response.json = MagicMock(side_effect=ValueError("no json")) + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.requests.request", + MagicMock(return_value=response), + ) + client = _client(monkeypatch) + + with pytest.raises(DebridLinkAPIError, match="HTTP 503") as err: + client._request_value("GET", "/seedbox/list", operation="status check") + assert err.value.code == "http503" + + def test_success_unwraps_the_value(self, monkeypatch): + _mock_request(monkeypatch, {"success": True, "value": {"id": "DL1"}}) + client = _client(monkeypatch) + + assert client._request_value("GET", "/seedbox/list", operation="status check") == { + "id": "DL1" + } + + def test_a_non_object_body_is_rejected(self, monkeypatch): + _mock_request(monkeypatch, ["unexpected"]) + client = _client(monkeypatch) + + with pytest.raises(RuntimeError, match="invalid response"): + client._request_value("GET", "/account/infos", operation="account check") + + def test_a_network_failure_does_not_leak_the_url(self, monkeypatch): + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.requests.request", + MagicMock(side_effect=requests.ConnectionError("https://debrid-link.com/secret")), + ) + client = _client(monkeypatch) + + with pytest.raises(RuntimeError) as err: + client._request_value("GET", "/seedbox/list", operation="status check") + assert "debrid-link.com" not in str(err.value) + assert "ConnectionError" in str(err.value) + + def test_flood_detected_starts_the_shared_back_off(self, monkeypatch): + _mock_request(monkeypatch, {"success": False, "error": "floodDetected"}, status_code=429) + client = _client(monkeypatch) + + with pytest.raises(DebridLinkAPIError): + client._request_value("GET", "/seedbox/list", operation="status check") + assert DebridLinkClient._flood_until > 0 + + +class TestAdd: + def test_magnet_is_sent_as_json(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + request = _mock_request(monkeypatch, {"success": True, "value": {"id": "DL1"}}) + client = _client(monkeypatch) + + assert client.add_download(_MAGNET, "Dune") == "DL1" + + request.assert_called_once() + assert request.call_args.args[0] == "POST" + assert request.call_args.args[1].endswith("/seedbox/add") + assert request.call_args.kwargs["json"] == {"url": _MAGNET} + assert "files" not in request.call_args.kwargs + + def test_proxy_url_is_uploaded_as_a_torrent_file(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + torrent_data, _ = _valid_torrent() + _mock_fetch(monkeypatch, content=torrent_data) + request = _mock_request(monkeypatch, {"success": True, "value": {"id": "DL2"}}) + client = _client(monkeypatch) + + assert client.add_download(_PROWLARR_PROXY_URL, "Dune") == "DL2" + + # A .torrent only goes up as multipart; the JSON body takes a magnet. + assert "json" not in request.call_args.kwargs + assert request.call_args.kwargs["files"]["file"][1] == torrent_data + + def test_missing_id_is_an_error_not_a_silent_success(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": True, "value": {}}) + client = _client(monkeypatch) + + with pytest.raises(RuntimeError, match="No torrent ID returned"): + client.add_download(_MAGNET, "Dune") + + def test_unresolvable_url_reports_the_reason(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_fetch(monkeypatch, error=OSError("tracker unreachable")) + request = _mock_request(monkeypatch, {"success": True, "value": {"id": "DL3"}}) + client = _client(monkeypatch) + + with pytest.raises(ValueError, match="Could not resolve a torrent to send"): + client.add_download(_PROWLARR_PROXY_URL, "Dune") + + request.assert_not_called() + + def test_without_an_api_key_it_refuses_before_any_call(self, monkeypatch): + request = _mock_request(monkeypatch, {"success": True, "value": {"id": "DL4"}}) + client = _client(monkeypatch, api_key="") + + with pytest.raises(RuntimeError, match="API key is not configured"): + client.add_download(_MAGNET, "Dune") + + request.assert_not_called() + + +class TestStatus: + @staticmethod + def _bare_client(): + return DebridLinkClient.__new__(DebridLinkClient) + + def test_partial_progress_is_halved_and_stays_downloading(self, tmp_path): + client = self._bare_client() + state = _state(tmp_path) + + status = client._handle_torrent_info( + {"downloadPercent": 40, "name": "Dune.epub", "downloadSpeed": 1234}, + state, + ) + + # The seedbox fetch is the first half of the bar; HTTP is the second. + assert status.progress == 20.0 + assert status.state == DownloadState.DOWNLOADING + assert status.complete is False + assert status.download_speed == 1234 + assert state.phase == "waiting_dl" + + def test_completion_hands_off_only_when_every_file_is_ready(self, tmp_path, monkeypatch): + client = self._bare_client() + state = _state(tmp_path) + started = MagicMock() + monkeypatch.setattr(client, "_maybe_start_download_thread", started) + + partial = { + "downloadPercent": 100, + "files": [ + {"name": "a.mp3", "downloadPercent": 100}, + {"name": "b.mp3", "downloadPercent": 80}, + ], + } + client._handle_torrent_info(partial, state) + started.assert_not_called() + + partial["files"][1]["downloadPercent"] = 100 + status = client._handle_torrent_info(partial, state) + + assert status.progress == 50.0 + started.assert_called_once_with(state) + + def test_an_empty_file_list_is_not_treated_as_ready(self, tmp_path, monkeypatch): + client = self._bare_client() + started = MagicMock() + monkeypatch.setattr(client, "_maybe_start_download_thread", started) + + client._handle_torrent_info({"downloadPercent": 100, "files": []}, _state(tmp_path)) + + started.assert_not_called() + + @pytest.mark.parametrize( + ("info", "expected"), + [ + ({"status": 0}, "Paused on Debrid-Link"), + ({"status": 1}, "Queued on Debrid-Link"), + ({"status": 2}, "verifying"), + ({"status": 4}, "downloading torrent"), + ({"status": 6}, "downloading torrent"), # the docs' own example value + ({"srvMaint": True}, "server maintenance"), + ], + ) + def test_documented_states_are_reported(self, tmp_path, info, expected): + status = self._bare_client()._handle_torrent_info( + {"downloadPercent": 10, **info}, _state(tmp_path) + ) + + assert status.state == DownloadState.DOWNLOADING + assert expected in (status.message or "") + + @pytest.mark.parametrize("done_status", [8, 100]) + def test_seeding_or_finished_counts_as_done(self, tmp_path, monkeypatch, done_status): + client = self._bare_client() + started = MagicMock() + monkeypatch.setattr(client, "_maybe_start_download_thread", started) + + client._handle_torrent_info( + { + "status": done_status, + "downloadPercent": 99, + "files": [{"name": "a.epub", "downloadPercent": 100}], + }, + _state(tmp_path), + ) + + started.assert_called_once() + + def test_a_torrent_held_for_file_selection_is_started_once(self, tmp_path, monkeypatch): + # ``wait`` means "hold for file selection", not "queued": without a config + # call the torrent never starts. + client = _client(monkeypatch) + request = _mock_request(monkeypatch, {"success": True, "value": []}) + state = _state(tmp_path) + + first = client._handle_torrent_info({"wait": True, "downloadPercent": 0}, state) + client._handle_torrent_info({"wait": True, "downloadPercent": 0}, state) + + assert "Starting on Debrid-Link" in (first.message or "") + assert request.call_count == 1 + args, kwargs = request.call_args + assert args[0] == "POST" + assert args[1].endswith("/seedbox/DL1/config") + assert kwargs["json"] == {"files-unwanted": []} + + def test_a_torrent_that_stops_moving_fails_after_the_stall_timeout(self, tmp_path, monkeypatch): + client = self._bare_client() + state = _state(tmp_path) + clock = [1000.0] + monkeypatch.setattr(debridlink.time, "monotonic", lambda: clock[0]) + state.progress_moved_at = clock[0] + + client._handle_torrent_info({"downloadPercent": 12, "peersConnected": 0}, state) + clock[0] += debridlink._STALL_TIMEOUT_SECONDS - 1 + assert client._handle_torrent_info({"downloadPercent": 12}, state).state == ( + DownloadState.DOWNLOADING + ) + + clock[0] += 2 + status = client._handle_torrent_info({"downloadPercent": 12, "peersConnected": 0}, state) + + assert status.state == DownloadState.ERROR + assert "no progress" in (status.message or "") + + def test_progress_resets_the_stall_clock(self, tmp_path, monkeypatch): + client = self._bare_client() + state = _state(tmp_path) + clock = [1000.0] + monkeypatch.setattr(debridlink.time, "monotonic", lambda: clock[0]) + state.progress_moved_at = clock[0] + + client._handle_torrent_info({"downloadPercent": 12}, state) + clock[0] += debridlink._STALL_TIMEOUT_SECONDS - 1 + client._handle_torrent_info({"downloadPercent": 13}, state) + clock[0] += debridlink._STALL_TIMEOUT_SECONDS - 1 + + assert client._handle_torrent_info({"downloadPercent": 13}, state).state == ( + DownloadState.DOWNLOADING + ) + + def test_a_vanished_torrent_becomes_an_error_rather_than_a_stall(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": True, "value": []}) + client = _client(monkeypatch) + + status = client.get_status("GONE") + + assert status.state == DownloadState.ERROR + assert "no longer on Debrid-Link" in (status.message or "") + + +class TestPolling: + """The handler polls every couple of seconds; Debrid-Link must not see that.""" + + def test_status_is_served_from_cache_between_refreshes(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + request = _mock_request( + monkeypatch, {"success": True, "value": [{"id": "DL1", "downloadPercent": 30}]} + ) + client = _client(monkeypatch) + + for _ in range(5): + status = client.get_status("DL1") + + assert request.call_count == 1 + assert status.progress == 15.0 + + def test_a_rate_limit_pauses_checks_instead_of_failing(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + request = _mock_request( + monkeypatch, {"success": False, "error": "floodDetected"}, status_code=429 + ) + client = _client(monkeypatch) + + first = client.get_status("DL1") + second = client.get_status("DL1") + + assert first.state == DownloadState.DOWNLOADING + assert "rate limit" in (first.message or "") + assert second.state == DownloadState.DOWNLOADING + assert request.call_count == 1 + + def test_a_temporary_error_is_ridden_out_then_fails(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": False, "error": "internalError"}, status_code=500) + clock = [1000.0] + monkeypatch.setattr(debridlink.time, "monotonic", lambda: clock[0]) + client = _client(monkeypatch) + + assert client.get_status("DL1").state == DownloadState.DOWNLOADING + clock[0] += debridlink._TRANSIENT_GRACE_SECONDS + debridlink._MIN_REFRESH_SECONDS + + status = client.get_status("DL1") + + assert status.state == DownloadState.ERROR + assert "internalError" in (status.message or "") + + def test_a_permanent_error_fails_at_once(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": False, "error": "badToken"}, status_code=401) + client = _client(monkeypatch) + + status = client.get_status("DL1") + + assert status.state == DownloadState.ERROR + assert "badToken" in (status.message or "") + + +class TestFetchTorrent: + def test_the_matching_id_is_picked_out_of_the_page(self, monkeypatch): + _mock_request( + monkeypatch, + { + "success": True, + "value": [{"id": "OTHER"}, {"id": "DL1", "downloadPercent": 12}], + }, + ) + client = _client(monkeypatch) + + assert client._fetch_torrent("DL1") == {"id": "DL1", "downloadPercent": 12} + + def test_an_absent_id_returns_none(self, monkeypatch): + _mock_request(monkeypatch, {"success": True, "value": [{"id": "OTHER"}]}) + client = _client(monkeypatch) + + assert client._fetch_torrent("DL1") is None + + def test_a_bad_id_error_means_the_torrent_is_gone(self, monkeypatch): + _mock_request(monkeypatch, {"success": False, "error": "badId"}, status_code=400) + client = _client(monkeypatch) + + assert client._fetch_torrent("DL1") is None + + def test_the_status_check_asks_for_just_that_torrent(self, monkeypatch): + request = _mock_request(monkeypatch, {"success": True, "value": []}) + client = _client(monkeypatch) + + client._fetch_torrent("DL1") + + assert request.call_args.kwargs["params"] == {"ids": "DL1"} + + def test_the_file_list_uses_the_singular_id_so_zips_are_expanded(self, monkeypatch): + # Debrid-Link lists a many-file torrent as one ZIP under ``ids``; ``?id=`` + # returns the individual files. + request = _mock_request( + monkeypatch, + { + "success": True, + "value": [{"id": "DL1", "files": [{"name": "a.mp3"}, {"name": "b.mp3"}]}], + }, + ) + client = _client(monkeypatch) + + files = client._fetch_file_list("DL1") + + assert request.call_args.kwargs["params"] == {"id": "DL1"} + assert [f["name"] for f in files] == ["a.mp3", "b.mp3"] + + +class TestConnection: + def test_missing_key_is_reported_without_a_call(self, monkeypatch): + request = _mock_request(monkeypatch, {"success": True, "value": {}}) + client = _client(monkeypatch, api_key="") + + assert client.test_connection() == (False, "Debrid-Link API Key is required") + request.assert_not_called() + + def test_an_expired_account_is_rejected(self, monkeypatch): + _mock_request( + monkeypatch, + {"success": True, "value": {"username": "rob", "premiumLeft": 0}}, + ) + client = _client(monkeypatch) + + ok, message = client.test_connection() + + assert ok is False + assert "does not have an active" in message + + def test_a_premium_account_is_accepted(self, monkeypatch): + _mock_request( + monkeypatch, + {"success": True, "value": {"username": "rob", "premiumLeft": 86400}}, + ) + client = _client(monkeypatch) + + ok, message = client.test_connection() + + assert ok is True + assert "rob" in message + + def test_an_api_failure_is_surfaced_not_raised(self, monkeypatch): + _mock_request(monkeypatch, {"success": False, "error": "badToken"}) + client = _client(monkeypatch) + + ok, message = client.test_connection() + + assert ok is False + assert "badToken" in message + + +class TestIsConfigured: + @staticmethod + def _config(monkeypatch, values): + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.config.get", + lambda key, default="": values.get(key, default), + ) + + def test_requires_both_the_selection_and_the_key(self, monkeypatch): + self._config( + monkeypatch, + {"PROWLARR_TORRENT_CLIENT": "debridlink", "DEBRIDLINK_API_KEY": "dl-key"}, + ) + assert DebridLinkClient.is_configured() is True + + def test_another_client_selected_means_not_configured(self, monkeypatch): + self._config( + monkeypatch, + {"PROWLARR_TORRENT_CLIENT": "torbox", "DEBRIDLINK_API_KEY": "dl-key"}, + ) + assert DebridLinkClient.is_configured() is False + + def test_no_key_means_not_configured(self, monkeypatch): + self._config( + monkeypatch, + {"PROWLARR_TORRENT_CLIENT": "debridlink", "DEBRIDLINK_API_KEY": ""}, + ) + assert DebridLinkClient.is_configured() is False + + +class TestDownloadPipeline: + @staticmethod + def _run(tmp_path, monkeypatch, files, payload=b"data"): + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(tmp_path, phase="downloading_http") + fetched: list[str] = [] + + def fake_download(url, referer=None, cancel_flag=None): + fetched.append(url) + return _buffer(payload) + + monkeypatch.setattr("shelfmark.download.clients.debridlink.download_url", fake_download) + client._process_and_download(state, files) + return state, fetched + + def test_book_files_are_preferred_over_the_rest(self, tmp_path, monkeypatch): + state, fetched = self._run( + tmp_path, + monkeypatch, + [ + {"name": "cover.jpg", "downloadUrl": "https://dl.example/cover.jpg"}, + {"name": "Dune.epub", "downloadUrl": "https://dl.example/Dune.epub"}, + ], + ) + + assert fetched == ["https://dl.example/Dune.epub"] + assert (tmp_path / "Dune.epub").read_bytes() == b"data" + assert state.phase == "complete" + assert state.progress == 100.0 + + def test_everything_is_taken_when_nothing_looks_like_a_book(self, tmp_path, monkeypatch): + state, _ = self._run( + tmp_path, monkeypatch, [{"name": "readme.nfo", "downloadUrl": "https://dl.example/x"}] + ) + + assert (tmp_path / "readme.nfo").exists() + assert state.phase == "complete" + + def test_a_zip_bundle_is_downloaded_as_a_book_file(self, tmp_path, monkeypatch): + state, fetched = self._run( + tmp_path, + monkeypatch, + [ + {"name": "notes.nfo", "downloadUrl": "https://dl.example/n"}, + {"name": "Dune.zip", "downloadUrl": "https://dl.example/z"}, + ], + ) + + assert fetched == ["https://dl.example/z"] + assert state.phase == "complete" + + def test_no_links_is_an_error(self, tmp_path): + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(tmp_path, phase="downloading_http") + + client._process_and_download(state, [{"name": "Dune.epub"}]) + + assert state.phase == "error" + assert "No download links" in (state.error_message or "") + + @pytest.mark.parametrize("name", ["../escape.epub", "/etc/escape.epub", "a/../../x.epub"]) + def test_a_file_name_cannot_escape_the_download_folder(self, tmp_path, monkeypatch, name): + target = tmp_path / "dl" + target.mkdir() + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(target, phase="downloading_http") + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.download_url", + lambda url, referer=None, cancel_flag=None: _buffer(), + ) + + client._process_and_download(state, [{"name": name, "downloadUrl": "https://dl.example/x"}]) + + assert state.phase == "error" + assert "unsafe file path" in (state.error_message or "") + assert not (tmp_path / "escape.epub").exists() + + def test_two_files_with_the_same_name_are_both_kept(self, tmp_path, monkeypatch): + payloads = iter([b"one", b"two"]) + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(tmp_path, phase="downloading_http") + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.download_url", + lambda url, referer=None, cancel_flag=None: _buffer(next(payloads)), + ) + + client._process_and_download( + state, + [ + {"name": "chapter.mp3", "downloadUrl": "https://dl.example/1"}, + {"name": "chapter.mp3", "downloadUrl": "https://dl.example/2"}, + ], + ) + + assert (tmp_path / "chapter.mp3").read_bytes() == b"one" + assert (tmp_path / "chapter (2).mp3").read_bytes() == b"two" + + def test_a_failed_file_is_reported_without_its_signed_url(self, tmp_path, monkeypatch): + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(tmp_path, phase="downloading_http") + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.download_url", + lambda url, referer=None, cancel_flag=None: None, + ) + + client._process_and_download( + state, [{"name": "Dune.epub", "downloadUrl": "https://seed1.debrid.link/dl/SIGNED"}] + ) + + assert state.phase == "error" + assert "Dune.epub" in (state.error_message or "") + assert "SIGNED" not in (state.error_message or "") + + def test_the_file_list_is_fetched_fresh_when_none_is_given(self, tmp_path, monkeypatch): + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(tmp_path, phase="downloading_http") + listing = MagicMock(return_value=[{"name": "Dune.epub", "downloadUrl": "https://x/y"}]) + monkeypatch.setattr(client, "_fetch_file_list", listing) + monkeypatch.setattr( + "shelfmark.download.clients.debridlink.download_url", + lambda url, referer=None, cancel_flag=None: _buffer(), + ) + + client._process_and_download(state) + + listing.assert_called_once_with("DL1") + assert state.phase == "complete" + + def test_cancelling_stops_before_the_next_file(self, tmp_path, monkeypatch): + client = DebridLinkClient.__new__(DebridLinkClient) + state = _state(tmp_path, phase="downloading_http") + fetched: list[str] = [] + + def fake_download(url, referer=None, cancel_flag=None): + fetched.append(url) + state.cancel_event.set() + return _buffer() + + monkeypatch.setattr("shelfmark.download.clients.debridlink.download_url", fake_download) + + client._process_and_download( + state, + [ + {"name": "a.mp3", "downloadUrl": "https://dl.example/a"}, + {"name": "b.mp3", "downloadUrl": "https://dl.example/b"}, + ], + ) + + assert fetched == ["https://dl.example/a"] + assert state.phase != "complete" + + +class TestRemove: + def test_remove_cancels_the_retrieval_and_cleans_up(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": True, "value": None}) + client = _client(monkeypatch) + state = _state(tmp_path / "debridlink_DL1", phase="downloading_http") + state.target_dir.mkdir() + DebridLinkClient._downloads["DL1"] = state + + assert client.remove("DL1") is True + + assert state.cancel_event.is_set() + assert not state.target_dir.exists() + assert "DL1" not in DebridLinkClient._downloads + + +class TestAsFloat: + @pytest.mark.parametrize( + ("value", "expected"), + [ + (42, 42.0), + (4.5, 4.5), + ("17", 17.0), + ("not a number", 0.0), + (None, 0.0), + (True, 0.0), + ], + ) + def test_api_numbers_are_coerced_without_raising(self, value, expected): + assert _as_float(value) == expected diff --git a/tests/prowlarr/test_debridlink_settings.py b/tests/prowlarr/test_debridlink_settings.py new file mode 100644 index 00000000..eb480f44 --- /dev/null +++ b/tests/prowlarr/test_debridlink_settings.py @@ -0,0 +1,49 @@ +"""Debrid-Link 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_debridlink_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, "DEBRIDLINK_API_KEY") + test_action = _field(fields, "test_debridlink") + + assert {option["value"] for option in client_field.options} >= {"debridlink"} + assert isinstance(api_key_field, PasswordField) + assert api_key_field.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "debridlink"} + assert isinstance(test_action, ActionButton) + assert test_action.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "debridlink"} + + +def test_debridlink_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.debridlink.DebridLinkClient", client_type) + monkeypatch.setattr(config, "get", lambda _key, default="": "saved-key") + + result = settings_module._test_debridlink_connection({"DEBRIDLINK_API_KEY": "unsaved-key"}) + + assert result == {"success": True, "message": "Connected"} + assert client._api_key == "unsaved-key" + + +def test_debridlink_connection_action_requires_api_key(): + from shelfmark.download.clients.settings import _test_debridlink_connection + + assert _test_debridlink_connection({"DEBRIDLINK_API_KEY": ""}) == { + "success": False, + "message": "Debrid-Link API Key is required", + }