feat: execute durable archive transfers

This commit is contained in:
2026-07-23 07:21:19 +00:00
parent 68db3ec4c5
commit 884aed9921
14 changed files with 1618 additions and 8 deletions
+82 -2
View File
@@ -126,6 +126,37 @@ class QBittorrentReader:
)
_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,
@@ -141,7 +172,9 @@ class QBittorrentReader:
"/api/v2/torrents/filePrio",
{
"hash": torrent_hash,
"id": f"0-{total_file_count - 1}",
"id": "|".join(
str(index) for index in range(total_file_count)
),
"priority": "0",
},
)
@@ -166,6 +199,53 @@ class QBittorrentReader:
"/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",
@@ -430,7 +510,7 @@ class QBittorrentReader:
def _is_download_state(state: str) -> bool:
return state not in {"checkingDL"} and (
return state not in {"checkingDL", "stoppedDL", "pausedDL"} and (
state.endswith("DL")
or state in {"downloading", "metaDL", "forcedMetaDL"}
)