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
+3
View File
@@ -19,6 +19,9 @@ derives canonical resource identities and selection ranges, builds lazy content
trees, and reports only safe Syncthing folders below the configured root.
Renamed/noncanonical torrent paths remain visible but are marked noncanonical
so later job preflight can reject them without hiding the resource.
The qBittorrent read adapter uses cookie authentication, bounded responses,
one reauthentication attempt on session expiry, hash-scoped file/metainfo
fetches, and never logs credentials, cookies, response bodies, or endpoints.
```bash
archive-client --config /etc/archive-control/client.toml --check-config
+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
+76
View File
@@ -0,0 +1,76 @@
import hashlib
import io
import json
import os
import tempfile
import unittest
from pathlib import Path, PurePosixPath
from unittest.mock import patch
from archive_clients.bencode import encode
from archive_clients.config import ServiceConfig
from archive_clients.qbittorrent import QBittorrentReader
class _Response:
def __init__(self, value: bytes):
self.value = io.BytesIO(value)
def read(self, size=-1):
return self.value.read(size)
def __enter__(self):
return self
def __exit__(self, *_):
return None
class _Opener:
def __init__(self, values):
self.values = iter(values)
self.calls = []
def open(self, call, timeout):
self.calls.append(call)
return _Response(next(self.values))
class QBittorrentReaderTests(unittest.TestCase):
def test_hash_scoped_lookup_exports_and_normalizes(self):
info = {
b"length": 3, b"name": b"a.txt", b"piece length": 16384,
b"pieces": b"x" * 20,
}
torrent_bytes = encode({b"info": info})
torrent_hash = hashlib.sha1(encode(info)).hexdigest()
responses = [
b"Ok.",
json.dumps([{
"hash": torrent_hash, "name": "a.txt", "state": "uploading",
}]).encode(),
b'[{"index":0,"name":"a.txt","size":3,"progress":1,"priority":1}]',
torrent_bytes,
]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
password = root / "password"
password.write_text("secret", encoding="utf-8")
os.chmod(password, 0o600)
config = ServiceConfig(
"http://qb", PurePosixPath("/downloads"), root,
username="admin", password_file=password,
)
opener = _Opener(responses)
with patch(
"archive_clients.qbittorrent.request.build_opener",
return_value=opener,
):
resource = QBittorrentReader(config).get_resource(torrent_hash)
self.assertIsNotNone(resource)
self.assertEqual(resource.summary.resource_id.info_hash_v1_hex, torrent_hash)
self.assertIn("hashes=", opener.calls[1])
if __name__ == "__main__":
unittest.main()