feat: add transfer service adapters

This commit is contained in:
2026-07-23 03:39:33 +00:00
parent 2ec90b6a2d
commit 68db3ec4c5
5 changed files with 610 additions and 4 deletions
+278
View File
@@ -4,7 +4,11 @@ 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 urllib import error, parse, request
@@ -16,6 +20,25 @@ 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
@@ -68,6 +91,174 @@ class QBittorrentReader:
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 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": f"0-{total_file_count - 1}",
"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 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,
) -> 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
while True:
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 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:
@@ -115,6 +306,81 @@ class QBittorrentReader:
"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,
@@ -161,3 +427,15 @@ class QBittorrentReader:
if len(data) > maximum:
raise QBittorrentError("qBittorrent response exceeds limit")
return data
def _is_download_state(state: str) -> bool:
return state not in {"checkingDL"} 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}")