Files
archive-clients/src/archive_clients/qbittorrent.py
T

534 lines
19 KiB
Python

"""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}")