llmt-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llmt/__init__.py +7 -0
- llmt/api.py +138 -0
- llmt/cli.py +562 -0
- llmt/clients.py +1316 -0
- llmt/data/manifest-pubkey.json +7 -0
- llmt/doctor.py +970 -0
- llmt/doctorcmd.py +142 -0
- llmt/download.py +321 -0
- llmt/errors.py +132 -0
- llmt/layout.py +676 -0
- llmt/manifest.py +314 -0
- llmt/matcher.py +358 -0
- llmt/models.py +130 -0
- llmt/output.py +51 -0
- llmt/permissions.py +154 -0
- llmt/scancmd.py +556 -0
- llmt/scanner.py +328 -0
- llmt/telemetry/__init__.py +15 -0
- llmt/telemetry/artifacts.py +217 -0
- llmt/telemetry/beat.py +221 -0
- llmt/telemetry/cmd.py +358 -0
- llmt/telemetry/collect.py +252 -0
- llmt/telemetry/config.py +237 -0
- llmt/telemetry/consent.py +57 -0
- llmt/telemetry/errors.py +41 -0
- llmt/telemetry/wire.py +194 -0
- llmt/verify.py +129 -0
- llmt_cli-0.2.0.dist-info/METADATA +307 -0
- llmt_cli-0.2.0.dist-info/RECORD +33 -0
- llmt_cli-0.2.0.dist-info/WHEEL +5 -0
- llmt_cli-0.2.0.dist-info/entry_points.txt +2 -0
- llmt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmt_cli-0.2.0.dist-info/top_level.txt +1 -0
llmt/clients.py
ADDED
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
"""BitTorrent client adapters for ``llmt scan`` seeding.
|
|
2
|
+
|
|
3
|
+
Three real adapters plus a test fake, behind one ``SeedClient`` interface:
|
|
4
|
+
|
|
5
|
+
* ``QBittorrentClient`` — WebUI API, implementing the frozen
|
|
6
|
+
``QBITTORRENT_ADD_RECIPE``: add **paused**, ``skip_checking=false``,
|
|
7
|
+
``autoTMM=false``, ``savepath=<plan.save_path>`` -> wait for magnet
|
|
8
|
+
metadata (piece layout) -> recheck, polled to completion -> resume
|
|
9
|
+
(``/torrents/start`` on WebUI API >= 2.11, i.e. qBt 5.x) ONLY when the
|
|
10
|
+
recheck confirmed 100% of the data present.
|
|
11
|
+
* ``TransmissionClient`` — RPC (``torrent-add`` paused -> wait metadata ->
|
|
12
|
+
verify, polled to completion -> start only at 100%), with the
|
|
13
|
+
``X-Transmission-Session-Id`` 409 handshake.
|
|
14
|
+
* ``FallbackClient`` — no client reachable/configured: surface the magnet
|
|
15
|
+
plus paused-add instructions for a manual add.
|
|
16
|
+
|
|
17
|
+
Security (red-team axes):
|
|
18
|
+
* Connection details come from flags / env, never hardcoded.
|
|
19
|
+
* Magnets from the API are UNTRUSTED: validated to the ``magnet:`` scheme and
|
|
20
|
+
a conservative character set, and only ever passed as form/JSON values over
|
|
21
|
+
``requests`` — never through a shell — so there is no injection surface.
|
|
22
|
+
* Webseed URLs are never accepted here (they can carry credentials); the
|
|
23
|
+
client obtains webseeds from the torrent itself.
|
|
24
|
+
* FAIL-CLOSED HANDOFF: the adapters never resume/start a torrent whose
|
|
25
|
+
recheck/verify did not confirm 100% local possession of every wanted,
|
|
26
|
+
locally-expected file. The link farm is
|
|
27
|
+
hardlink/symlink-only (no copy mode), so letting the client download
|
|
28
|
+
"missing" pieces would write THROUGH the links into the user's original
|
|
29
|
+
files (corruption), or pull unlinked sibling files of a multi-file
|
|
30
|
+
container. Incomplete or unverifiable => honest error, torrent left
|
|
31
|
+
paused, non-zero exit (ClientError).
|
|
32
|
+
* Every WebUI/RPC response is status-checked -- and the qBt add BODY is
|
|
33
|
+
checked too (HTTP 200 "Fails." = rejected add); "rechecked/resumed/seeding"
|
|
34
|
+
is only ever reported for a client-confirmed state.
|
|
35
|
+
* M5 (size-aware Option C, Pat 2026-07-13): for a PARTIALLY-present
|
|
36
|
+
multi-file container the scan layer passes a ``FileSelection``. Every
|
|
37
|
+
missing large file is marked do-not-download (qBt ``filePrio`` priority 0
|
|
38
|
+
/ Transmission ``files-unwanted``) after metadata is known and BEFORE any
|
|
39
|
+
recheck or resume, and the mark is READ BACK from the client; if the
|
|
40
|
+
client does not confirm it, the handoff fails closed (torrent left
|
|
41
|
+
paused). Missing tiny companion files (<= 10 MiB cumulative,
|
|
42
|
+
``expect_fetch``) stay wanted and are fetched by the client after resume
|
|
43
|
+
-- the completion gate then checks per-file state instead of the scalar
|
|
44
|
+
progress, so it still requires 100% of every file the user actually has.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import base64
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import time
|
|
53
|
+
from dataclasses import dataclass, field
|
|
54
|
+
from typing import Optional
|
|
55
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
56
|
+
|
|
57
|
+
import requests
|
|
58
|
+
|
|
59
|
+
from .errors import LlmtError
|
|
60
|
+
from .layout import M5_AUTO_FETCH_MAX_BYTES
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def magnet_embeds_userinfo(magnet: str) -> bool:
|
|
64
|
+
"""True if any embedded tracker/webseed URL param carries userinfo
|
|
65
|
+
(``user:pass@host``) — a credential-smuggling vector we refuse."""
|
|
66
|
+
try:
|
|
67
|
+
query = urlsplit(magnet).query
|
|
68
|
+
for key, val in parse_qsl(query, keep_blank_values=True):
|
|
69
|
+
if key.lower() in ("tr", "ws", "xs", "as", "mt") and "://" in val:
|
|
70
|
+
if "@" in urlsplit(val).netloc:
|
|
71
|
+
return True
|
|
72
|
+
except ValueError:
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
_MAGNET_RE = re.compile(r"^magnet:\?[A-Za-z0-9=&%:._~!*'();/?@+$,\[\]-]+$")
|
|
77
|
+
|
|
78
|
+
# BEP 9 (v1): xt=urn:btih:<40-hex or 32-base32>. BEP 52 (v2):
|
|
79
|
+
# xt=urn:btmh:1220<64-hex> (multihash: sha2-256 code 0x12, length 0x20).
|
|
80
|
+
_BTIH_RE = re.compile(r"^(?:[0-9A-Fa-f]{40}|[A-Za-z2-7]{32})$")
|
|
81
|
+
_BTMH_RE = re.compile(r"^1220[0-9A-Fa-f]{64}$", re.IGNORECASE)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def magnet_has_valid_infohash(magnet: str) -> bool:
|
|
85
|
+
"""True iff some ``xt`` param carries a well-formed BitTorrent info-hash
|
|
86
|
+
(``urn:btih:`` v1 or ``urn:btmh:`` v2). Clients reject magnets without
|
|
87
|
+
one -- qBittorrent answers HTTP 200 with body ``Fails.`` (ADP-6) -- so
|
|
88
|
+
they are refused up front instead of after a doomed add call."""
|
|
89
|
+
try:
|
|
90
|
+
pairs = parse_qsl(urlsplit(magnet).query, keep_blank_values=True)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
for key, val in pairs:
|
|
94
|
+
if key.lower() != "xt":
|
|
95
|
+
continue
|
|
96
|
+
val = val.strip()
|
|
97
|
+
low = val.lower()
|
|
98
|
+
if low.startswith("urn:btih:") and _BTIH_RE.match(val[9:]):
|
|
99
|
+
return True
|
|
100
|
+
if low.startswith("urn:btmh:") and _BTMH_RE.match(val[9:]):
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ClientError(LlmtError):
|
|
106
|
+
"""A torrent-client add/connection failed."""
|
|
107
|
+
exit_code = 14
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _norm_torrent_path(path: object) -> str:
|
|
111
|
+
"""Normalise an in-torrent path for name matching against a client's file
|
|
112
|
+
list: forward slashes, no leading "./" or stray leading/trailing slashes.
|
|
113
|
+
Comparison is case-sensitive (torrent paths are byte-exact)."""
|
|
114
|
+
p = str(path or "").replace("\\", "/")
|
|
115
|
+
while p.startswith("./"):
|
|
116
|
+
p = p[2:]
|
|
117
|
+
return p.strip("/")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class FileSelection:
|
|
122
|
+
"""M5 per-file wanted/unwanted decisions for one multi-file container.
|
|
123
|
+
|
|
124
|
+
``unwanted``: in-torrent paths (root-inclusive, as clients display
|
|
125
|
+
them) of missing files that must be marked
|
|
126
|
+
do-not-download BEFORE the torrent is ever resumed
|
|
127
|
+
(model weights / anything over the auto-fetch cap).
|
|
128
|
+
``expect_fetch``: in-torrent paths of missing metadata-class tiny files
|
|
129
|
+
(<= 10 MiB cumulative) that stay wanted; the client is
|
|
130
|
+
EXPECTED to download them after resume, so the
|
|
131
|
+
completion gate does not require them present.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
unwanted: "tuple[str, ...]" = ()
|
|
135
|
+
expect_fetch: "tuple[str, ...]" = ()
|
|
136
|
+
|
|
137
|
+
def __bool__(self) -> bool:
|
|
138
|
+
return bool(self.unwanted or self.expect_fetch)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class AddResult:
|
|
143
|
+
"""Outcome of a seed handoff.
|
|
144
|
+
|
|
145
|
+
``ok`` is honest (D15): True only when every step — add, metadata,
|
|
146
|
+
recheck to 100%, resume/start — was confirmed by the client. A handoff
|
|
147
|
+
that could not be verified reports ok=False; a failed or refused one
|
|
148
|
+
raises ``ClientError`` (fail closed)."""
|
|
149
|
+
method: str
|
|
150
|
+
ok: bool
|
|
151
|
+
steps: list = field(default_factory=list)
|
|
152
|
+
message: str = ""
|
|
153
|
+
dry_run: bool = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class PathVisibilityProbe:
|
|
158
|
+
"""S3 pre-flight result: is the torrent client on the same filesystem
|
|
159
|
+
namespace as the machine llmt runs on? ``supported`` False means the
|
|
160
|
+
adapter could not probe (unknown client, or the read failed) -- advisory
|
|
161
|
+
only, never a gate. ``visible_locally`` False is the signature of a
|
|
162
|
+
dockerized/remote client (its own default save path does not exist on
|
|
163
|
+
this machine)."""
|
|
164
|
+
supported: bool
|
|
165
|
+
client_default_path: str = ""
|
|
166
|
+
visible_locally: "Optional[bool]" = None
|
|
167
|
+
detail: str = ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _near_zero(progress, have=None) -> bool:
|
|
171
|
+
"""True when a finished recheck/verify found (almost) NONE of the data --
|
|
172
|
+
the tell-tale of a client that cannot see the layout path at all, as
|
|
173
|
+
opposed to a genuinely partial file."""
|
|
174
|
+
try:
|
|
175
|
+
if have is not None and int(have) == 0:
|
|
176
|
+
return True
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
pass
|
|
179
|
+
try:
|
|
180
|
+
return float(progress) < 0.01
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _path_blindness_hint(save_path: str) -> str:
|
|
186
|
+
"""Guidance appended to a fail-closed handoff error when the client's
|
|
187
|
+
recheck/verify found (near-)none of data llmt just verified at 100%
|
|
188
|
+
locally -- almost always a path the client cannot see."""
|
|
189
|
+
return (
|
|
190
|
+
" The client reported (almost) NONE of the data present at:\n"
|
|
191
|
+
" " + str(save_path) + "\n"
|
|
192
|
+
" llmt verified these file(s) at 100% on the machine it ran on, so a "
|
|
193
|
+
"near-0% recheck almost always means the torrent client cannot SEE "
|
|
194
|
+
"that path -- a qBittorrent/Transmission running in Docker (a seedbox "
|
|
195
|
+
"or unRAID box) only sees its own mounted volumes, not host paths such "
|
|
196
|
+
"as ~/.llmt/seeding-layouts.\n"
|
|
197
|
+
" Fix: put llmt's link farm on a directory the client can see (one of "
|
|
198
|
+
"its mounted volumes) and re-run, for example:\n"
|
|
199
|
+
" llmt scan --layout-base /path/mounted/into/the/client")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def validate_magnet(magnet: object) -> str:
|
|
203
|
+
"""Return the magnet iff it is a well-formed ``magnet:?`` URI, else raise.
|
|
204
|
+
|
|
205
|
+
Anti-injection: rejects whitespace, control chars, non-magnet schemes, and
|
|
206
|
+
anything outside a conservative URI character set."""
|
|
207
|
+
if not isinstance(magnet, str) or not magnet:
|
|
208
|
+
raise ClientError("refusing an empty/invalid magnet from the API.")
|
|
209
|
+
if any(c in magnet for c in (" ", "\n", "\r", "\t", "\x00")):
|
|
210
|
+
raise ClientError("refusing a magnet containing whitespace/control chars.")
|
|
211
|
+
if not magnet.startswith("magnet:?") or not _MAGNET_RE.match(magnet):
|
|
212
|
+
raise ClientError("refusing a malformed magnet URI (anti-injection).")
|
|
213
|
+
if not magnet_has_valid_infohash(magnet):
|
|
214
|
+
raise ClientError(
|
|
215
|
+
"refusing a magnet without a valid xt=urn:btih:/urn:btmh: "
|
|
216
|
+
"info-hash param -- clients reject these (qBittorrent answers "
|
|
217
|
+
"HTTP 200 'Fails.').")
|
|
218
|
+
if magnet_embeds_userinfo(magnet):
|
|
219
|
+
raise ClientError(
|
|
220
|
+
"refusing a magnet whose tracker/webseed URL carries credentials "
|
|
221
|
+
"(userinfo smuggling defense).")
|
|
222
|
+
return magnet
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class SeedClient:
|
|
226
|
+
name = "base"
|
|
227
|
+
|
|
228
|
+
def add(self, *, magnet: str, info_hash: Optional[str], save_path: str,
|
|
229
|
+
torrent_name: str = "", dry_run: bool = False,
|
|
230
|
+
file_selection: Optional[FileSelection] = None) -> AddResult:
|
|
231
|
+
raise NotImplementedError
|
|
232
|
+
|
|
233
|
+
def probe_local_visibility(self) -> PathVisibilityProbe:
|
|
234
|
+
"""S3 best-effort pre-flight: does this client share a filesystem
|
|
235
|
+
namespace with the machine llmt runs on? Base returns an unsupported
|
|
236
|
+
probe; real adapters override. Never raises -- advisory only."""
|
|
237
|
+
return PathVisibilityProbe(supported=False)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --------------------------------------------------------------------------- #
|
|
241
|
+
# qBittorrent WebUI
|
|
242
|
+
# --------------------------------------------------------------------------- #
|
|
243
|
+
|
|
244
|
+
class QBittorrentClient(SeedClient):
|
|
245
|
+
name = "qbittorrent"
|
|
246
|
+
|
|
247
|
+
# Poll tuning (class attributes so tests can shrink them). All waits are
|
|
248
|
+
# bounded: ~poll_interval * *_max_polls seconds, then an honest failure.
|
|
249
|
+
poll_interval = 1.0 # seconds between polls
|
|
250
|
+
metadata_max_polls = 120 # magnet metadata wait (~2 min at defaults)
|
|
251
|
+
recheck_max_polls = 3600 # recheck completion wait (~1 h at defaults)
|
|
252
|
+
recheck_grace_polls = 5 # polls allowed for the recheck to get scheduled
|
|
253
|
+
|
|
254
|
+
def __init__(self, url: str, username: Optional[str] = None,
|
|
255
|
+
password: Optional[str] = None,
|
|
256
|
+
session: Optional[requests.Session] = None,
|
|
257
|
+
timeout: int = 30):
|
|
258
|
+
url = (url or "").rstrip("/")
|
|
259
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
260
|
+
raise ClientError("qBittorrent URL must be http(s)://")
|
|
261
|
+
self.url = url
|
|
262
|
+
self.username = username
|
|
263
|
+
self.password = password
|
|
264
|
+
self.session = session or requests.Session()
|
|
265
|
+
self.timeout = timeout
|
|
266
|
+
self._logged_in = False
|
|
267
|
+
|
|
268
|
+
def _headers(self) -> dict:
|
|
269
|
+
# qBittorrent's WebUI requires a matching Referer for CSRF.
|
|
270
|
+
return {"Referer": self.url, "Origin": self.url}
|
|
271
|
+
|
|
272
|
+
def _login(self) -> None:
|
|
273
|
+
if self._logged_in:
|
|
274
|
+
return
|
|
275
|
+
if not (self.username or self.password):
|
|
276
|
+
self._logged_in = True # WebUI may allow localhost without auth
|
|
277
|
+
return
|
|
278
|
+
r = self.session.post(
|
|
279
|
+
f"{self.url}/api/v2/auth/login",
|
|
280
|
+
data={"username": self.username or "", "password": self.password or ""},
|
|
281
|
+
headers=self._headers(), timeout=self.timeout,
|
|
282
|
+
)
|
|
283
|
+
# Login-success contract (accept iff the creds were accepted AND a
|
|
284
|
+
# valid authenticated session was established):
|
|
285
|
+
# * qBt 5.x (WebUI API 2.15.x) answers a SUCCESSFUL login with HTTP
|
|
286
|
+
# 204 and an EMPTY body -- it only sets the QBT_SID_* cookie.
|
|
287
|
+
# Legacy 4.x answers 200 "Ok.". Brittle-matching body == "Ok."
|
|
288
|
+
# (the old check) rejected every 5.x login and aborted the add.
|
|
289
|
+
# * A rejected login is 200 "Fails." (bad creds) or a non-2xx status
|
|
290
|
+
# (401/403/...); qBt does NOT set a valid session cookie for those.
|
|
291
|
+
# So any non-2xx or the literal "Fails." body is an outright failure,
|
|
292
|
+
# and a 2xx is necessary but NOT sufficient: confirm the session is
|
|
293
|
+
# really authenticated with the same lightweight probe version-detect
|
|
294
|
+
# uses. With the session cookie now set, GET /app/webapiVersion
|
|
295
|
+
# returns 200 only when logged in -- future-proof against qBt flipping
|
|
296
|
+
# the success status between 200 and 204.
|
|
297
|
+
if not (200 <= r.status_code < 300) or r.text.strip() == "Fails.":
|
|
298
|
+
raise ClientError("qBittorrent login failed (check --qb-user/--qb-pass).")
|
|
299
|
+
probe = self._get("/api/v2/app/webapiVersion")
|
|
300
|
+
if probe.status_code != 200:
|
|
301
|
+
raise ClientError("qBittorrent login failed (check --qb-user/--qb-pass).")
|
|
302
|
+
self._logged_in = True
|
|
303
|
+
|
|
304
|
+
# -- checked HTTP helpers (H2: no response is ever discarded) ---------- #
|
|
305
|
+
|
|
306
|
+
def _get(self, path: str, params: Optional[dict] = None) -> requests.Response:
|
|
307
|
+
return self.session.get(f"{self.url}{path}", params=params or {},
|
|
308
|
+
headers=self._headers(), timeout=self.timeout)
|
|
309
|
+
|
|
310
|
+
def _post_checked(self, path: str, data: dict, what: str) -> requests.Response:
|
|
311
|
+
r = self.session.post(f"{self.url}{path}", data=data,
|
|
312
|
+
headers=self._headers(), timeout=self.timeout)
|
|
313
|
+
if r.status_code != 200:
|
|
314
|
+
raise ClientError(f"qBittorrent {what} failed (HTTP {r.status_code}).")
|
|
315
|
+
return r
|
|
316
|
+
|
|
317
|
+
def _properties(self, h: str) -> Optional[dict]:
|
|
318
|
+
"""GET /torrents/properties; None while the torrent is not yet known
|
|
319
|
+
(magnet adds register asynchronously)."""
|
|
320
|
+
r = self._get("/api/v2/torrents/properties", {"hash": h})
|
|
321
|
+
if r.status_code == 404:
|
|
322
|
+
return None
|
|
323
|
+
if r.status_code != 200:
|
|
324
|
+
raise ClientError(
|
|
325
|
+
f"qBittorrent properties read failed (HTTP {r.status_code}).")
|
|
326
|
+
try:
|
|
327
|
+
return r.json()
|
|
328
|
+
except ValueError as exc:
|
|
329
|
+
raise ClientError("qBittorrent properties returned non-JSON.") from exc
|
|
330
|
+
|
|
331
|
+
def _info(self, h: str) -> Optional[dict]:
|
|
332
|
+
"""GET /torrents/info for one hash; None if the torrent is unknown."""
|
|
333
|
+
r = self._get("/api/v2/torrents/info", {"hashes": h})
|
|
334
|
+
if r.status_code != 200:
|
|
335
|
+
raise ClientError(
|
|
336
|
+
f"qBittorrent info read failed (HTTP {r.status_code}).")
|
|
337
|
+
try:
|
|
338
|
+
entries = r.json()
|
|
339
|
+
except ValueError as exc:
|
|
340
|
+
raise ClientError("qBittorrent info returned non-JSON.") from exc
|
|
341
|
+
if not isinstance(entries, list) or not entries:
|
|
342
|
+
return None
|
|
343
|
+
# Old Web API (2.0.0, qBt 4.1.0-4.1.2) silently IGNORES the hashes=
|
|
344
|
+
# filter and returns ALL torrents; trusting entries[0] would read an
|
|
345
|
+
# arbitrary torrent's state/progress and defeat the verify-before-
|
|
346
|
+
# seed gate. Only an entry whose hash matches is believed (case-
|
|
347
|
+
# insensitive: info-hashes are hex); no match is treated exactly
|
|
348
|
+
# like an empty response — torrent unknown, fail closed upstream.
|
|
349
|
+
for entry in entries:
|
|
350
|
+
if (isinstance(entry, dict)
|
|
351
|
+
and str(entry.get("hash") or "").lower() == h.lower()):
|
|
352
|
+
return entry
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def _files(self, h: str) -> "list[dict]":
|
|
356
|
+
"""GET /torrents/files: the torrent's file list (name, per-file
|
|
357
|
+
progress 0..1, priority, index). Status- and shape-checked."""
|
|
358
|
+
r = self._get("/api/v2/torrents/files", {"hash": h})
|
|
359
|
+
if r.status_code != 200:
|
|
360
|
+
raise ClientError(
|
|
361
|
+
f"qBittorrent files read failed (HTTP {r.status_code}).")
|
|
362
|
+
try:
|
|
363
|
+
entries = r.json()
|
|
364
|
+
except ValueError as exc:
|
|
365
|
+
raise ClientError("qBittorrent files returned non-JSON.") from exc
|
|
366
|
+
if not isinstance(entries, list) or not entries:
|
|
367
|
+
raise ClientError(
|
|
368
|
+
"qBittorrent returned an empty/unexpected file list for the "
|
|
369
|
+
"torrent; cannot verify per-file state -- NOT seeding.")
|
|
370
|
+
return [e for e in entries if isinstance(e, dict)]
|
|
371
|
+
|
|
372
|
+
def _apply_selection(self, h: str, selection: FileSelection,
|
|
373
|
+
steps: list) -> None:
|
|
374
|
+
"""M5: mark every missing LARGE file do-not-download (priority 0)
|
|
375
|
+
BEFORE any recheck/resume, and read the mark back from the client.
|
|
376
|
+
Unconfirmed => ClientError (fail closed, torrent left paused)."""
|
|
377
|
+
if not selection.unwanted:
|
|
378
|
+
return
|
|
379
|
+
entries = self._files(h)
|
|
380
|
+
by_name: dict = {}
|
|
381
|
+
for pos, e in enumerate(entries):
|
|
382
|
+
by_name[_norm_torrent_path(e.get("name"))] = (pos, e)
|
|
383
|
+
ids = []
|
|
384
|
+
for path in selection.unwanted:
|
|
385
|
+
hit = by_name.get(_norm_torrent_path(path))
|
|
386
|
+
if hit is None:
|
|
387
|
+
raise ClientError(
|
|
388
|
+
f"cannot mark {path!r} do-not-download: the torrent's "
|
|
389
|
+
"file list does not contain it (torrent/manifest "
|
|
390
|
+
"disagreement) -- refusing to continue; the torrent was "
|
|
391
|
+
"left paused in qBittorrent and is NOT seeding.")
|
|
392
|
+
pos, e = hit
|
|
393
|
+
idx = e.get("index")
|
|
394
|
+
ids.append(idx if isinstance(idx, int)
|
|
395
|
+
and not isinstance(idx, bool) else pos)
|
|
396
|
+
self._post_checked(
|
|
397
|
+
"/api/v2/torrents/filePrio",
|
|
398
|
+
{"hash": h, "id": "|".join(str(i) for i in ids), "priority": 0},
|
|
399
|
+
"filePrio")
|
|
400
|
+
steps.append(
|
|
401
|
+
f"POST /api/v2/torrents/filePrio (priority=0 for "
|
|
402
|
+
f"{len(ids)} missing large file(s)) -> HTTP 200")
|
|
403
|
+
self._assert_unwanted_applied(h, selection)
|
|
404
|
+
steps.append("confirmed do-not-download (priority 0) on: "
|
|
405
|
+
+ ", ".join(selection.unwanted))
|
|
406
|
+
|
|
407
|
+
def _assert_unwanted_applied(self, h: str,
|
|
408
|
+
selection: FileSelection) -> None:
|
|
409
|
+
"""Read priorities back; every unwanted file must REALLY be at
|
|
410
|
+
priority 0 or the handoff fails closed (no resume)."""
|
|
411
|
+
if not selection.unwanted:
|
|
412
|
+
return
|
|
413
|
+
prio = {_norm_torrent_path(e.get("name")): e.get("priority")
|
|
414
|
+
for e in self._files(h)}
|
|
415
|
+
bad = []
|
|
416
|
+
for path in selection.unwanted:
|
|
417
|
+
v = prio.get(_norm_torrent_path(path))
|
|
418
|
+
try:
|
|
419
|
+
ok = int(v) == 0
|
|
420
|
+
except (TypeError, ValueError):
|
|
421
|
+
ok = False
|
|
422
|
+
if not ok:
|
|
423
|
+
bad.append(path)
|
|
424
|
+
if bad:
|
|
425
|
+
raise ClientError(
|
|
426
|
+
"qBittorrent did not confirm do-not-download (priority 0) "
|
|
427
|
+
"for: " + ", ".join(bad) + " -- refusing to resume (resuming "
|
|
428
|
+
"would download the missing large file(s) onto your disk); "
|
|
429
|
+
"the torrent was left paused in qBittorrent and is NOT "
|
|
430
|
+
"seeding.")
|
|
431
|
+
|
|
432
|
+
def _gate_selection(self, h: str, selection: FileSelection,
|
|
433
|
+
save_path: str = "") -> None:
|
|
434
|
+
"""M5 completion gate for a partial container. The scalar
|
|
435
|
+
/torrents/info ``progress`` is computed over WANTED bytes, so with
|
|
436
|
+
still-wanted expect_fetch files missing it is legitimately < 1.0 --
|
|
437
|
+
and we refuse to trust any scalar here anyway. The gate reads
|
|
438
|
+
per-file progress from /torrents/files (unambiguous regardless of
|
|
439
|
+
the scalar's wanted-ness semantics): every file the user is supposed
|
|
440
|
+
to HAVE must be at 100%; unwanted files are re-confirmed at priority
|
|
441
|
+
0 (so no resume window ever exists with a large missing file
|
|
442
|
+
wanted); expect_fetch files may be incomplete by design."""
|
|
443
|
+
entries = self._files(h)
|
|
444
|
+
unwanted = {_norm_torrent_path(x) for x in selection.unwanted}
|
|
445
|
+
fetch = {_norm_torrent_path(x) for x in selection.expect_fetch}
|
|
446
|
+
incomplete = []
|
|
447
|
+
saw_any_data = False
|
|
448
|
+
for pos, e in enumerate(entries):
|
|
449
|
+
name = _norm_torrent_path(e.get("name"))
|
|
450
|
+
if name in unwanted or name in fetch:
|
|
451
|
+
continue
|
|
452
|
+
try:
|
|
453
|
+
done = float(e.get("progress") or 0.0)
|
|
454
|
+
except (TypeError, ValueError):
|
|
455
|
+
done = 0.0
|
|
456
|
+
if done > 0.0:
|
|
457
|
+
saw_any_data = True
|
|
458
|
+
if done < 1.0:
|
|
459
|
+
incomplete.append(str(e.get("name") or f"file #{pos}"))
|
|
460
|
+
if incomplete:
|
|
461
|
+
base = (
|
|
462
|
+
"recheck finished but these locally-expected files are not "
|
|
463
|
+
"100% present: " + ", ".join(incomplete) + " -- refusing to "
|
|
464
|
+
"resume; the torrent was left paused in qBittorrent and is "
|
|
465
|
+
"NOT seeding.")
|
|
466
|
+
if not saw_any_data:
|
|
467
|
+
raise ClientError(base + "\n" + _path_blindness_hint(save_path))
|
|
468
|
+
raise ClientError(base + " (Resuming would download the missing "
|
|
469
|
+
"data into your local files.)")
|
|
470
|
+
# M5 hardening: the torrent's own metadata is the final authority on
|
|
471
|
+
# expect_fetch sizes -- a wrong-but-signed size_bytes must never
|
|
472
|
+
# cause a large download. Every expect_fetch file, and their sum,
|
|
473
|
+
# must fit the auto-fetch cap or the handoff fails closed.
|
|
474
|
+
oversized = []
|
|
475
|
+
fetch_total = 0
|
|
476
|
+
for e in entries:
|
|
477
|
+
name = _norm_torrent_path(e.get("name"))
|
|
478
|
+
if name not in fetch:
|
|
479
|
+
continue
|
|
480
|
+
size = e.get("size")
|
|
481
|
+
if (not isinstance(size, int) or isinstance(size, bool)
|
|
482
|
+
or size < 0 or size > M5_AUTO_FETCH_MAX_BYTES):
|
|
483
|
+
oversized.append(str(e.get("name")))
|
|
484
|
+
else:
|
|
485
|
+
fetch_total += size
|
|
486
|
+
if oversized or fetch_total > M5_AUTO_FETCH_MAX_BYTES:
|
|
487
|
+
names = ", ".join(oversized) if oversized else ", ".join(
|
|
488
|
+
sorted(fetch))
|
|
489
|
+
raise ClientError(
|
|
490
|
+
"refusing to resume: the torrent itself reports companion "
|
|
491
|
+
"file(s) expected to be tiny as over the 10 MiB auto-fetch "
|
|
492
|
+
"cap (or of unknown size): " + names + " -- the signed "
|
|
493
|
+
"metadata disagreed with the torrent; the torrent was left "
|
|
494
|
+
"paused in qBittorrent and is NOT seeding.")
|
|
495
|
+
self._assert_unwanted_applied(h, selection)
|
|
496
|
+
|
|
497
|
+
def _piece_counts(self, h: str) -> "tuple[int, int]":
|
|
498
|
+
"""(pieces_have, pieces_num) for the honest report; (-1, -1) if the
|
|
499
|
+
counts cannot be read (never masks the underlying refusal)."""
|
|
500
|
+
try:
|
|
501
|
+
props = self._properties(h) or {}
|
|
502
|
+
return (int(props.get("pieces_have", -1)),
|
|
503
|
+
int(props.get("pieces_num", -1)))
|
|
504
|
+
except (ClientError, TypeError, ValueError):
|
|
505
|
+
return (-1, -1)
|
|
506
|
+
|
|
507
|
+
# -- handoff phases ----------------------------------------------------- #
|
|
508
|
+
|
|
509
|
+
def _resolve_handle(self, info_hash: str) -> Optional[str]:
|
|
510
|
+
"""Finding 3: discover the ACTUAL qBittorrent WebUI addressing handle
|
|
511
|
+
for the torrent we just added, instead of assuming it equals the v1
|
|
512
|
+
info-hash. qBittorrent (libtorrent 2.0) keys BitTorrent v2 *hybrid*
|
|
513
|
+
torrents in the WebUI by their TRUNCATED-v2 info-hash, NOT the v1
|
|
514
|
+
info-hash we computed for the magnet/.torrent identity -- so
|
|
515
|
+
``/torrents/properties?hash=<v1>`` 404s and
|
|
516
|
+
``/torrents/info?hashes=<v1>`` returns 0 matches, and every keyed-by-v1
|
|
517
|
+
post-add call fails closed at the metadata gate.
|
|
518
|
+
|
|
519
|
+
qBt 4.4.0+/API 2.8.3+ (present in BOTH tested images -- 4.6.7/2.9.3 and
|
|
520
|
+
5.2.3/2.15.1) exposes ``infohash_v1`` and ``infohash_v2`` on every
|
|
521
|
+
torrent in the /torrents/info list. Query the full list (the v1-keyed
|
|
522
|
+
targeted filter can't find a hybrid torrent) and find OUR torrent by
|
|
523
|
+
matching our known v1 hash against ``infohash_v1``; use THAT torrent's
|
|
524
|
+
qBt ``hash`` field as the handle for every subsequent WebUI call.
|
|
525
|
+
|
|
526
|
+
Match precedence: ``infohash_v1`` (hybrid or v1-only), then
|
|
527
|
+
``infohash_v2``, then the qBt ``hash`` field directly (a v1-only,
|
|
528
|
+
non-hybrid torrent on older qBt keys by the v1 hash == what we asked
|
|
529
|
+
for). The add registers asynchronously, so poll -- bounded, reusing
|
|
530
|
+
the metadata wait budget -- and return None (caller fails closed) if
|
|
531
|
+
the torrent never appears. This affects ONLY the WebUI addressing
|
|
532
|
+
handle: ``info_hash`` remains the torrent's cryptographic identity for
|
|
533
|
+
the magnet/.torrent resolution and the manifest/verify flow.
|
|
534
|
+
"""
|
|
535
|
+
want = (info_hash or "").lower()
|
|
536
|
+
for _attempt in range(self.metadata_max_polls):
|
|
537
|
+
# Full list, no hashes= filter: qBt cannot look a hybrid torrent up
|
|
538
|
+
# by its v1 hash, so a targeted query would return nothing.
|
|
539
|
+
r = self._get("/api/v2/torrents/info")
|
|
540
|
+
if r.status_code != 200:
|
|
541
|
+
raise ClientError(
|
|
542
|
+
f"qBittorrent info read failed (HTTP {r.status_code}).")
|
|
543
|
+
try:
|
|
544
|
+
entries = r.json()
|
|
545
|
+
except ValueError as exc:
|
|
546
|
+
raise ClientError(
|
|
547
|
+
"qBittorrent info returned non-JSON.") from exc
|
|
548
|
+
if isinstance(entries, list):
|
|
549
|
+
v2_match = None
|
|
550
|
+
hash_match = None
|
|
551
|
+
for e in entries:
|
|
552
|
+
if not isinstance(e, dict):
|
|
553
|
+
continue
|
|
554
|
+
qbt_hash = str(e.get("hash") or "").lower()
|
|
555
|
+
if str(e.get("infohash_v1") or "").lower() == want:
|
|
556
|
+
# Primary: hybrid or v1-only -- qBt's hash is authoritative.
|
|
557
|
+
return qbt_hash or want
|
|
558
|
+
if (v2_match is None
|
|
559
|
+
and str(e.get("infohash_v2") or "").lower() == want):
|
|
560
|
+
v2_match = qbt_hash or want
|
|
561
|
+
if hash_match is None and qbt_hash == want:
|
|
562
|
+
hash_match = qbt_hash
|
|
563
|
+
if v2_match is not None:
|
|
564
|
+
return v2_match
|
|
565
|
+
if hash_match is not None:
|
|
566
|
+
return hash_match
|
|
567
|
+
time.sleep(self.poll_interval)
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
def _wait_for_metadata(self, h: str) -> None:
|
|
571
|
+
"""Magnet adds resolve metadata asynchronously; until the piece layout
|
|
572
|
+
is known a recheck is a silent no-op (M4). Poll, bounded, and fail
|
|
573
|
+
honestly on timeout — never pretend the recheck meant something."""
|
|
574
|
+
for _attempt in range(self.metadata_max_polls):
|
|
575
|
+
props = self._properties(h)
|
|
576
|
+
if props is not None and int(props.get("pieces_num") or -1) > 0:
|
|
577
|
+
return
|
|
578
|
+
time.sleep(self.poll_interval)
|
|
579
|
+
raise ClientError(
|
|
580
|
+
"timed out waiting for magnet metadata (piece layout still unknown "
|
|
581
|
+
f"after ~{int(self.metadata_max_polls * self.poll_interval)}s); the "
|
|
582
|
+
"torrent was left paused in qBittorrent and is NOT seeding.")
|
|
583
|
+
|
|
584
|
+
def _wait_recheck_complete(self, h: str) -> float:
|
|
585
|
+
"""Poll until the recheck finishes; return the resulting progress
|
|
586
|
+
(0.0-1.0). Completion is only believed after a ``checking*`` state
|
|
587
|
+
was OBSERVED and then cleared: right after the recheck POST the
|
|
588
|
+
torrent may still show its STALE pre-recheck fastresume state (often
|
|
589
|
+
progress==1.0 for an already-linked file), which must never be
|
|
590
|
+
mistaken for a finished recheck. The grace window only extends the
|
|
591
|
+
wait for checking to appear — it never converts to success; if the
|
|
592
|
+
recheck is never observed running, fail closed."""
|
|
593
|
+
seen_checking = False
|
|
594
|
+
for attempt in range(self.recheck_max_polls):
|
|
595
|
+
info = self._info(h)
|
|
596
|
+
if info is None:
|
|
597
|
+
raise ClientError(
|
|
598
|
+
"torrent disappeared from qBittorrent during recheck; "
|
|
599
|
+
"NOT seeding.")
|
|
600
|
+
state = str(info.get("state") or "")
|
|
601
|
+
checking = state.startswith("checking") or state == "queuedForChecking"
|
|
602
|
+
progress = float(info.get("progress") or 0.0)
|
|
603
|
+
if checking:
|
|
604
|
+
seen_checking = True
|
|
605
|
+
elif seen_checking:
|
|
606
|
+
# The recheck was observed running and has now finished;
|
|
607
|
+
# this progress is the verified truth.
|
|
608
|
+
return progress
|
|
609
|
+
elif attempt >= self.recheck_grace_polls:
|
|
610
|
+
# The recheck was never observed running: the non-checking
|
|
611
|
+
# state (and its progress — typically a stale 100% from the
|
|
612
|
+
# pre-recheck fastresume data) cannot be trusted. Fail
|
|
613
|
+
# closed rather than resume on stale state.
|
|
614
|
+
raise ClientError(
|
|
615
|
+
"recheck was accepted but never observed running (state "
|
|
616
|
+
f"stayed '{state}' through the grace window); refusing "
|
|
617
|
+
"to trust the client's stale progress — the torrent was "
|
|
618
|
+
"left paused in qBittorrent and is NOT seeding.")
|
|
619
|
+
time.sleep(self.poll_interval)
|
|
620
|
+
raise ClientError(
|
|
621
|
+
"recheck did not finish within "
|
|
622
|
+
f"~{int(self.recheck_max_polls * self.poll_interval)}s; the torrent "
|
|
623
|
+
"was left paused in qBittorrent and is NOT seeding.")
|
|
624
|
+
|
|
625
|
+
def _detect_start_endpoint(self) -> Optional[str]:
|
|
626
|
+
"""WebUI API >= 2.11 (qBt 5.0) REMOVED /torrents/resume in favour of
|
|
627
|
+
/torrents/start (404, no alias). Detect via /app/webapiVersion;
|
|
628
|
+
return 'start' or 'resume', or None when the version read failed
|
|
629
|
+
(caller then tries /start and falls back to /resume)."""
|
|
630
|
+
try:
|
|
631
|
+
r = self._get("/api/v2/app/webapiVersion")
|
|
632
|
+
if r.status_code != 200:
|
|
633
|
+
return None
|
|
634
|
+
ver = re.match(r"^\s*v?(\d+)\.(\d+)", r.text or "")
|
|
635
|
+
if not ver:
|
|
636
|
+
return None
|
|
637
|
+
major_minor = (int(ver.group(1)), int(ver.group(2)))
|
|
638
|
+
return "start" if major_minor >= (2, 11) else "resume"
|
|
639
|
+
except requests.RequestException:
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
def _start_torrent(self, h: str, steps: list) -> str:
|
|
643
|
+
"""Resume/start the torrent on the version-correct endpoint, checking
|
|
644
|
+
every response. Returns the endpoint used."""
|
|
645
|
+
endpoint = self._detect_start_endpoint()
|
|
646
|
+
if endpoint is not None:
|
|
647
|
+
self._post_checked(f"/api/v2/torrents/{endpoint}", {"hashes": h},
|
|
648
|
+
endpoint)
|
|
649
|
+
steps.append(f"POST /api/v2/torrents/{endpoint} (hashes={h}) "
|
|
650
|
+
"-> HTTP 200")
|
|
651
|
+
return endpoint
|
|
652
|
+
# Version unknown: try the current endpoint first, fall back once.
|
|
653
|
+
r = self.session.post(f"{self.url}/api/v2/torrents/start",
|
|
654
|
+
data={"hashes": h}, headers=self._headers(),
|
|
655
|
+
timeout=self.timeout)
|
|
656
|
+
if r.status_code == 200:
|
|
657
|
+
steps.append(f"POST /api/v2/torrents/start (hashes={h}) -> HTTP 200")
|
|
658
|
+
return "start"
|
|
659
|
+
if r.status_code == 404:
|
|
660
|
+
steps.append("POST /api/v2/torrents/start -> HTTP 404 "
|
|
661
|
+
"(pre-2.11 WebUI); retrying /api/v2/torrents/resume")
|
|
662
|
+
self._post_checked("/api/v2/torrents/resume", {"hashes": h},
|
|
663
|
+
"resume")
|
|
664
|
+
steps.append(f"POST /api/v2/torrents/resume (hashes={h}) "
|
|
665
|
+
"-> HTTP 200")
|
|
666
|
+
return "resume"
|
|
667
|
+
raise ClientError(f"qBittorrent start failed (HTTP {r.status_code}).")
|
|
668
|
+
|
|
669
|
+
def add(self, *, magnet, info_hash, save_path, torrent_name="",
|
|
670
|
+
dry_run=False, file_selection=None):
|
|
671
|
+
magnet = validate_magnet(magnet)
|
|
672
|
+
selection = file_selection if file_selection else None
|
|
673
|
+
steps = [
|
|
674
|
+
f"POST /api/v2/torrents/add "
|
|
675
|
+
f"(savepath={save_path}, autoTMM=false, paused=true, stopped=true, "
|
|
676
|
+
f"skip_checking=false)"
|
|
677
|
+
]
|
|
678
|
+
if dry_run:
|
|
679
|
+
return AddResult(self.name, True, steps,
|
|
680
|
+
"dry-run: no WebUI calls made", True)
|
|
681
|
+
# Fix 14A-12: resolve the magnet to a .torrent up front (aria2c via the
|
|
682
|
+
# hardened build_aria2c_args choke point) -- real clients do NOT fetch
|
|
683
|
+
# magnet metadata while added paused/stopped
|
|
684
|
+
# (docs/HARNESS-magnet-metadata.md), so a paused magnet add would hang
|
|
685
|
+
# at the metadata gate forever. On ANY resolution failure this raises
|
|
686
|
+
# ClientError and the magnet is NEVER sent to qBittorrent.
|
|
687
|
+
from .download import resolve_magnet_to_torrent
|
|
688
|
+
torrent_bytes = resolve_magnet_to_torrent(magnet, info_hash)
|
|
689
|
+
steps.insert(0, "resolved magnet -> .torrent via aria2c "
|
|
690
|
+
"(metadata-only, no payload)")
|
|
691
|
+
self._login()
|
|
692
|
+
# Per QBITTORRENT_ADD_RECIPE: paused + skip_checking=false + autoTMM=false.
|
|
693
|
+
r = self.session.post(
|
|
694
|
+
f"{self.url}/api/v2/torrents/add",
|
|
695
|
+
files={"torrents": ("resolved.torrent", torrent_bytes,
|
|
696
|
+
"application/x-bittorrent")},
|
|
697
|
+
data={
|
|
698
|
+
"savepath": save_path,
|
|
699
|
+
"autoTMM": "false",
|
|
700
|
+
# qBt 4.x honors "paused"; qBt 5.x renamed it to "stopped".
|
|
701
|
+
# Sending both keeps "add paused" on every version.
|
|
702
|
+
"paused": "true",
|
|
703
|
+
"stopped": "true",
|
|
704
|
+
"skip_checking": "false",
|
|
705
|
+
},
|
|
706
|
+
headers=self._headers(), timeout=max(self.timeout, 60),
|
|
707
|
+
)
|
|
708
|
+
if r.status_code != 200:
|
|
709
|
+
raise ClientError(f"qBittorrent add failed (HTTP {r.status_code}).")
|
|
710
|
+
# ADP-6: qBittorrent rejects an add (e.g. an invalid magnet) with
|
|
711
|
+
# HTTP 200 and the literal body "Fails." -- the status code alone is
|
|
712
|
+
# NOT success. Believing it would send the recheck/resume machinery
|
|
713
|
+
# chasing a torrent that was never added (phantom-seed report).
|
|
714
|
+
if (r.text or "").strip().lower() == "fails.":
|
|
715
|
+
raise ClientError(
|
|
716
|
+
"qBittorrent refused the add (HTTP 200, body 'Fails.') -- "
|
|
717
|
+
"the torrent was NOT added and is NOT seeding.")
|
|
718
|
+
h = (info_hash or "").lower()
|
|
719
|
+
if not h:
|
|
720
|
+
steps.append("WARNING: no info_hash returned by the API; recheck/"
|
|
721
|
+
"resume must be triggered manually in qBittorrent.")
|
|
722
|
+
if selection is not None and selection.unwanted:
|
|
723
|
+
steps.append(
|
|
724
|
+
"WARNING (M5): BEFORE resuming manually, mark these "
|
|
725
|
+
"missing large files \"do not download\" in qBittorrent "
|
|
726
|
+
"-- resuming without that would download them onto your "
|
|
727
|
+
"disk: " + ", ".join(selection.unwanted))
|
|
728
|
+
return AddResult(
|
|
729
|
+
self.name, False, steps,
|
|
730
|
+
"added paused, but the handoff could not be verified (no "
|
|
731
|
+
"info_hash) — NOT seeding until you recheck and resume "
|
|
732
|
+
"manually in qBittorrent", False)
|
|
733
|
+
# Finding 3: the WebUI handle for a BitTorrent v2 *hybrid* torrent is
|
|
734
|
+
# qBt's truncated-v2 hash, NOT the v1 info-hash. Resolve the real
|
|
735
|
+
# handle ONCE here and key every downstream call (metadata/properties,
|
|
736
|
+
# recheck, info, start/resume) on it. info_hash itself is unchanged --
|
|
737
|
+
# it stays the torrent's cryptographic identity for the manifest/verify
|
|
738
|
+
# flow. Fail closed if the torrent never appears (never proceed on a
|
|
739
|
+
# bad/assumed handle).
|
|
740
|
+
handle = self._resolve_handle(h)
|
|
741
|
+
if handle is None:
|
|
742
|
+
raise ClientError(
|
|
743
|
+
"added paused, but qBittorrent never listed the torrent by "
|
|
744
|
+
"its info-hash within "
|
|
745
|
+
f"~{int(self.metadata_max_polls * self.poll_interval)}s, so the "
|
|
746
|
+
"WebUI addressing handle could not be resolved; the torrent was "
|
|
747
|
+
"left paused in qBittorrent and is NOT seeding.")
|
|
748
|
+
if handle != h:
|
|
749
|
+
steps.append(
|
|
750
|
+
f"resolved qBt WebUI handle {handle} (v2-hybrid torrent keyed "
|
|
751
|
+
f"by truncated-v2 hash; v1 info_hash {h})")
|
|
752
|
+
else:
|
|
753
|
+
steps.append(f"qBt WebUI handle == info_hash ({h})")
|
|
754
|
+
h = handle
|
|
755
|
+
# M4: wait for the magnet's metadata before rechecking.
|
|
756
|
+
self._wait_for_metadata(h)
|
|
757
|
+
steps.append("waited for magnet metadata (piece layout known)")
|
|
758
|
+
# M5: set do-not-download marks BEFORE any recheck/resume so no code
|
|
759
|
+
# path can resume the torrent while a large missing file is wanted.
|
|
760
|
+
if selection is not None:
|
|
761
|
+
self._apply_selection(h, selection, steps)
|
|
762
|
+
# Recheck hashes the linked bytes; poll it to completion (C2).
|
|
763
|
+
self._post_checked("/api/v2/torrents/recheck", {"hashes": h}, "recheck")
|
|
764
|
+
steps.append(f"POST /api/v2/torrents/recheck (hashes={h}) -> HTTP 200")
|
|
765
|
+
progress = self._wait_recheck_complete(h)
|
|
766
|
+
if selection is None:
|
|
767
|
+
if progress < 1.0:
|
|
768
|
+
# FAIL CLOSED (C2): resuming an incomplete torrent would make
|
|
769
|
+
# the client download the missing pieces THROUGH the
|
|
770
|
+
# hardlinks/symlinks into the user's original files, or pull
|
|
771
|
+
# unlinked sibling files.
|
|
772
|
+
have, total = self._piece_counts(h)
|
|
773
|
+
detail = (f"{have} of {total} pieces present"
|
|
774
|
+
if total > 0 and have >= 0
|
|
775
|
+
else f"only {progress:.1%} of the data present")
|
|
776
|
+
tail = ("\n" + _path_blindness_hint(save_path)
|
|
777
|
+
if _near_zero(progress, have)
|
|
778
|
+
else " (Resuming would download the missing data "
|
|
779
|
+
"into your local files.)")
|
|
780
|
+
raise ClientError(
|
|
781
|
+
f"recheck finished but found {detail} — refusing to "
|
|
782
|
+
"resume; the torrent was left paused in qBittorrent and "
|
|
783
|
+
"is NOT seeding." + tail)
|
|
784
|
+
steps.append("recheck complete: 100% of the data verified present")
|
|
785
|
+
else:
|
|
786
|
+
# M5 partial container: per-file gate (see _gate_selection).
|
|
787
|
+
self._gate_selection(h, selection, save_path)
|
|
788
|
+
steps.append(
|
|
789
|
+
"recheck complete: every locally-expected file verified 100% "
|
|
790
|
+
f"present ({len(selection.unwanted)} marked do-not-download, "
|
|
791
|
+
f"{len(selection.expect_fetch)} small companion file(s) to "
|
|
792
|
+
"fetch)")
|
|
793
|
+
endpoint = self._start_torrent(h, steps)
|
|
794
|
+
if selection is not None and selection.expect_fetch:
|
|
795
|
+
steps.append(
|
|
796
|
+
"the client will now fetch the small companion file(s): "
|
|
797
|
+
+ ", ".join(selection.expect_fetch))
|
|
798
|
+
message = (f"added paused -> rechecked (100% present) -> resumed "
|
|
799
|
+
f"(/{endpoint} confirmed HTTP 200)")
|
|
800
|
+
if selection is not None:
|
|
801
|
+
parts = []
|
|
802
|
+
if selection.unwanted:
|
|
803
|
+
parts.append(f"{len(selection.unwanted)} missing large "
|
|
804
|
+
"file(s) marked do-not-download")
|
|
805
|
+
if selection.expect_fetch:
|
|
806
|
+
parts.append(f"{len(selection.expect_fetch)} small companion "
|
|
807
|
+
"file(s) will be fetched")
|
|
808
|
+
message = (f"added paused -> priorities set -> rechecked (all "
|
|
809
|
+
f"locally-expected files 100% present) -> resumed "
|
|
810
|
+
f"(/{endpoint} confirmed HTTP 200); " + "; ".join(parts))
|
|
811
|
+
return AddResult(self.name, True, steps, message, False)
|
|
812
|
+
|
|
813
|
+
def probe_local_visibility(self) -> PathVisibilityProbe:
|
|
814
|
+
"""S3: read qBt's own default save path (the client's perspective) and
|
|
815
|
+
check whether that path exists on the machine llmt runs on. Works on
|
|
816
|
+
every qBt version (``/app/preferences`` ``save_path`` is stable across
|
|
817
|
+
4.6.x and 5.x). Best-effort: any failure -> unsupported (advisory)."""
|
|
818
|
+
try:
|
|
819
|
+
self._login()
|
|
820
|
+
r = self._get("/api/v2/app/preferences")
|
|
821
|
+
if r.status_code != 200:
|
|
822
|
+
return PathVisibilityProbe(
|
|
823
|
+
False, detail=f"preferences read HTTP {r.status_code}")
|
|
824
|
+
prefs = r.json()
|
|
825
|
+
except (ClientError, requests.RequestException, ValueError) as exc:
|
|
826
|
+
return PathVisibilityProbe(False, detail=str(exc))
|
|
827
|
+
default = str((prefs or {}).get("save_path") or "")
|
|
828
|
+
if not default:
|
|
829
|
+
return PathVisibilityProbe(
|
|
830
|
+
True, "", None, "client reported no default save path")
|
|
831
|
+
visible = os.path.isdir(os.path.expanduser(default))
|
|
832
|
+
return PathVisibilityProbe(True, default, visible)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# --------------------------------------------------------------------------- #
|
|
836
|
+
# Transmission RPC
|
|
837
|
+
# --------------------------------------------------------------------------- #
|
|
838
|
+
|
|
839
|
+
class TransmissionClient(SeedClient):
|
|
840
|
+
name = "transmission"
|
|
841
|
+
|
|
842
|
+
# Poll tuning (class attributes so tests can shrink them); bounded waits.
|
|
843
|
+
poll_interval = 1.0
|
|
844
|
+
metadata_max_polls = 120
|
|
845
|
+
verify_max_polls = 3600
|
|
846
|
+
verify_grace_polls = 5
|
|
847
|
+
|
|
848
|
+
def __init__(self, url: str, username: Optional[str] = None,
|
|
849
|
+
password: Optional[str] = None,
|
|
850
|
+
session: Optional[requests.Session] = None,
|
|
851
|
+
timeout: int = 30):
|
|
852
|
+
url = (url or "").rstrip("/")
|
|
853
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
854
|
+
raise ClientError("Transmission URL must be http(s):// (RPC endpoint).")
|
|
855
|
+
self.url = url
|
|
856
|
+
self.username = username
|
|
857
|
+
self.password = password
|
|
858
|
+
self.session = session or requests.Session()
|
|
859
|
+
self.timeout = timeout
|
|
860
|
+
self._sid = ""
|
|
861
|
+
|
|
862
|
+
def _rpc(self, method: str, arguments: dict) -> dict:
|
|
863
|
+
auth = (self.username, self.password) if self.username else None
|
|
864
|
+
headers = {}
|
|
865
|
+
if self._sid:
|
|
866
|
+
headers["X-Transmission-Session-Id"] = self._sid
|
|
867
|
+
body = {"method": method, "arguments": arguments}
|
|
868
|
+
r = self.session.post(self.url, json=body, headers=headers,
|
|
869
|
+
auth=auth, timeout=self.timeout)
|
|
870
|
+
if r.status_code == 409:
|
|
871
|
+
self._sid = r.headers.get("X-Transmission-Session-Id", "")
|
|
872
|
+
headers["X-Transmission-Session-Id"] = self._sid
|
|
873
|
+
r = self.session.post(self.url, json=body, headers=headers,
|
|
874
|
+
auth=auth, timeout=self.timeout)
|
|
875
|
+
if r.status_code != 200:
|
|
876
|
+
raise ClientError(f"Transmission RPC {method} failed "
|
|
877
|
+
f"(HTTP {r.status_code}).")
|
|
878
|
+
try:
|
|
879
|
+
return r.json()
|
|
880
|
+
except ValueError as exc:
|
|
881
|
+
raise ClientError("Transmission RPC returned non-JSON.") from exc
|
|
882
|
+
|
|
883
|
+
def _rpc_ok(self, method: str, arguments: dict) -> dict:
|
|
884
|
+
"""RPC call whose ``result`` field must be ``success`` (H2 analog:
|
|
885
|
+
Transmission signals errors in-body over HTTP 200)."""
|
|
886
|
+
res = self._rpc(method, arguments)
|
|
887
|
+
if res.get("result") != "success":
|
|
888
|
+
raise ClientError(f"Transmission {method}: {res.get('result')}")
|
|
889
|
+
return res
|
|
890
|
+
|
|
891
|
+
def _torrent_state(self, tid) -> dict:
|
|
892
|
+
res = self._rpc_ok("torrent-get", {"ids": [tid], "fields": [
|
|
893
|
+
"id", "status", "percentDone", "metadataPercentComplete",
|
|
894
|
+
"recheckProgress", "haveValid", "sizeWhenDone"]})
|
|
895
|
+
torrents = (res.get("arguments") or {}).get("torrents") or []
|
|
896
|
+
if not torrents:
|
|
897
|
+
raise ClientError(
|
|
898
|
+
"torrent disappeared from Transmission during the handoff; "
|
|
899
|
+
"NOT seeding.")
|
|
900
|
+
return torrents[0]
|
|
901
|
+
|
|
902
|
+
def _wait_for_metadata(self, tid) -> None:
|
|
903
|
+
"""Magnet metadata resolves asynchronously; verify before that is a
|
|
904
|
+
no-op. Bounded poll, honest failure on timeout."""
|
|
905
|
+
for _attempt in range(self.metadata_max_polls):
|
|
906
|
+
t = self._torrent_state(tid)
|
|
907
|
+
mpc = t.get("metadataPercentComplete")
|
|
908
|
+
if mpc is None or float(mpc) >= 1.0:
|
|
909
|
+
return
|
|
910
|
+
time.sleep(self.poll_interval)
|
|
911
|
+
raise ClientError(
|
|
912
|
+
"timed out waiting for magnet metadata after "
|
|
913
|
+
f"~{int(self.metadata_max_polls * self.poll_interval)}s; the "
|
|
914
|
+
"torrent was left paused in Transmission and is NOT seeding.")
|
|
915
|
+
|
|
916
|
+
def _torrent_files(self, tid) -> "tuple[list, list]":
|
|
917
|
+
"""torrent-get files (name/length/bytesCompleted) + fileStats
|
|
918
|
+
(wanted/priority), shape-checked."""
|
|
919
|
+
res = self._rpc_ok("torrent-get", {"ids": [tid], "fields": [
|
|
920
|
+
"id", "files", "fileStats"]})
|
|
921
|
+
torrents = (res.get("arguments") or {}).get("torrents") or []
|
|
922
|
+
if not torrents:
|
|
923
|
+
raise ClientError(
|
|
924
|
+
"torrent disappeared from Transmission during the handoff; "
|
|
925
|
+
"NOT seeding.")
|
|
926
|
+
t = torrents[0] if isinstance(torrents[0], dict) else {}
|
|
927
|
+
files = t.get("files")
|
|
928
|
+
stats = t.get("fileStats")
|
|
929
|
+
files = files if isinstance(files, list) else []
|
|
930
|
+
stats = stats if isinstance(stats, list) else []
|
|
931
|
+
if not files:
|
|
932
|
+
raise ClientError(
|
|
933
|
+
"Transmission returned an empty/unexpected file list for the "
|
|
934
|
+
"torrent; cannot verify per-file state -- NOT seeding.")
|
|
935
|
+
return files, stats
|
|
936
|
+
|
|
937
|
+
def _indices_for(self, files: list, paths) -> "list[int]":
|
|
938
|
+
by_name: dict = {}
|
|
939
|
+
for i, f in enumerate(files):
|
|
940
|
+
if isinstance(f, dict):
|
|
941
|
+
by_name[_norm_torrent_path(f.get("name"))] = i
|
|
942
|
+
idxs = []
|
|
943
|
+
for path in paths:
|
|
944
|
+
i = by_name.get(_norm_torrent_path(path))
|
|
945
|
+
if i is None:
|
|
946
|
+
raise ClientError(
|
|
947
|
+
f"cannot mark {path!r} do-not-download: the torrent's "
|
|
948
|
+
"file list does not contain it (torrent/manifest "
|
|
949
|
+
"disagreement) -- refusing to continue; the torrent was "
|
|
950
|
+
"left paused in Transmission and is NOT seeding.")
|
|
951
|
+
idxs.append(i)
|
|
952
|
+
return idxs
|
|
953
|
+
|
|
954
|
+
def _apply_selection(self, tid, selection: FileSelection,
|
|
955
|
+
steps: list) -> None:
|
|
956
|
+
"""M5: mark every missing LARGE file files-unwanted BEFORE any
|
|
957
|
+
verify/start, and read the flag back. Unconfirmed => ClientError
|
|
958
|
+
(fail closed, torrent left paused)."""
|
|
959
|
+
if not selection.unwanted:
|
|
960
|
+
return
|
|
961
|
+
files, _stats = self._torrent_files(tid)
|
|
962
|
+
idxs = self._indices_for(files, selection.unwanted)
|
|
963
|
+
self._rpc_ok("torrent-set", {"ids": [tid], "files-unwanted": idxs})
|
|
964
|
+
steps.append(f"torrent-set files-unwanted ({len(idxs)} missing large "
|
|
965
|
+
"file(s)) -> success")
|
|
966
|
+
self._assert_unwanted_applied(tid, selection)
|
|
967
|
+
steps.append("confirmed do-not-download (wanted=false) on: "
|
|
968
|
+
+ ", ".join(selection.unwanted))
|
|
969
|
+
|
|
970
|
+
def _assert_unwanted_applied(self, tid,
|
|
971
|
+
selection: FileSelection) -> None:
|
|
972
|
+
"""Read fileStats back; every unwanted file must REALLY have
|
|
973
|
+
wanted=false or the handoff fails closed (no start)."""
|
|
974
|
+
if not selection.unwanted:
|
|
975
|
+
return
|
|
976
|
+
files, stats = self._torrent_files(tid)
|
|
977
|
+
idxs = self._indices_for(files, selection.unwanted)
|
|
978
|
+
bad = []
|
|
979
|
+
for path, i in zip(selection.unwanted, idxs):
|
|
980
|
+
st = stats[i] if i < len(stats) and isinstance(stats[i], dict) else None
|
|
981
|
+
if st is None or st.get("wanted") is not False:
|
|
982
|
+
bad.append(path)
|
|
983
|
+
if bad:
|
|
984
|
+
raise ClientError(
|
|
985
|
+
"Transmission did not confirm do-not-download "
|
|
986
|
+
"(wanted=false) for: " + ", ".join(bad) + " -- refusing to "
|
|
987
|
+
"start (starting would download the missing large file(s) "
|
|
988
|
+
"onto your disk); the torrent was left paused in "
|
|
989
|
+
"Transmission and is NOT seeding.")
|
|
990
|
+
|
|
991
|
+
def _gate_selection(self, tid, selection: FileSelection,
|
|
992
|
+
save_path: str = "") -> None:
|
|
993
|
+
"""M5 completion gate for a partial container. Transmission's
|
|
994
|
+
scalar ``percentDone`` covers WANTED files only (RPC spec: "How much
|
|
995
|
+
has been downloaded of the files the user wants"), so with
|
|
996
|
+
still-wanted expect_fetch files missing it is legitimately < 1.0 --
|
|
997
|
+
and we refuse to trust any scalar here anyway. The gate reads
|
|
998
|
+
per-file bytesCompleted/length from torrent-get files: every file
|
|
999
|
+
the user is supposed to HAVE must be complete; unwanted files are
|
|
1000
|
+
re-confirmed wanted=false (no start window with a large missing
|
|
1001
|
+
file wanted); expect_fetch files may be incomplete by design."""
|
|
1002
|
+
files, _stats = self._torrent_files(tid)
|
|
1003
|
+
unwanted = {_norm_torrent_path(x) for x in selection.unwanted}
|
|
1004
|
+
fetch = {_norm_torrent_path(x) for x in selection.expect_fetch}
|
|
1005
|
+
incomplete = []
|
|
1006
|
+
saw_any_data = False
|
|
1007
|
+
for i, f in enumerate(files):
|
|
1008
|
+
if not isinstance(f, dict):
|
|
1009
|
+
continue
|
|
1010
|
+
name = _norm_torrent_path(f.get("name"))
|
|
1011
|
+
if name in unwanted or name in fetch:
|
|
1012
|
+
continue
|
|
1013
|
+
try:
|
|
1014
|
+
length = int(f.get("length") or 0)
|
|
1015
|
+
done = int(f.get("bytesCompleted") or 0)
|
|
1016
|
+
except (TypeError, ValueError):
|
|
1017
|
+
length, done = 1, 0
|
|
1018
|
+
if done > 0:
|
|
1019
|
+
saw_any_data = True
|
|
1020
|
+
if done < length:
|
|
1021
|
+
incomplete.append(str(f.get("name") or f"file #{i}"))
|
|
1022
|
+
if incomplete:
|
|
1023
|
+
base = (
|
|
1024
|
+
"verify finished but these locally-expected files are not "
|
|
1025
|
+
"100% present: " + ", ".join(incomplete) + " -- refusing to "
|
|
1026
|
+
"start; the torrent was left paused in Transmission and is "
|
|
1027
|
+
"NOT seeding.")
|
|
1028
|
+
if not saw_any_data:
|
|
1029
|
+
raise ClientError(base + "\n" + _path_blindness_hint(save_path))
|
|
1030
|
+
raise ClientError(base + " (Starting would download the missing "
|
|
1031
|
+
"data into your local files.)")
|
|
1032
|
+
# M5 hardening: the torrent's own metadata is the final authority on
|
|
1033
|
+
# expect_fetch sizes -- a wrong-but-signed size_bytes must never
|
|
1034
|
+
# cause a large download. Every expect_fetch file, and their sum,
|
|
1035
|
+
# must fit the auto-fetch cap or the handoff fails closed.
|
|
1036
|
+
oversized = []
|
|
1037
|
+
fetch_total = 0
|
|
1038
|
+
for f in files:
|
|
1039
|
+
if not isinstance(f, dict):
|
|
1040
|
+
continue
|
|
1041
|
+
name = _norm_torrent_path(f.get("name"))
|
|
1042
|
+
if name not in fetch:
|
|
1043
|
+
continue
|
|
1044
|
+
length = f.get("length")
|
|
1045
|
+
if (not isinstance(length, int) or isinstance(length, bool)
|
|
1046
|
+
or length < 0 or length > M5_AUTO_FETCH_MAX_BYTES):
|
|
1047
|
+
oversized.append(str(f.get("name")))
|
|
1048
|
+
else:
|
|
1049
|
+
fetch_total += length
|
|
1050
|
+
if oversized or fetch_total > M5_AUTO_FETCH_MAX_BYTES:
|
|
1051
|
+
names = ", ".join(oversized) if oversized else ", ".join(
|
|
1052
|
+
sorted(fetch))
|
|
1053
|
+
raise ClientError(
|
|
1054
|
+
"refusing to start: the torrent itself reports companion "
|
|
1055
|
+
"file(s) expected to be tiny as over the 10 MiB auto-fetch "
|
|
1056
|
+
"cap (or of unknown size): " + names + " -- the signed "
|
|
1057
|
+
"metadata disagreed with the torrent; the torrent was left "
|
|
1058
|
+
"paused in Transmission and is NOT seeding.")
|
|
1059
|
+
self._assert_unwanted_applied(tid, selection)
|
|
1060
|
+
|
|
1061
|
+
def _wait_verify_complete(self, tid) -> dict:
|
|
1062
|
+
"""Poll until the verify finishes (status 1=queued-to-verify,
|
|
1063
|
+
2=verifying); return the final torrent state. Completion is only
|
|
1064
|
+
believed after a verifying status was OBSERVED and then cleared:
|
|
1065
|
+
right after torrent-verify the torrent may still show stale status 0
|
|
1066
|
+
with its pre-verify percentDone. The grace window only extends the
|
|
1067
|
+
wait for verifying to appear — never converts to success; if the
|
|
1068
|
+
verify is never observed running, fail closed."""
|
|
1069
|
+
seen_checking = False
|
|
1070
|
+
for attempt in range(self.verify_max_polls):
|
|
1071
|
+
t = self._torrent_state(tid)
|
|
1072
|
+
checking = int(t.get("status") or 0) in (1, 2)
|
|
1073
|
+
done = float(t.get("percentDone") or 0.0)
|
|
1074
|
+
if checking:
|
|
1075
|
+
seen_checking = True
|
|
1076
|
+
elif seen_checking:
|
|
1077
|
+
# The verify was observed running and has now finished.
|
|
1078
|
+
return t
|
|
1079
|
+
elif attempt >= self.verify_grace_polls:
|
|
1080
|
+
raise ClientError(
|
|
1081
|
+
"verify was accepted but never observed running (status "
|
|
1082
|
+
f"stayed {t.get('status')} through the grace window); "
|
|
1083
|
+
"refusing to trust the client's stale progress — the "
|
|
1084
|
+
"torrent was left paused in Transmission and is NOT "
|
|
1085
|
+
"seeding.")
|
|
1086
|
+
time.sleep(self.poll_interval)
|
|
1087
|
+
raise ClientError(
|
|
1088
|
+
"verify did not finish within "
|
|
1089
|
+
f"~{int(self.verify_max_polls * self.poll_interval)}s; the torrent "
|
|
1090
|
+
"was left paused in Transmission and is NOT seeding.")
|
|
1091
|
+
|
|
1092
|
+
def add(self, *, magnet, info_hash, save_path, torrent_name="",
|
|
1093
|
+
dry_run=False, file_selection=None):
|
|
1094
|
+
magnet = validate_magnet(magnet)
|
|
1095
|
+
selection = file_selection if file_selection else None
|
|
1096
|
+
steps = [f"torrent-add (metainfo=<resolved .torrent>, "
|
|
1097
|
+
f"download-dir={save_path}, paused=true)"]
|
|
1098
|
+
if dry_run:
|
|
1099
|
+
return AddResult(self.name, True, steps,
|
|
1100
|
+
"dry-run: no RPC calls made", True)
|
|
1101
|
+
# Fix 14A-12: resolve the magnet to a .torrent up front (see
|
|
1102
|
+
# QbtClient.add) and hand Transmission the metainfo (base64) instead of
|
|
1103
|
+
# the magnet -- a paused magnet never fetches metadata. Resolution
|
|
1104
|
+
# failure raises ClientError here; the magnet is never sent.
|
|
1105
|
+
from .download import resolve_magnet_to_torrent
|
|
1106
|
+
torrent_bytes = resolve_magnet_to_torrent(magnet, info_hash)
|
|
1107
|
+
steps.insert(0, "resolved magnet -> .torrent via aria2c "
|
|
1108
|
+
"(metadata-only, no payload)")
|
|
1109
|
+
res = self._rpc("torrent-add", {
|
|
1110
|
+
"metainfo": base64.b64encode(torrent_bytes).decode("ascii"),
|
|
1111
|
+
"download-dir": save_path, "paused": True,
|
|
1112
|
+
})
|
|
1113
|
+
if res.get("result") != "success":
|
|
1114
|
+
raise ClientError(f"Transmission torrent-add: {res.get('result')}")
|
|
1115
|
+
args = res.get("arguments", {}) or {}
|
|
1116
|
+
tobj = args.get("torrent-added") or args.get("torrent-duplicate") or {}
|
|
1117
|
+
tid = tobj.get("id")
|
|
1118
|
+
if tid is None:
|
|
1119
|
+
steps.append("WARNING: no torrent id returned; verify/start manually.")
|
|
1120
|
+
if selection is not None and selection.unwanted:
|
|
1121
|
+
steps.append(
|
|
1122
|
+
"WARNING (M5): BEFORE starting manually, mark these "
|
|
1123
|
+
'missing large files "do not download" in Transmission '
|
|
1124
|
+
"-- starting without that would download them onto your "
|
|
1125
|
+
"disk: " + ", ".join(selection.unwanted))
|
|
1126
|
+
return AddResult(
|
|
1127
|
+
self.name, False, steps,
|
|
1128
|
+
"added paused, but the handoff could not be verified (no "
|
|
1129
|
+
"torrent id) — NOT seeding until you verify and start "
|
|
1130
|
+
"manually in Transmission", False)
|
|
1131
|
+
# Wait for magnet metadata, then verify and poll it to completion.
|
|
1132
|
+
self._wait_for_metadata(tid)
|
|
1133
|
+
steps.append("waited for magnet metadata")
|
|
1134
|
+
# M5: set do-not-download marks BEFORE any verify/start so no code
|
|
1135
|
+
# path can start the torrent while a large missing file is wanted.
|
|
1136
|
+
if selection is not None:
|
|
1137
|
+
self._apply_selection(tid, selection, steps)
|
|
1138
|
+
self._rpc_ok("torrent-verify", {"ids": [tid]})
|
|
1139
|
+
steps.append(f"torrent-verify (ids=[{tid}]) -> success")
|
|
1140
|
+
t = self._wait_verify_complete(tid)
|
|
1141
|
+
if selection is None:
|
|
1142
|
+
done = float(t.get("percentDone") or 0.0)
|
|
1143
|
+
if done < 1.0:
|
|
1144
|
+
# FAIL CLOSED: starting would download the missing data
|
|
1145
|
+
# through the link farm into the user's original files.
|
|
1146
|
+
have = t.get("haveValid")
|
|
1147
|
+
want = t.get("sizeWhenDone")
|
|
1148
|
+
detail = (f"{have} of {want} bytes present"
|
|
1149
|
+
if isinstance(have, int) and isinstance(want, int)
|
|
1150
|
+
and want > 0
|
|
1151
|
+
else f"only {done:.1%} of the data present")
|
|
1152
|
+
tail = ("\n" + _path_blindness_hint(save_path)
|
|
1153
|
+
if _near_zero(done, have)
|
|
1154
|
+
else " (Starting would download the missing data "
|
|
1155
|
+
"into your local files.)")
|
|
1156
|
+
raise ClientError(
|
|
1157
|
+
f"verify finished but found {detail} — refusing to "
|
|
1158
|
+
"start; the torrent was left paused in Transmission and "
|
|
1159
|
+
"is NOT seeding." + tail)
|
|
1160
|
+
steps.append("verify complete: 100% of the data verified present")
|
|
1161
|
+
else:
|
|
1162
|
+
# M5 partial container: per-file gate (see _gate_selection).
|
|
1163
|
+
self._gate_selection(tid, selection, save_path)
|
|
1164
|
+
steps.append(
|
|
1165
|
+
"verify complete: every locally-expected file verified 100% "
|
|
1166
|
+
f"present ({len(selection.unwanted)} marked do-not-download, "
|
|
1167
|
+
f"{len(selection.expect_fetch)} small companion file(s) to "
|
|
1168
|
+
"fetch)")
|
|
1169
|
+
self._rpc_ok("torrent-start", {"ids": [tid]})
|
|
1170
|
+
steps.append(f"torrent-start (ids=[{tid}]) -> success")
|
|
1171
|
+
if selection is not None and selection.expect_fetch:
|
|
1172
|
+
steps.append(
|
|
1173
|
+
"the client will now fetch the small companion file(s): "
|
|
1174
|
+
+ ", ".join(selection.expect_fetch))
|
|
1175
|
+
message = ("added paused -> verified (100% present) -> started "
|
|
1176
|
+
"(torrent-start confirmed success)")
|
|
1177
|
+
if selection is not None:
|
|
1178
|
+
parts = []
|
|
1179
|
+
if selection.unwanted:
|
|
1180
|
+
parts.append(f"{len(selection.unwanted)} missing large "
|
|
1181
|
+
"file(s) marked do-not-download")
|
|
1182
|
+
if selection.expect_fetch:
|
|
1183
|
+
parts.append(f"{len(selection.expect_fetch)} small companion "
|
|
1184
|
+
"file(s) will be fetched")
|
|
1185
|
+
message = ("added paused -> files-unwanted set -> verified (all "
|
|
1186
|
+
"locally-expected files 100% present) -> started "
|
|
1187
|
+
"(torrent-start confirmed success); "
|
|
1188
|
+
+ "; ".join(parts))
|
|
1189
|
+
return AddResult(self.name, True, steps, message, False)
|
|
1190
|
+
|
|
1191
|
+
def probe_local_visibility(self) -> PathVisibilityProbe:
|
|
1192
|
+
"""S3: read Transmission's own default download dir (its perspective)
|
|
1193
|
+
and check whether that path exists on the machine llmt runs on.
|
|
1194
|
+
Best-effort: any failure -> unsupported (advisory only)."""
|
|
1195
|
+
try:
|
|
1196
|
+
sess = self._rpc("session-get", {}).get("arguments") or {}
|
|
1197
|
+
except (ClientError, requests.RequestException, ValueError) as exc:
|
|
1198
|
+
return PathVisibilityProbe(False, detail=str(exc))
|
|
1199
|
+
default = str((sess or {}).get("download-dir") or "")
|
|
1200
|
+
if not default:
|
|
1201
|
+
return PathVisibilityProbe(
|
|
1202
|
+
True, "", None, "client reported no default download dir")
|
|
1203
|
+
visible = os.path.isdir(os.path.expanduser(default))
|
|
1204
|
+
return PathVisibilityProbe(True, default, visible)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
# --------------------------------------------------------------------------- #
|
|
1208
|
+
# Fallback (no client) + test fake
|
|
1209
|
+
# --------------------------------------------------------------------------- #
|
|
1210
|
+
|
|
1211
|
+
class FallbackClient(SeedClient):
|
|
1212
|
+
"""No client reachable/configured: surface the magnet plus manual-add
|
|
1213
|
+
instructions. The instructions MUST keep the fail-closed handoff intact:
|
|
1214
|
+
add PAUSED, recheck, and resume only on a 100% recheck — an auto-start
|
|
1215
|
+
client would begin downloading before the recheck runs."""
|
|
1216
|
+
name = "fallback"
|
|
1217
|
+
|
|
1218
|
+
def add(self, *, magnet, info_hash, save_path, torrent_name="",
|
|
1219
|
+
dry_run=False, file_selection=None):
|
|
1220
|
+
magnet = validate_magnet(magnet)
|
|
1221
|
+
selection = file_selection if file_selection else None
|
|
1222
|
+
steps = ["no torrent client configured/reachable"]
|
|
1223
|
+
if selection is None:
|
|
1224
|
+
steps.append(
|
|
1225
|
+
f"to seed manually: add this magnet PAUSED (do not start it), "
|
|
1226
|
+
f"set the save path to {save_path}, then force-recheck so the "
|
|
1227
|
+
f"client discovers the existing bytes, and resume only once "
|
|
1228
|
+
f"the recheck reads 100%")
|
|
1229
|
+
return AddResult("fallback-magnet", True, steps, magnet, dry_run)
|
|
1230
|
+
# M5: this adapter cannot set priorities itself -- instructions are
|
|
1231
|
+
# its mechanism, so they must carry the same do-not-download guidance.
|
|
1232
|
+
steps.append(
|
|
1233
|
+
f"to seed manually: add this magnet PAUSED (do not start it) and "
|
|
1234
|
+
f"set the save path to {save_path}")
|
|
1235
|
+
if selection.unwanted:
|
|
1236
|
+
steps.append(
|
|
1237
|
+
"IMPORTANT (M5): BEFORE resuming, mark these missing large "
|
|
1238
|
+
'files "do not download" (priority 0 / deselect) in your '
|
|
1239
|
+
"client -- llmt never downloads model-weight files on your "
|
|
1240
|
+
"behalf: " + ", ".join(selection.unwanted) + ". With them "
|
|
1241
|
+
"deselected the torrent will seed only the files you have "
|
|
1242
|
+
"and will show as incomplete in your client.")
|
|
1243
|
+
if selection.expect_fetch:
|
|
1244
|
+
steps.append(
|
|
1245
|
+
"note (M5): these small companion files (<= 10 MiB total) "
|
|
1246
|
+
"are missing locally and WILL be downloaded by your client "
|
|
1247
|
+
f"into {save_path} once resumed: "
|
|
1248
|
+
+ ", ".join(selection.expect_fetch))
|
|
1249
|
+
steps.append(
|
|
1250
|
+
"then force-recheck so the client discovers the existing bytes, "
|
|
1251
|
+
"confirm every file you already have reads 100% complete, and "
|
|
1252
|
+
"only then resume (deselected files and the small companion "
|
|
1253
|
+
"files above will not read 100% -- that is expected)")
|
|
1254
|
+
return AddResult("fallback-magnet", True, steps, magnet, dry_run)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
class FakeClient(SeedClient):
|
|
1258
|
+
"""In-memory client for tests: records adds and asserts the recipe order."""
|
|
1259
|
+
name = "fake"
|
|
1260
|
+
|
|
1261
|
+
def __init__(self, fail: bool = False):
|
|
1262
|
+
self.added: list = []
|
|
1263
|
+
self.fail = fail
|
|
1264
|
+
|
|
1265
|
+
def add(self, *, magnet, info_hash, save_path, torrent_name="",
|
|
1266
|
+
dry_run=False, file_selection=None):
|
|
1267
|
+
validate_magnet(magnet) # exercise the same anti-injection gate
|
|
1268
|
+
if self.fail:
|
|
1269
|
+
raise ClientError("fake client add failure")
|
|
1270
|
+
seq = ["add(paused=true,skip_checking=false,autoTMM=false)"]
|
|
1271
|
+
if file_selection and file_selection.unwanted:
|
|
1272
|
+
seq.append("set-unwanted(priority=0)")
|
|
1273
|
+
seq += ["recheck", "resume"]
|
|
1274
|
+
rec = {
|
|
1275
|
+
"magnet": magnet, "info_hash": info_hash, "save_path": save_path,
|
|
1276
|
+
"torrent_name": torrent_name, "dry_run": dry_run, "sequence": seq,
|
|
1277
|
+
"file_selection": file_selection,
|
|
1278
|
+
}
|
|
1279
|
+
self.added.append(rec)
|
|
1280
|
+
return AddResult("fake", True, seq, "recorded", dry_run)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
# --------------------------------------------------------------------------- #
|
|
1284
|
+
# Selection from flags / env
|
|
1285
|
+
# --------------------------------------------------------------------------- #
|
|
1286
|
+
|
|
1287
|
+
def build_client(args) -> SeedClient:
|
|
1288
|
+
"""Choose an adapter from flags/env. Never hardcodes connection details."""
|
|
1289
|
+
kind = getattr(args, "client", None)
|
|
1290
|
+
qb_url = getattr(args, "qb_url", None) or os.environ.get("LLMT_QB_URL")
|
|
1291
|
+
tr_url = getattr(args, "tr_url", None) or os.environ.get("LLMT_TR_URL")
|
|
1292
|
+
|
|
1293
|
+
if kind == "none":
|
|
1294
|
+
return FallbackClient()
|
|
1295
|
+
|
|
1296
|
+
if kind == "qbittorrent" or (kind is None and qb_url):
|
|
1297
|
+
if not qb_url:
|
|
1298
|
+
raise ClientError("qBittorrent selected but no --qb-url / LLMT_QB_URL.")
|
|
1299
|
+
return QBittorrentClient(
|
|
1300
|
+
qb_url,
|
|
1301
|
+
getattr(args, "qb_user", None) or os.environ.get("LLMT_QB_USER"),
|
|
1302
|
+
getattr(args, "qb_pass", None) or os.environ.get("LLMT_QB_PASS"),
|
|
1303
|
+
timeout=getattr(args, "timeout", 30),
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
if kind == "transmission" or (kind is None and tr_url):
|
|
1307
|
+
if not tr_url:
|
|
1308
|
+
raise ClientError("Transmission selected but no --tr-url / LLMT_TR_URL.")
|
|
1309
|
+
return TransmissionClient(
|
|
1310
|
+
tr_url,
|
|
1311
|
+
getattr(args, "tr_user", None) or os.environ.get("LLMT_TR_USER"),
|
|
1312
|
+
getattr(args, "tr_pass", None) or os.environ.get("LLMT_TR_PASS"),
|
|
1313
|
+
timeout=getattr(args, "timeout", 30),
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
return FallbackClient()
|