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/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()