feat: add authenticated qbittorrent reader

This commit is contained in:
2026-07-22 16:36:04 +00:00
parent f0555703cb
commit 720fa67202
3 changed files with 238 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
"""Authenticated, bounded qBittorrent read adapter."""
from __future__ import annotations
import json
import threading
from http.cookiejar import CookieJar
from typing import Any
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 _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 _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 _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:
response = self._read(self._opener.open(call, timeout=self.timeout), 64)
except (error.URLError, OSError) as exc:
raise QBittorrentError("qBittorrent authentication failed") from exc
if response.strip() != b"Ok.":
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