"""Authenticated, bounded qBittorrent read adapter.""" from __future__ import annotations import json import threading import time import uuid from dataclasses import dataclass from http.cookiejar import CookieJar from pathlib import PurePosixPath from typing import Any from typing import Callable from urllib import error, parse, request from archive_clients.config import ServiceConfig from archive_clients.resources import NormalizedResource, normalize_resource class QBittorrentError(RuntimeError): pass class QBittorrentDownloadAttempt(QBittorrentError): pass class QBittorrentHttpError(QBittorrentError): def __init__(self, status: int): super().__init__(f"qBittorrent HTTP request failed with status {status}") self.status = status @dataclass(frozen=True) class RecheckResult: torrent_hash: str selected_file_indices: tuple[int, ...] downloaded_bytes_before: int downloaded_bytes_after: int final_state: str class _NoRedirect(request.HTTPRedirectHandler): def redirect_request(self, *_: Any, **__: Any) -> None: return None class QBittorrentReader: def __init__(self, config: ServiceConfig, timeout: float = 15): self.config = config self.timeout = timeout self._opener = request.build_opener( request.HTTPCookieProcessor(CookieJar()), _NoRedirect() ) self._authenticated = False self._lock = threading.RLock() def list_resources(self, name_filter: str = "") -> list[NormalizedResource]: torrents = self._json("/api/v2/torrents/info") if not isinstance(torrents, list): raise QBittorrentError("qBittorrent torrent list is not an array") result = [] folded_filter = name_filter.casefold() for torrent in torrents: if not isinstance(torrent, dict): raise QBittorrentError("qBittorrent torrent record is invalid") name = torrent.get("name") if folded_filter and ( not isinstance(name, str) or folded_filter not in name.casefold() ): continue torrent_hash = torrent.get("hash") if not isinstance(torrent_hash, str): raise QBittorrentError("qBittorrent torrent hash is invalid") result.append(self._normalize(torrent, torrent_hash)) return result def get_resource(self, torrent_hash: str) -> NormalizedResource | None: torrents = self._json( "/api/v2/torrents/info", {"hashes": torrent_hash.lower()} ) if not isinstance(torrents, list): raise QBittorrentError("qBittorrent lookup response is invalid") exact = [ torrent for torrent in torrents if isinstance(torrent, dict) and str(torrent.get("hash", "")).lower() == torrent_hash.lower() ] if not exact: return None if len(exact) != 1: raise QBittorrentError("qBittorrent lookup is ambiguous") return self._normalize(exact[0], torrent_hash) def add_stopped( self, metainfo: bytes, save_path: str, ) -> None: if not metainfo: raise QBittorrentError("torrent metainfo is empty") candidate = PurePosixPath(save_path) try: candidate.relative_to(self.config.api_root) except ValueError as exc: raise QBittorrentError( "qBittorrent save path is outside its configured root" ) from exc if ( not candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts ): raise QBittorrentError("qBittorrent save path is unsafe") response = self._multipart( "/api/v2/torrents/add", { "savepath": save_path, "autoTMM": "false", # qBittorrent 4.x calls this paused and 5.x calls it stopped. "paused": "true", "stopped": "true", }, "torrents", "source.torrent", metainfo, ) _require_mutation_success(response, "add torrent") def add_stopped_with_retry( self, metainfo: bytes, save_path: str, torrent_hash: str, *, max_attempts: int = 3, initial_delay: float = 1, ) -> None: if max_attempts < 1 or initial_delay < 0: raise QBittorrentError("torrent add retry values are invalid") delay = initial_delay last_error: QBittorrentError | None = None for attempt in range(1, max_attempts + 1): try: self.add_stopped(metainfo, save_path) self.wait_until_present( torrent_hash, timeout=15, poll_interval=0.1 ) return except QBittorrentError as error: last_error = error if self._torrent_is_present(torrent_hash): return if attempt == max_attempts: break time.sleep(delay) delay *= 2 assert last_error is not None raise last_error def set_selection( self, torrent_hash: str, selected_file_indices: list[int] | tuple[int, ...], total_file_count: int, ) -> None: selected = sorted(set(selected_file_indices)) if total_file_count < 1: raise QBittorrentError("torrent file count must be positive") if not selected or selected[0] < 0 or selected[-1] >= total_file_count: raise QBittorrentError("selected torrent file index is invalid") self._post_form( "/api/v2/torrents/filePrio", { "hash": torrent_hash, "id": "|".join( str(index) for index in range(total_file_count) ), "priority": "0", }, ) self._post_form( "/api/v2/torrents/filePrio", { "hash": torrent_hash, "id": "|".join(str(index) for index in selected), "priority": "1", }, ) def stop(self, torrent_hash: str) -> None: try: self._post_form( "/api/v2/torrents/stop", {"hashes": torrent_hash} ) except QBittorrentHttpError as exc: if exc.status != 404: raise self._post_form( "/api/v2/torrents/pause", {"hashes": torrent_hash} ) def start(self, torrent_hash: str) -> None: try: self._post_form( "/api/v2/torrents/start", {"hashes": torrent_hash} ) except QBittorrentHttpError as exc: if exc.status != 404: raise self._post_form( "/api/v2/torrents/resume", {"hashes": torrent_hash} ) def wait_until_present( self, torrent_hash: str, *, timeout: float, poll_interval: float = 0.1, ) -> None: if timeout <= 0 or poll_interval < 0: raise QBittorrentError("torrent lookup timing values are invalid") deadline = time.monotonic() + timeout while True: if self._torrent_is_present(torrent_hash): return if time.monotonic() >= deadline: raise QBittorrentError( "qBittorrent did not expose the added torrent in time" ) time.sleep( min(poll_interval, max(0, deadline - time.monotonic())) ) def _torrent_is_present(self, torrent_hash: str) -> bool: torrents = self._json( "/api/v2/torrents/info", {"hashes": torrent_hash} ) if not isinstance(torrents, list): raise QBittorrentError( "qBittorrent lookup response is invalid" ) return any( isinstance(item, dict) and str(item.get("hash", "")).lower() == torrent_hash.lower() for item in torrents ) def delete_entry(self, torrent_hash: str) -> None: self._post_form( "/api/v2/torrents/delete", {"hashes": torrent_hash, "deleteFiles": "false"}, ) def recheck_and_wait( self, torrent_hash: str, selected_file_indices: list[int] | tuple[int, ...], *, timeout: float, poll_interval: float = 1, cancel_check: Callable[[], None] | None = None, progress_callback: Callable[[float], None] | None = None, ) -> RecheckResult: selected = tuple(sorted(set(selected_file_indices))) if not selected: raise QBittorrentError("recheck selection cannot be empty") if timeout <= 0 or poll_interval < 0: raise QBittorrentError("recheck timing values are invalid") self.stop(torrent_hash) baseline = self._downloaded_bytes(torrent_hash) self._post_form( "/api/v2/torrents/recheck", {"hashes": torrent_hash} ) deadline = time.monotonic() + timeout cancel_check = cancel_check or (lambda: None) while True: cancel_check() record = self._torrent_record(torrent_hash) state = record.get("state") if not isinstance(state, str): raise QBittorrentError("qBittorrent torrent state is invalid") downloaded = self._downloaded_bytes(torrent_hash) if downloaded > baseline or _is_download_state(state): try: self.stop(torrent_hash) finally: raise QBittorrentDownloadAttempt( "qBittorrent attempted to download during verification" ) files = self._json( "/api/v2/torrents/files", {"hash": torrent_hash} ) if not isinstance(files, list) or not all( isinstance(item, dict) for item in files ): raise QBittorrentError("qBittorrent file list is invalid") progress = { item.get("index"): item.get("progress") for item in files } if progress_callback is not None: progress_callback( sum( max(0.0, min(float(progress.get(index, 0)), 1.0)) for index in selected ) / len(selected) ) if all( isinstance(progress.get(index), (int, float)) and float(progress[index]) >= 1 for index in selected ) and state not in {"checkingDL", "checkingUP", "checkingResumeData"}: return RecheckResult( torrent_hash, selected, baseline, downloaded, state, ) if time.monotonic() >= deadline: raise QBittorrentError("qBittorrent recheck timed out") time.sleep(min(poll_interval, max(0, deadline - time.monotonic()))) def _torrent_record(self, torrent_hash: str) -> dict[str, Any]: torrents = self._json( "/api/v2/torrents/info", {"hashes": torrent_hash} ) if not isinstance(torrents, list): raise QBittorrentError("qBittorrent lookup response is invalid") exact = [ item for item in torrents if isinstance(item, dict) and str(item.get("hash", "")).lower() == torrent_hash.lower() ] if len(exact) != 1: raise QBittorrentError("qBittorrent target torrent is missing") return exact[0] def _downloaded_bytes(self, torrent_hash: str) -> int: properties = self._json( "/api/v2/torrents/properties", {"hash": torrent_hash} ) if not isinstance(properties, dict): raise QBittorrentError("qBittorrent properties response is invalid") value = properties.get("total_downloaded") if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise QBittorrentError( "qBittorrent downloaded byte counter is invalid" ) return value def _normalize( self, torrent: dict[str, Any], torrent_hash: str ) -> NormalizedResource: files = self._json( "/api/v2/torrents/files", {"hash": torrent_hash} ) if not isinstance(files, list) or not all( isinstance(item, dict) for item in files ): raise QBittorrentError("qBittorrent file list is invalid") metainfo = self._bytes( "/api/v2/torrents/export", {"hash": torrent_hash}, maximum=128 * 1024 * 1024, ) return normalize_resource(torrent, files, metainfo) def _json( self, path: str, parameters: dict[str, str] | None = None ) -> Any: try: return json.loads(self._bytes(path, parameters).decode("utf-8")) except (UnicodeError, json.JSONDecodeError) as exc: raise QBittorrentError("qBittorrent returned invalid JSON") from exc def _bytes( self, path: str, parameters: dict[str, str] | None = None, maximum: int = 8 * 1024 * 1024, ) -> bytes: with self._lock: if not self._authenticated: self._login() try: return self._request(path, parameters, maximum) except error.HTTPError as exc: if exc.code not in {401, 403}: raise QBittorrentError("qBittorrent HTTP request failed") from exc self._authenticated = False self._login() try: return self._request(path, parameters, maximum) except error.HTTPError as retry_exc: raise QBittorrentError( "qBittorrent request failed after reauthentication" ) from retry_exc def _post_form(self, path: str, parameters: dict[str, str]) -> None: body = parse.urlencode(parameters).encode("utf-8") call = request.Request( self._url(path), data=body, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) response = self._authorized_request(call, maximum=1024 * 1024) _require_mutation_success(response, "mutate torrent") def _multipart( self, path: str, fields: dict[str, str], file_field: str, filename: str, content: bytes, ) -> bytes: boundary = f"archive-control-{uuid.uuid4().hex}" chunks: list[bytes] = [] for name, value in fields.items(): chunks.extend(( f"--{boundary}\r\n".encode(), ( f'Content-Disposition: form-data; name="{name}"\r\n\r\n' ).encode(), value.encode(), b"\r\n", )) chunks.extend(( f"--{boundary}\r\n".encode(), ( "Content-Disposition: form-data; " f'name="{file_field}"; filename="{filename}"\r\n' ).encode(), b"Content-Type: application/x-bittorrent\r\n\r\n", content, b"\r\n", f"--{boundary}--\r\n".encode(), )) call = request.Request( self._url(path), data=b"".join(chunks), method="POST", headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, ) return self._authorized_request(call, maximum=1024 * 1024) def _authorized_request(self, call: request.Request, maximum: int) -> bytes: with self._lock: if not self._authenticated: self._login() try: return self._read( self._opener.open(call, timeout=self.timeout), maximum ) except error.HTTPError as exc: if exc.code not in {401, 403}: raise QBittorrentHttpError(exc.code) from exc self._authenticated = False self._login() try: return self._read( self._opener.open(call, timeout=self.timeout), maximum ) except (error.HTTPError, error.URLError, OSError) as retry_exc: raise QBittorrentError( "qBittorrent request failed after reauthentication" ) from retry_exc except (error.URLError, OSError) as exc: raise QBittorrentError( "qBittorrent service is unavailable" ) from exc def _login(self) -> None: body = parse.urlencode({ "username": self.config.username, "password": self.config.read_password(), }).encode("utf-8") call = request.Request( self._url("/api/v2/auth/login"), data=body, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) try: login_response = self._opener.open(call, timeout=self.timeout) login_status = getattr(login_response, "status", 200) response = self._read(login_response, 64) except (error.URLError, OSError) as exc: raise QBittorrentError("qBittorrent authentication failed") from exc if response.strip() != b"Ok." and not ( login_status == 204 and not response.strip() ): raise QBittorrentError("qBittorrent authentication failed") self._authenticated = True def _request( self, path: str, parameters: dict[str, str] | None, maximum: int ) -> bytes: url = self._url(path) if parameters: url = f"{url}?{parse.urlencode(parameters)}" try: return self._read( self._opener.open(url, timeout=self.timeout), maximum ) except error.HTTPError: raise except (error.URLError, OSError) as exc: raise QBittorrentError("qBittorrent service is unavailable") from exc def _url(self, path: str) -> str: return f"{self.config.endpoint.rstrip('/')}{path}" @staticmethod def _read(response: Any, maximum: int) -> bytes: with response: data = response.read(maximum + 1) if len(data) > maximum: raise QBittorrentError("qBittorrent response exceeds limit") return data def _is_download_state(state: str) -> bool: return state not in {"checkingDL", "stoppedDL", "pausedDL"} and ( state.endswith("DL") or state in {"downloading", "metaDL", "forcedMetaDL"} ) def _require_mutation_success(response: bytes, operation: str) -> None: if response.strip() not in {b"", b"Ok."}: raise QBittorrentError(f"qBittorrent refused to {operation}")