534 lines
19 KiB
Python
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}")
|