diff --git a/shelfmark/download/clients/__init__.py b/shelfmark/download/clients/__init__.py index 453749c..57d84d6 100644 --- a/shelfmark/download/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -360,9 +360,11 @@ class DownloadClient(ABC): # Client registry: protocol -> list of client classes _CLIENTS: dict[str, list[type[DownloadClient]]] = {} _BUILTIN_CLIENT_MODULES = ( + "shelfmark.download.clients.alldebrid", "shelfmark.download.clients.deluge", "shelfmark.download.clients.nzbget", "shelfmark.download.clients.qbittorrent", + "shelfmark.download.clients.realdebrid", "shelfmark.download.clients.rtorrent", "shelfmark.download.clients.sabnzbd", "shelfmark.download.clients.transmission", diff --git a/shelfmark/download/clients/alldebrid.py b/shelfmark/download/clients/alldebrid.py new file mode 100644 index 0000000..10edbba --- /dev/null +++ b/shelfmark/download/clients/alldebrid.py @@ -0,0 +1,665 @@ +"""AllDebrid debrid service client for Shelfmark. + +Routes magnet links through the AllDebrid API (v4/v4.1) to download +torrent content via AllDebrid's CDN infrastructure. +""" + +from __future__ import annotations + +import shutil +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import quote + +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.http import download_url +from shelfmark.download.network import get_ssl_verify + +logger = setup_logger(__name__) + +_API_BASE = "https://api.alldebrid.com/v4" +_AGENT = "shelfmark" + +# AllDebrid magnet status codes (from API v4.1 documentation). +_STATUS_DOWNLOADING = frozenset({0, 1, 2, 3}) +_STATUS_READY = 4 + +# Timeouts and retry limits for API calls. +_API_TIMEOUT = 30 +_STATUS_TIMEOUT = 15 +_DELAYED_POLL_INTERVAL = 5 +_DELAYED_POLL_MAX_ATTEMPTS = 12 + +# 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", + ".ogg", + ".opus", + ".pdf", + ".rtf", + ".txt", + ".wma", +) + + +def _flatten_magnet_files( + entries: list[dict[str, Any]], + prefix: str = "", +) -> list[dict[str, Any]]: + """Flatten AllDebrid's nested file tree into a list of file dicts. + + AllDebrid returns files with ``"n"`` (name), ``"s"`` (size), + ``"l"`` (link), and ``"e"`` (children) keys. Directories use + ``"e"`` to nest their contents. + + Returns: + List of ``{"filename": ..., "size": ..., "link": ...}`` dicts. + + """ + flat: list[dict[str, Any]] = [] + for entry in entries: + name = entry.get("n", "") + if "e" in entry: + flat.extend( + _flatten_magnet_files(entry["e"], prefix=f"{prefix}{name}/"), + ) + elif entry.get("l"): + flat.append({ + "filename": f"{prefix}{name}", + "size": entry.get("s", 0), + "link": entry["l"], + }) + return flat + + +@dataclass +class _DownloadState: + """Internal mutable state for an in-progress AllDebrid download.""" + + magnet_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) + + +@register_client("torrent") +class AllDebridClient(DownloadClient): + """AllDebrid debrid service client. + + Downloads torrent content by uploading magnet links to AllDebrid, + waiting for the torrent to complete on their servers, then fetching + the resulting files via direct HTTP download from AllDebrid's CDN. + + API documentation: https://docs.alldebrid.com/ + """ + + protocol = "torrent" + name = "alldebrid" + + _downloads: dict[str, _DownloadState] = {} + _downloads_lock = threading.Lock() + + def __init__(self) -> None: + self._api_key = config_text(config.get("ALLDEBRID_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 AllDebrid is selected and an API key exists.""" + client = config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) + api_key = config_text(config.get("ALLDEBRID_API_KEY", "")) + return client == "alldebrid" and bool(api_key) + + def test_connection(self) -> tuple[bool, str]: + """Validate the API key and check Premium subscription status.""" + if not self._api_key: + return False, "AllDebrid API Key is required" + try: + url = f"{_API_BASE}/user" + resp = requests.get( + url, + headers=self._auth_headers(), + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(url), + ) + resp.raise_for_status() + data = resp.json() + if data.get("status") != "success": + err = data.get("error", {}).get("message", "API error") + return False, f"AllDebrid error: {err}" + user = data.get("data", {}).get("user", {}) + username = user.get("username", "Unknown") + if not user.get("isPremium", False): + return ( + False, + f"AllDebrid user '{username}' does not have " + f"a Premium subscription", + ) + return True, f"Connected to AllDebrid as '{username}' (Premium)" + except Exception as e: + return False, f"Connection failed: {e}" + + def add_download( + self, + url: str, + name: str, + category: str | None = None, + expected_hash: str | None = None, + **kwargs: object, + ) -> str: + """Upload a magnet link to AllDebrid and return the magnet ID.""" + if not self._api_key: + msg = "AllDebrid API key is not configured" + raise RuntimeError(msg) + + magnet_link = url + if not magnet_link.startswith("magnet:") and expected_hash: + magnet_link = f"magnet:?xt=urn:btih:{expected_hash}" + + api_url = f"{_API_BASE}/magnet/upload" + try: + resp = requests.post( + api_url, + headers=self._auth_headers(), + data={"magnets[]": magnet_link}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(api_url), + ) + resp.raise_for_status() + data = resp.json() + if data.get("status") != "success": + code = data.get("error", {}).get("code", "UNKNOWN") + msg = f"AllDebrid upload failed: {code}" + raise RuntimeError(msg) + + magnets = data.get("data", {}).get("magnets", []) + if not magnets: + msg = "No magnet returned from AllDebrid" + raise RuntimeError(msg) + + info = magnets[0] + if info.get("error"): + code = info["error"].get("code", "UNKNOWN") + msg = f"AllDebrid magnet error: {code}" + raise RuntimeError(msg) + + magnet_id = str(info.get("id", "")) + if not magnet_id: + msg = "No magnet ID returned from AllDebrid" + raise RuntimeError(msg) + + target_dir = TMP_DIR / f"alldebrid_{magnet_id}" + target_dir.mkdir(parents=True, exist_ok=True) + + state = _DownloadState( + magnet_id=magnet_id, + name=name, + target_dir=target_dir, + phase="waiting_ad", + ) + with self._downloads_lock: + self._downloads[magnet_id] = state + + logger.info( + "Added torrent to AllDebrid: ID %s (%s)", + magnet_id, + name, + ) + return magnet_id + + except Exception: + logger.exception("Failed to upload magnet to AllDebrid") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """Poll AllDebrid for magnet 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 "AllDebrid 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, + ) + + # Ask AllDebrid for the current magnet status. + try: + status_url = f"{_API_BASE.replace('/v4', '/v4.1')}/magnet/status" + resp = requests.post( + status_url, + headers=self._auth_headers(), + data={"id": download_id}, + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(status_url), + ) + resp.raise_for_status() + data = resp.json() + if data.get("status") != "success": + err = data.get("error", {}).get("message", "Status failed") + return DownloadStatus.error( + f"AllDebrid status error: {err}", + ) + + mag = self._extract_magnet_info(data) + return self._handle_magnet_status(mag, state) + + except Exception as e: + logger.exception( + "Error checking AllDebrid status for %s", download_id, + ) + return DownloadStatus.error(str(e)) + + def remove( + self, download_id: str, *, delete_files: bool = False, + ) -> bool: + """Delete the magnet from AllDebrid and clean up local files.""" + try: + url = f"{_API_BASE}/magnet/delete" + requests.post( + url, + headers=self._auth_headers(), + data={"id": download_id}, + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(url), + ) + except Exception as e: + logger.warning("Failed to delete magnet from AllDebrid: %s", e) + + with self._downloads_lock: + state = self._downloads.pop(download_id, None) + + if state and state.target_dir.exists(): + shutil.rmtree(state.target_dir, ignore_errors=True) + return True + + 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"alldebrid_{download_id}" + if target_dir.exists(): + return str(target_dir) + return None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _ensure_state(self, download_id: str) -> _DownloadState: + """Get or create download state for the given magnet ID.""" + with self._downloads_lock: + state = self._downloads.get(download_id) + if state: + return state + + target_dir = TMP_DIR / f"alldebrid_{download_id}" + state = _DownloadState( + magnet_id=download_id, + name=f"Download {download_id}", + target_dir=target_dir, + phase="waiting_ad", + ) + with self._downloads_lock: + self._downloads[download_id] = state + return state + + @staticmethod + def _extract_magnet_info(data: dict[str, Any]) -> dict[str, Any]: + """Extract magnet info dict from a status API response.""" + mag_data = data.get("data", {}).get("magnets", {}) + if isinstance(mag_data, list) and mag_data: + return mag_data[0] + if isinstance(mag_data, dict): + return mag_data + return {} + + def _handle_magnet_status( + self, + mag: dict[str, Any], + state: _DownloadState, + ) -> DownloadStatus: + """Map AllDebrid magnet status to a DownloadStatus.""" + status_code = mag.get("statusCode") + + if status_code in _STATUS_DOWNLOADING: + size = mag.get("size", 0) + downloaded = mag.get("downloaded", 0) + pct = (downloaded / size * 100.0) if size > 0 else 0.0 + return DownloadStatus( + progress=pct * 0.5, + state=DownloadState.DOWNLOADING, + message=( + f"AllDebrid downloading torrent " + f"({mag.get('filename', state.name)})" + ), + complete=False, + file_path=None, + download_speed=mag.get("downloadSpeed", 0), + ) + + if status_code == _STATUS_READY or mag.get("ready", False): + self._maybe_start_download_thread(state) + return DownloadStatus( + progress=50.0, + state=DownloadState.DOWNLOADING, + message="AllDebrid ready, retrieving files...", + complete=False, + file_path=None, + ) + + # Terminal error from AllDebrid. + error_txt = ( + mag.get("error", {}).get("message") + or f"AllDebrid status code {status_code}" + ) + with state.lock: + state.phase = "error" + state.error_message = error_txt + return DownloadStatus.error(error_txt) + + def _maybe_start_download_thread(self, state: _DownloadState) -> None: + """Spawn a background thread to unlock and download files.""" + with state.lock: + already_running = state.phase in ( + "unlocking", "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 = "unlocking" + t = threading.Thread( + target=self._process_and_download, + args=(state,), + daemon=True, + ) + state.download_thread = t + t.start() + + # ------------------------------------------------------------------ + # Link unlocking + # ------------------------------------------------------------------ + + def _unlock_file_link(self, link: str) -> str: + """Resolve an AllDebrid file link to a direct CDN download URL. + + AllDebrid's ``/v4/magnet/files`` endpoint returns virtual links + (``alldebrid.com/f/...``) that must be converted to direct CDN + URLs via ``/v4/link/unlock``. + + Strategy: + 1. If the link is already a CDN URL (``/dl/``), return it. + 2. ``POST /v4/link/unlock`` with Bearer auth (primary). + 3. ``GET /v4/link/unlock`` with query parameters (fallback). + 4. Append ``apikey=`` to ``alldebrid.com/f/`` links + (last-resort fallback for ghost-cached torrents). + + """ + # 1. Already a direct CDN link. + if "/dl/" in link: + return link + + headers = self._auth_headers() + unlock_url = f"{_API_BASE}/link/unlock" + err_msg = "Unknown unlock error" + + # 2. POST unlock (primary method). + try: + resp = requests.post( + unlock_url, + headers=headers, + data={"link": link}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(unlock_url), + ) + if resp.status_code == 200: + body = resp.json() + if body.get("status") == "success": + direct = self._resolve_unlock_data( + body.get("data", {}), headers, + ) + if direct: + return direct + err_msg = body.get("error", {}).get( + "message", "Unlock failed", + ) + except Exception as e: + logger.debug("POST unlock exception: %s", e) + + # 3. GET unlock fallback with URL-encoded link. + try: + encoded = quote(link, safe="") + get_url = ( + f"{_API_BASE}/link/unlock" + f"?agent={_AGENT}" + f"&apikey={self._api_key}" + f"&link={encoded}" + ) + resp = requests.get( + get_url, + headers=headers, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(get_url), + ) + if resp.status_code == 200: + body = resp.json() + if body.get("status") == "success": + direct = body.get("data", {}).get("link") + if direct: + return direct + err_msg = body.get("error", {}).get("message", err_msg) + except Exception as e: + logger.debug("GET unlock exception: %s", e) + + # 4. Last-resort: append apikey to alldebrid.com/f/ links. + if "alldebrid.com/f/" in link: + logger.info( + "Using apikey fallback for AllDebrid file link: %s", link, + ) + if "apikey=" not in link: + sep = "&" if "?" in link else "?" + return f"{link}{sep}apikey={self._api_key}" + return link + + logger.error( + "AllDebrid unlock failed for '%s': %s", link, err_msg, + ) + msg = f"AllDebrid unlock failed: {err_msg}" + raise RuntimeError(msg) + + def _resolve_unlock_data( + self, + data: dict[str, Any], + headers: dict[str, str], + ) -> str | None: + """Extract the direct link from unlock response data. + + Handles the *delayed link* flow where AllDebrid returns a + ``delayed`` ID instead of an immediate download link. + """ + # Delayed link: poll until the CDN file is ready. + if "delayed" in data: + delayed_id = data["delayed"] + logger.info( + "AllDebrid link delayed (ID %s), polling...", delayed_id, + ) + delayed_url = f"{_API_BASE}/link/delayed" + for _ in range(_DELAYED_POLL_MAX_ATTEMPTS): + time.sleep(_DELAYED_POLL_INTERVAL) + try: + resp = requests.post( + delayed_url, + headers=headers, + data={"id": delayed_id}, + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(delayed_url), + ) + if resp.status_code != 200: + continue + body = resp.json() + d = body.get("data", {}) + if ( + body.get("status") == "success" + and d.get("status") == 2 + and d.get("link") + ): + return d["link"] + except Exception as e: + logger.debug("Delayed poll exception: %s", e) + + return data.get("link") + + # ------------------------------------------------------------------ + # File download pipeline + # ------------------------------------------------------------------ + + def _process_and_download(self, state: _DownloadState) -> None: + """Fetch the file list, unlock links, and download via HTTP. + + Runs in a background thread spawned by ``_maybe_start_download_thread``. + """ + try: + files = self._fetch_file_list(state.magnet_id) + relevant = [ + f for f in files + if f["filename"].lower().endswith(_BOOK_EXTENSIONS) + ] + if not relevant: + relevant = files + + with state.lock: + state.phase = "downloading_http" + + total = len(relevant) + for idx, file_info in enumerate(relevant): + direct_link = self._unlock_file_link(file_info["link"]) + + rel_path = Path(file_info["filename"]) + dest = state.target_dir / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + + logger.info( + "Downloading AllDebrid file %d/%d: %s", + idx + 1, total, rel_path, + ) + + buf = download_url( + direct_link, referer="https://alldebrid.com/", + ) + if not buf: + msg = f"Failed to download from {direct_link}" + raise RuntimeError(msg) + + with open(dest, "wb") as fh: + fh.write(buf.getvalue()) + + with state.lock: + state.progress = 50.0 + (idx + 1) / total * 50.0 + + with state.lock: + state.phase = "complete" + state.progress = 100.0 + + logger.info( + "AllDebrid download complete for ID %s at %s", + state.magnet_id, state.target_dir, + ) + + except Exception: + logger.exception( + "Error in AllDebrid download for ID %s", state.magnet_id, + ) + with state.lock: + state.phase = "error" + state.error_message = str( + state.error_message or "Download failed", + ) + + def _fetch_file_list( + self, magnet_id: str, + ) -> list[dict[str, Any]]: + """Retrieve and flatten the file tree for a magnet.""" + url = f"{_API_BASE}/magnet/files" + resp = requests.post( + url, + headers=self._auth_headers(), + data={"id[]": magnet_id}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(url), + ) + resp.raise_for_status() + data = resp.json() + if data.get("status") != "success": + msg = f"Failed to list magnet files: {data.get('error')}" + raise RuntimeError(msg) + + magnets = data.get("data", {}).get("magnets", []) + if not magnets: + msg = "No magnet files returned" + raise RuntimeError(msg) + + files = _flatten_magnet_files(magnets[0].get("files", [])) + if not files: + msg = "No files found in torrent" + raise RuntimeError(msg) + return files diff --git a/shelfmark/download/clients/realdebrid.py b/shelfmark/download/clients/realdebrid.py new file mode 100644 index 0000000..006833f --- /dev/null +++ b/shelfmark/download/clients/realdebrid.py @@ -0,0 +1,492 @@ +"""Real-Debrid debrid service client for Shelfmark. + +Routes magnet links through the Real-Debrid REST API (v1.0) to download +torrent content via Real-Debrid's CDN infrastructure. +""" + +from __future__ import annotations + +import shutil +import threading + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +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.http import download_url +from shelfmark.download.network import get_ssl_verify + +logger = setup_logger(__name__) + +_API_BASE = "https://api.real-debrid.com/rest/1.0" + +# Real-Debrid torrent status values. +_STATUS_DOWNLOADING = frozenset({ + "magnet_conversion", + "waiting_files_selection", + "downloading", + "compressing", + "uploading", +}) +_STATUS_READY = "downloaded" +_STATUS_ERROR = frozenset({"error", "virus", "dead"}) + +# Timeouts for API calls. +_API_TIMEOUT = 30 +_STATUS_TIMEOUT = 15 + +# 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", + ".ogg", + ".opus", + ".pdf", + ".rtf", + ".txt", + ".wma", +) + + +@dataclass +class _DownloadState: + """Internal mutable state for an in-progress Real-Debrid 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) + + +@register_client("torrent") +class RealDebridClient(DownloadClient): + """Real-Debrid debrid service client. + + Downloads torrent content by uploading magnet links to Real-Debrid, + selecting all files for download on their servers, then unrestricting + and fetching the resulting files via direct HTTP download from + Real-Debrid's CDN. + + API documentation: https://api.real-debrid.com/ + """ + + protocol = "torrent" + name = "realdebrid" + + _downloads: dict[str, _DownloadState] = {} + _downloads_lock = threading.Lock() + + def __init__(self) -> None: + self._api_key = config_text(config.get("REALDEBRID_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 Real-Debrid is selected and an API key exists.""" + client = config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) + api_key = config_text(config.get("REALDEBRID_API_KEY", "")) + return client == "realdebrid" and bool(api_key) + + def test_connection(self) -> tuple[bool, str]: + """Validate the API key and check Premium subscription status.""" + if not self._api_key: + return False, "Real-Debrid API Key is required" + try: + url = f"{_API_BASE}/user" + resp = requests.get( + url, + headers=self._auth_headers(), + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(url), + ) + resp.raise_for_status() + user = resp.json() + username = user.get("username", "Unknown") + account_type = user.get("type", "free") + if account_type != "premium": + return ( + False, + f"Real-Debrid user '{username}' does not have " + f"a Premium subscription (type: {account_type})", + ) + return True, f"Connected to Real-Debrid as '{username}' (Premium)" + except Exception as e: + return False, f"Connection failed: {e}" + + def add_download( + self, + url: str, + name: str, + category: str | None = None, + expected_hash: str | None = None, + **kwargs: object, + ) -> str: + """Upload a magnet link to Real-Debrid and select all files.""" + if not self._api_key: + msg = "Real-Debrid API key is not configured" + raise RuntimeError(msg) + + magnet_link = url + if not magnet_link.startswith("magnet:") and expected_hash: + magnet_link = f"magnet:?xt=urn:btih:{expected_hash}" + + add_url = f"{_API_BASE}/torrents/addMagnet" + try: + resp = requests.post( + add_url, + headers=self._auth_headers(), + data={"magnet": magnet_link}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(add_url), + ) + resp.raise_for_status() + data = resp.json() + + torrent_id = str(data.get("id", "")) + if not torrent_id: + msg = "No torrent ID returned from Real-Debrid" + raise RuntimeError(msg) + + # Select all files so Real-Debrid starts downloading the torrent + select_url = f"{_API_BASE}/torrents/selectFiles/{torrent_id}" + sel_resp = requests.post( + select_url, + headers=self._auth_headers(), + data={"files": "all"}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(select_url), + ) + sel_resp.raise_for_status() + + target_dir = TMP_DIR / f"realdebrid_{torrent_id}" + target_dir.mkdir(parents=True, exist_ok=True) + + state = _DownloadState( + torrent_id=torrent_id, + name=name, + target_dir=target_dir, + phase="waiting_rd", + ) + with self._downloads_lock: + self._downloads[torrent_id] = state + + logger.info( + "Added torrent to Real-Debrid: ID %s (%s)", + torrent_id, + name, + ) + return torrent_id + + except Exception: + logger.exception("Failed to upload magnet to Real-Debrid") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """Poll Real-Debrid 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 "Real-Debrid 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, + ) + + # Query Real-Debrid for torrent info. + try: + info_url = f"{_API_BASE}/torrents/info/{download_id}" + resp = requests.get( + info_url, + headers=self._auth_headers(), + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(info_url), + ) + resp.raise_for_status() + info = resp.json() + return self._handle_torrent_info(info, state) + + except Exception as e: + logger.exception( + "Error checking Real-Debrid status for %s", download_id, + ) + return DownloadStatus.error(str(e)) + + def remove( + self, download_id: str, *, delete_files: bool = False, + ) -> bool: + """Delete the torrent from Real-Debrid and clean up local files.""" + try: + url = f"{_API_BASE}/torrents/delete/{download_id}" + requests.delete( + url, + headers=self._auth_headers(), + timeout=_STATUS_TIMEOUT, + verify=get_ssl_verify(url), + ) + except Exception as e: + logger.warning("Failed to delete torrent from Real-Debrid: %s", e) + + with self._downloads_lock: + state = self._downloads.pop(download_id, None) + + if state and state.target_dir.exists(): + shutil.rmtree(state.target_dir, ignore_errors=True) + return True + + 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"realdebrid_{download_id}" + if target_dir.exists(): + return str(target_dir) + return None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + 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"realdebrid_{download_id}" + state = _DownloadState( + torrent_id=download_id, + name=f"Download {download_id}", + target_dir=target_dir, + phase="waiting_rd", + ) + with self._downloads_lock: + self._downloads[download_id] = state + return state + + def _handle_torrent_info( + self, + info: dict[str, Any], + state: _DownloadState, + ) -> DownloadStatus: + """Map Real-Debrid torrent info to a DownloadStatus.""" + status = info.get("status", "") + + if status in _STATUS_DOWNLOADING: + progress = float(info.get("progress", 0.0)) + speed = int(info.get("speed", 0)) + filename = info.get("filename", state.name) + return DownloadStatus( + progress=progress * 0.5, + state=DownloadState.DOWNLOADING, + message=f"Real-Debrid downloading torrent ({filename})", + complete=False, + file_path=None, + download_speed=speed, + ) + + if status == _STATUS_READY: + links = info.get("links", []) + files = info.get("files", []) + self._maybe_start_download_thread(state, links, files) + return DownloadStatus( + progress=50.0, + state=DownloadState.DOWNLOADING, + message="Real-Debrid ready, retrieving files...", + complete=False, + file_path=None, + ) + + # Terminal error from Real-Debrid. + error_txt = f"Real-Debrid status error: {status}" + with state.lock: + state.phase = "error" + state.error_message = error_txt + return DownloadStatus.error(error_txt) + + def _maybe_start_download_thread( + self, + state: _DownloadState, + links: list[str], + files: list[dict[str, Any]], + ) -> None: + """Spawn a background thread to unrestrict and download files.""" + with state.lock: + already_running = state.phase in ( + "unrestricting", "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 = "unrestricting" + t = threading.Thread( + target=self._process_and_download, + args=(state, links, files), + daemon=True, + ) + state.download_thread = t + t.start() + + # ------------------------------------------------------------------ + # File download pipeline + # ------------------------------------------------------------------ + + def _process_and_download( + self, + state: _DownloadState, + links: list[str], + files: list[dict[str, Any]], + ) -> None: + """Unrestrict links and download files via HTTP. + + Runs in a background thread spawned by ``_maybe_start_download_thread``. + """ + try: + if not links: + msg = "No download links returned by Real-Debrid" + raise RuntimeError(msg) + + # Match selected files with links + selected_files = [f for f in files if f.get("selected") == 1] + + # Filter relevant ebook / audiobook files + relevant_indices: list[int] = [] + for i, f_info in enumerate(selected_files): + path_str = f_info.get("path", "").lower() + if path_str.endswith(_BOOK_EXTENSIONS): + relevant_indices.append(i) + + if not relevant_indices: + relevant_indices = list(range(len(links))) + + with state.lock: + state.phase = "downloading_http" + + total = len(relevant_indices) + for idx, rel_idx in enumerate(relevant_indices): + if rel_idx >= len(links): + continue + link = links[rel_idx] + + # Unrestrict the Real-Debrid link to get direct CDN download URL + unrestrict_url = f"{_API_BASE}/unrestrict/link" + unl_resp = requests.post( + unrestrict_url, + headers=self._auth_headers(), + data={"link": link}, + timeout=_API_TIMEOUT, + verify=get_ssl_verify(unrestrict_url), + ) + unl_resp.raise_for_status() + unl_data = unl_resp.json() + + direct_url = unl_data.get("download") + filename = unl_data.get("filename") + if not direct_url: + msg = f"Failed to unrestrict Real-Debrid link: {link}" + raise RuntimeError(msg) + + # Determine relative file path + if rel_idx < len(selected_files): + rel_path_str = selected_files[rel_idx].get("path", "").lstrip("/") + rel_path = Path(rel_path_str) + else: + rel_path = Path(filename or f"file_{idx + 1}") + + dest = state.target_dir / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + + logger.info( + "Downloading Real-Debrid file %d/%d: %s", + idx + 1, total, rel_path, + ) + + buf = download_url( + direct_url, referer="https://real-debrid.com/", + ) + if not buf: + msg = f"Failed to download from {direct_url}" + raise RuntimeError(msg) + + with open(dest, "wb") as fh: + fh.write(buf.getvalue()) + + with state.lock: + state.progress = 50.0 + (idx + 1) / total * 50.0 + + with state.lock: + state.phase = "complete" + state.progress = 100.0 + + logger.info( + "Real-Debrid download complete for ID %s at %s", + state.torrent_id, state.target_dir, + ) + + except Exception: + logger.exception( + "Error in Real-Debrid download for ID %s", state.torrent_id, + ) + with state.lock: + state.phase = "error" + state.error_message = str( + state.error_message or "Download failed", + ) diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index abccd74..197f05a 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -531,6 +531,40 @@ def _test_sabnzbd_connection(current_values: dict[str, Any] | None = None) -> di return {"success": True, "message": f"Connected to SABnzbd {version}"} +def _test_alldebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Test the AllDebrid API connection using current form values.""" + from shelfmark.core.config import config + from shelfmark.download.clients.alldebrid import AllDebridClient + + current_values = current_values or {} + api_key = _resolve_string_setting(current_values, config.get, "ALLDEBRID_API_KEY") + + if not api_key: + return {"success": False, "message": "AllDebrid API Key is required"} + + client = AllDebridClient() + 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 + from shelfmark.download.clients.realdebrid import RealDebridClient + + current_values = current_values or {} + api_key = _resolve_string_setting(current_values, config.get, "REALDEBRID_API_KEY") + + if not api_key: + return {"success": False, "message": "Real-Debrid API Key is required"} + + client = RealDebridClient() + client._api_key = api_key + success, message = client.test_connection() + return {"success": success, "message": message} + + # ==================== Download Clients Tab ==================== @@ -555,13 +589,45 @@ def prowlarr_clients_settings() -> list[SettingsField]: description="Choose which torrent client to use", options=[ {"value": "", "label": "None"}, + {"value": "alldebrid", "label": "AllDebrid"}, {"value": "qbittorrent", "label": "qBittorrent"}, + {"value": "realdebrid", "label": "Real-Debrid"}, {"value": "transmission", "label": "Transmission"}, {"value": "deluge", "label": "Deluge"}, {"value": "rtorrent", "label": "rTorrent"}, ], default="", ), + # --- AllDebrid Settings --- + PasswordField( + key="ALLDEBRID_API_KEY", + label="API Key", + description="AllDebrid API Key (apiv4) from your AllDebrid account settings", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"}, + ), + ActionButton( + key="test_alldebrid", + label="Test Connection", + description="Verify your AllDebrid configuration", + style="primary", + callback=_test_alldebrid_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"}, + ), + # --- Real-Debrid Settings --- + PasswordField( + key="REALDEBRID_API_KEY", + label="API Key", + description="Real-Debrid API Key (Secret Token) from your Real-Debrid account settings", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"}, + ), + ActionButton( + key="test_realdebrid", + label="Test Connection", + description="Verify your Real-Debrid configuration", + style="primary", + callback=_test_realdebrid_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"}, + ), # --- qBittorrent Settings --- TextField( key="QBITTORRENT_URL",