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/doctor.py ADDED
@@ -0,0 +1,970 @@
1
+ """``llmt doctor`` core — diagnose why your seeds aren't seeding (WP-14.4b).
2
+
3
+ Red-team finding 2: many willing seeders are silently useless — blocked
4
+ ports, CGNAT, VPN misbinding, pathological upload caps, disabled DHT/PEX,
5
+ queueing or ratio limits that quietly stopped the torrents, wrong content
6
+ paths, non-seeding add states. ``llmt doctor`` reads the torrent client's
7
+ OWN state and settings and prints one boring, practical
8
+ PASS / WARN / FAIL / N-A line per check, with a one-line fix hint on every
9
+ non-PASS.
10
+
11
+ Honesty contract (D15): every check labels what it can actually observe.
12
+ Almost every signal here is the client's self-reported view over its local
13
+ API — "connection status: connected" means *the client says so*, not that
14
+ llmt probed your port from the internet. Where a client's API does not
15
+ expose a signal at all, the check reports N-A with the reason — never PASS.
16
+
17
+ Fail-closed: a torrent whose state this module does not recognize is NEVER
18
+ reported healthy; it FAILs with an "unknown state" diagnosis. A torrent that
19
+ is not 100% complete is NEVER healthy.
20
+
21
+ Doctor is strictly read-only diagnosis: it never adds, resumes, rechecks,
22
+ moves, hashes, or offers anything, so it cannot create a back-door around
23
+ the WP-14.4-S seed-safety invariant (only full-file SHA-256 registry
24
+ matches are ever offered for seeding, and only by ``llmt scan``).
25
+
26
+ This module builds ON TOP of the frozen WP-14.4-S adapters in ``clients.py``
27
+ (``QBittorrentClient`` / ``TransmissionClient``) without modifying them: the
28
+ read-only sources below reuse each adapter's authenticated session / RPC
29
+ plumbing additively (``_login``/``_headers``/``session`` for qBittorrent,
30
+ ``_rpc`` for Transmission).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ from dataclasses import dataclass, field
37
+ from typing import Iterable, Optional
38
+
39
+ import requests
40
+
41
+ from .clients import ClientError, QBittorrentClient, TransmissionClient
42
+ from .layout import DEFAULT_BASE_DIR, MODE_DIRECT, MODE_LINKFARM # noqa: F401
43
+ from .output import human_size, safe_text
44
+
45
+ # Check statuses.
46
+ PASS = "PASS"
47
+ WARN = "WARN"
48
+ FAIL = "FAIL"
49
+ NA = "N-A"
50
+
51
+ #: An upload cap at or below this (bytes/s) is plainly pathological for
52
+ #: seeding multi-GB models. A *reasonable* cap (e.g. 1 MB/s) must not warn.
53
+ PATHOLOGICAL_UP_CAP_BPS = 50 * 1024
54
+
55
+ #: Exit code when doctor finds at least one FAIL (0 = no failures found).
56
+ #: Chosen outside the 1-14 range already used by errors.py / clients.py.
57
+ EXIT_DOCTOR_FAIL = 15
58
+
59
+ #: Bound on per-torrent tracker fetches (one API call each on qBittorrent).
60
+ MAX_TRACKER_FETCH = 100
61
+
62
+ #: Bound on files walked when inspecting a multi-file content directory.
63
+ MAX_LAYOUT_WALK = 256
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # Snapshot data model (what a source can actually observe; None = not exposed)
68
+ # --------------------------------------------------------------------------- #
69
+
70
+ @dataclass
71
+ class TrackerStatus:
72
+ url: str
73
+ state: str # "working"|"updating"|"not-contacted"|"not-working"|"disabled"
74
+ message: str = ""
75
+
76
+
77
+ @dataclass
78
+ class TorrentHealth:
79
+ info_hash: str
80
+ name: str
81
+ raw_state: str
82
+ category: str # seeding|downloading|paused|queued|
83
+ # checking|errored|unknown
84
+ progress: Optional[float] # 0.0-1.0, None = client didn't say
85
+ save_path: str = ""
86
+ content_path: str = ""
87
+ ratio: Optional[float] = None
88
+ ratio_limit: Optional[float] = None # effective applicable limit, None=none
89
+ error_message: str = ""
90
+ trackers: Optional[list] = None # None = not fetched / not exposed
91
+
92
+ def label(self) -> str:
93
+ h = (self.info_hash or "")[:8]
94
+ return f"{safe_text(self.name) or '(unnamed)'} ({h or 'no-hash'})"
95
+
96
+
97
+ @dataclass
98
+ class ClientSnapshot:
99
+ kind: str
100
+ endpoint: str
101
+ reachable: bool
102
+ reach_error: str = ""
103
+ version: str = ""
104
+ connection_status: Optional[str] = None # qBt self-view: connected/
105
+ # firewalled/disconnected
106
+ port_open: Optional[bool] = None # Transmission daemon port-test
107
+ dht_enabled: Optional[bool] = None
108
+ pex_enabled: Optional[bool] = None
109
+ upload_limit_bps: Optional[int] = None # None = not exposed; <=0 = uncapped
110
+ bound_interface: Optional[str] = None # None = not exposed; "" = not bound
111
+ queueing_enabled: Optional[bool] = None
112
+ global_ratio_limit: Optional[float] = None # None = disabled/unknown
113
+ client_default_save_path: str = "" # client's own default save dir
114
+ # (the client's perspective); ""
115
+ # = not exposed
116
+ torrents: list = field(default_factory=list)
117
+
118
+
119
+ @dataclass
120
+ class CheckResult:
121
+ check: str
122
+ scope: str # "client" or the torrent label
123
+ status: str # PASS | WARN | FAIL | N-A
124
+ detail: str
125
+ hint: str = "" # one-line fix hint; required by convention on non-PASS
126
+
127
+ def to_dict(self) -> dict:
128
+ return {"check": self.check, "scope": self.scope,
129
+ "status": self.status, "detail": self.detail,
130
+ "hint": self.hint}
131
+
132
+
133
+ # --------------------------------------------------------------------------- #
134
+ # State normalization (fail-closed: unmapped -> "unknown", never healthy)
135
+ # --------------------------------------------------------------------------- #
136
+
137
+ _QBT_STATE_MAP = {
138
+ "uploading": "seeding", "stalledUP": "seeding", "forcedUP": "seeding",
139
+ "queuedUP": "queued", "queuedDL": "queued",
140
+ "pausedUP": "paused", "pausedDL": "paused",
141
+ "stoppedUP": "paused", "stoppedDL": "paused",
142
+ "checkingUP": "checking", "checkingDL": "checking",
143
+ "checkingResumeData": "checking", "moving": "checking",
144
+ "allocating": "downloading", "downloading": "downloading",
145
+ "stalledDL": "downloading", "forcedDL": "downloading",
146
+ "metaDL": "downloading", "forcedMetaDL": "downloading",
147
+ "error": "errored", "missingFiles": "errored",
148
+ }
149
+
150
+ _TR_STATUS_MAP = {
151
+ 0: "paused", 1: "checking", 2: "checking", 3: "queued",
152
+ 4: "downloading", 5: "queued", 6: "seeding",
153
+ }
154
+
155
+ _QBT_TRACKER_MAP = {
156
+ 0: "disabled", 1: "not-contacted", 2: "working",
157
+ 3: "updating", 4: "not-working",
158
+ }
159
+
160
+
161
+ # --------------------------------------------------------------------------- #
162
+ # Read-only sources over the frozen adapters (additive; clients.py untouched)
163
+ # --------------------------------------------------------------------------- #
164
+
165
+ class DoctorSource:
166
+ """Read-only view of one torrent client. ``snapshot`` never mutates the
167
+ client and never adds/starts/stops anything."""
168
+
169
+ kind = "base"
170
+
171
+ def snapshot(self, hashes: Optional[set] = None) -> ClientSnapshot:
172
+ raise NotImplementedError
173
+
174
+
175
+ class NullDoctorSource(DoctorSource):
176
+ """No client configured — reachability fails with a config hint."""
177
+
178
+ kind = "none"
179
+
180
+ def snapshot(self, hashes=None):
181
+ return ClientSnapshot(
182
+ kind="none", endpoint="(none)", reachable=False,
183
+ reach_error=("no torrent client configured; pass --qb-url or "
184
+ "--tr-url (or set LLMT_QB_URL / LLMT_TR_URL)"))
185
+
186
+
187
+ class QbtDoctorSource(DoctorSource):
188
+ kind = "qbittorrent"
189
+
190
+ def __init__(self, client: QBittorrentClient):
191
+ self.client = client
192
+
193
+ def _get(self, path: str, params: Optional[dict] = None):
194
+ c = self.client
195
+ r = c.session.get(f"{c.url}{path}", params=params or {},
196
+ headers=c._headers(), timeout=c.timeout)
197
+ if r.status_code != 200:
198
+ raise ClientError(f"qBittorrent GET {path} failed "
199
+ f"(HTTP {r.status_code}).")
200
+ return r
201
+
202
+ def snapshot(self, hashes=None):
203
+ snap = ClientSnapshot(kind=self.kind, endpoint=self.client.url,
204
+ reachable=False)
205
+ try:
206
+ self.client._login()
207
+ snap.version = safe_text(
208
+ self._get("/api/v2/app/version").text.strip())
209
+ prefs = self._get("/api/v2/app/preferences").json()
210
+ transfer = self._get("/api/v2/transfer/info").json()
211
+ torrents = self._get("/api/v2/torrents/info").json()
212
+ except (ClientError, requests.RequestException, ValueError) as exc:
213
+ snap.reach_error = safe_text(str(exc))
214
+ return snap
215
+ snap.reachable = True
216
+ if not isinstance(prefs, dict):
217
+ prefs = {}
218
+ if not isinstance(transfer, dict):
219
+ transfer = {}
220
+ if not isinstance(torrents, list):
221
+ torrents = []
222
+
223
+ if isinstance(prefs.get("up_limit"), (int, float)):
224
+ snap.upload_limit_bps = int(prefs["up_limit"])
225
+ if isinstance(prefs.get("dht"), bool):
226
+ snap.dht_enabled = prefs["dht"]
227
+ if isinstance(prefs.get("pex"), bool):
228
+ snap.pex_enabled = prefs["pex"]
229
+ if isinstance(prefs.get("queueing_enabled"), bool):
230
+ snap.queueing_enabled = prefs["queueing_enabled"]
231
+ if (prefs.get("max_ratio_enabled") is True
232
+ and isinstance(prefs.get("max_ratio"), (int, float))
233
+ and prefs["max_ratio"] >= 0):
234
+ snap.global_ratio_limit = float(prefs["max_ratio"])
235
+ iface = prefs.get("current_network_interface")
236
+ if isinstance(iface, str):
237
+ snap.bound_interface = safe_text(iface)
238
+ cs = transfer.get("connection_status")
239
+ if isinstance(cs, str):
240
+ snap.connection_status = safe_text(cs)
241
+ sp = prefs.get("save_path")
242
+ if isinstance(sp, str):
243
+ snap.client_default_save_path = safe_text(sp)
244
+
245
+ wanted = {h.lower() for h in hashes} if hashes else None
246
+ selected = []
247
+ for t in torrents:
248
+ if not isinstance(t, dict):
249
+ continue
250
+ th = self._torrent(t)
251
+ if wanted is not None and th.info_hash.lower() not in wanted:
252
+ continue
253
+ selected.append(th)
254
+ for th in selected[:MAX_TRACKER_FETCH]:
255
+ th.trackers = self._trackers(th.info_hash)
256
+ snap.torrents = selected
257
+ return snap
258
+
259
+ def _torrent(self, t: dict) -> TorrentHealth:
260
+ raw = t.get("state") if isinstance(t.get("state"), str) else ""
261
+ progress = t.get("progress")
262
+ rl = t.get("ratio_limit")
263
+ eff = None
264
+ if isinstance(rl, (int, float)):
265
+ if rl >= 0:
266
+ eff = float(rl) # per-torrent limit
267
+ # -2 = "use global" resolved by the ratio check via the snapshot;
268
+ # -1 = explicitly unlimited for this torrent.
269
+ elif int(rl) == -2:
270
+ eff = -2.0 # sentinel: defer to global
271
+ return TorrentHealth(
272
+ info_hash=str(t.get("hash") or ""),
273
+ name=str(t.get("name") or ""),
274
+ raw_state=safe_text(raw),
275
+ category=_QBT_STATE_MAP.get(raw, "unknown"),
276
+ progress=float(progress)
277
+ if isinstance(progress, (int, float)) else None,
278
+ save_path=str(t.get("save_path") or ""),
279
+ content_path=str(t.get("content_path") or ""),
280
+ ratio=float(t.get("ratio"))
281
+ if isinstance(t.get("ratio"), (int, float)) else None,
282
+ ratio_limit=eff,
283
+ )
284
+
285
+ def _trackers(self, info_hash: str) -> Optional[list]:
286
+ if not info_hash:
287
+ return None
288
+ try:
289
+ rows = self._get("/api/v2/torrents/trackers",
290
+ params={"hash": info_hash}).json()
291
+ except (ClientError, requests.RequestException, ValueError):
292
+ return None
293
+ out = []
294
+ if not isinstance(rows, list):
295
+ return None
296
+ for r in rows:
297
+ if not isinstance(r, dict):
298
+ continue
299
+ url = str(r.get("url") or "")
300
+ if url.startswith("**"):
301
+ continue # DHT/PEX/LSD pseudo-entries
302
+ state = _QBT_TRACKER_MAP.get(r.get("status"), "not-working")
303
+ out.append(TrackerStatus(url=safe_text(url), state=state,
304
+ message=safe_text(str(r.get("msg") or ""))))
305
+ return out
306
+
307
+
308
+ class TransmissionDoctorSource(DoctorSource):
309
+ kind = "transmission"
310
+
311
+ _FIELDS = ["id", "name", "hashString", "status", "percentDone", "error",
312
+ "errorString", "downloadDir", "uploadRatio", "seedRatioMode",
313
+ "seedRatioLimit", "trackerStats", "isFinished"]
314
+
315
+ def __init__(self, client: TransmissionClient):
316
+ self.client = client
317
+
318
+ def snapshot(self, hashes=None):
319
+ snap = ClientSnapshot(kind=self.kind, endpoint=self.client.url,
320
+ reachable=False)
321
+ try:
322
+ sess = self.client._rpc("session-get", {}).get("arguments") or {}
323
+ tor = (self.client._rpc(
324
+ "torrent-get", {"fields": self._FIELDS}).get("arguments")
325
+ or {}).get("torrents") or []
326
+ except (ClientError, requests.RequestException, ValueError) as exc:
327
+ snap.reach_error = safe_text(str(exc))
328
+ return snap
329
+ snap.reachable = True
330
+ if not isinstance(sess, dict):
331
+ sess = {}
332
+ snap.version = safe_text(str(sess.get("version") or ""))
333
+ dd = sess.get("download-dir")
334
+ if isinstance(dd, str):
335
+ snap.client_default_save_path = safe_text(dd)
336
+ if isinstance(sess.get("dht-enabled"), bool):
337
+ snap.dht_enabled = sess["dht-enabled"]
338
+ if isinstance(sess.get("pex-enabled"), bool):
339
+ snap.pex_enabled = sess["pex-enabled"]
340
+ if isinstance(sess.get("speed-limit-up-enabled"), bool):
341
+ if not sess["speed-limit-up-enabled"]:
342
+ snap.upload_limit_bps = 0
343
+ elif isinstance(sess.get("speed-limit-up"), (int, float)):
344
+ # Transmission expresses this in kB/s (SI).
345
+ snap.upload_limit_bps = int(sess["speed-limit-up"]) * 1000
346
+ qflags = [sess.get("seed-queue-enabled"),
347
+ sess.get("download-queue-enabled")]
348
+ if any(isinstance(q, bool) for q in qflags):
349
+ snap.queueing_enabled = any(q is True for q in qflags)
350
+ if (sess.get("seedRatioLimited") is True
351
+ and isinstance(sess.get("seedRatioLimit"), (int, float))):
352
+ snap.global_ratio_limit = float(sess["seedRatioLimit"])
353
+ # Interface binding is not exposed over the RPC -> stays None (N-A).
354
+ try:
355
+ pt = self.client._rpc("port-test", {}).get("arguments") or {}
356
+ if isinstance(pt.get("port-is-open"), bool):
357
+ snap.port_open = pt["port-is-open"]
358
+ except (ClientError, requests.RequestException, ValueError):
359
+ snap.port_open = None
360
+
361
+ wanted = {h.lower() for h in hashes} if hashes else None
362
+ for t in tor:
363
+ if not isinstance(t, dict):
364
+ continue
365
+ th = self._torrent(t, snap)
366
+ if wanted is not None and th.info_hash.lower() not in wanted:
367
+ continue
368
+ snap.torrents.append(th)
369
+ return snap
370
+
371
+ def _torrent(self, t: dict, snap: ClientSnapshot) -> TorrentHealth:
372
+ status = t.get("status")
373
+ category = _TR_STATUS_MAP.get(status, "unknown") \
374
+ if isinstance(status, int) else "unknown"
375
+ err = t.get("error")
376
+ error_message = ""
377
+ if isinstance(err, int) and err != 0:
378
+ category = "errored"
379
+ error_message = safe_text(str(t.get("errorString") or f"error {err}"))
380
+ mode = t.get("seedRatioMode")
381
+ eff = None
382
+ if mode == 1 and isinstance(t.get("seedRatioLimit"), (int, float)):
383
+ eff = float(t["seedRatioLimit"])
384
+ elif mode == 0:
385
+ eff = -2.0 # defer to global
386
+ # mode 2 = unlimited for this torrent -> None
387
+ name = str(t.get("name") or "")
388
+ ddir = str(t.get("downloadDir") or "")
389
+ content = os.path.join(ddir, name) if (ddir and name) else ""
390
+ progress = t.get("percentDone")
391
+ trackers = []
392
+ for ts in (t.get("trackerStats") or []):
393
+ if not isinstance(ts, dict):
394
+ continue
395
+ if ts.get("lastAnnounceSucceeded") is True:
396
+ state = "working"
397
+ elif ts.get("hasAnnounced") is False:
398
+ state = "not-contacted"
399
+ else:
400
+ state = "not-working"
401
+ trackers.append(TrackerStatus(
402
+ url=safe_text(str(ts.get("announce") or ts.get("host") or "")),
403
+ state=state,
404
+ message=safe_text(str(ts.get("lastAnnounceResult") or ""))))
405
+ return TorrentHealth(
406
+ info_hash=str(t.get("hashString") or ""),
407
+ name=name,
408
+ raw_state=f"status={status}" + (f" error={err}" if error_message else ""),
409
+ category=category,
410
+ progress=float(progress)
411
+ if isinstance(progress, (int, float)) else None,
412
+ save_path=ddir,
413
+ content_path=content,
414
+ ratio=float(t.get("uploadRatio"))
415
+ if isinstance(t.get("uploadRatio"), (int, float)) else None,
416
+ ratio_limit=eff,
417
+ error_message=error_message,
418
+ trackers=trackers,
419
+ )
420
+
421
+
422
+ # --------------------------------------------------------------------------- #
423
+ # Checks (pure functions over a ClientSnapshot; unit-testable, no network)
424
+ # --------------------------------------------------------------------------- #
425
+
426
+ _CLIENT_CHECKS = ("client-reachable", "port-reachability", "dht", "pex",
427
+ "upload-cap", "vpn-bind", "queueing", "ratio-limit",
428
+ "layout-base-visible")
429
+
430
+
431
+ def _effective_ratio_limit(t: TorrentHealth, snap: ClientSnapshot):
432
+ if t.ratio_limit is None:
433
+ return None
434
+ if t.ratio_limit == -2.0:
435
+ return snap.global_ratio_limit
436
+ return t.ratio_limit
437
+
438
+
439
+ def _under(base: str, path: str) -> bool:
440
+ if not base or not path:
441
+ return False
442
+ base = os.path.abspath(os.path.expanduser(base))
443
+ path = os.path.abspath(path)
444
+ return path == base or path.startswith(base + os.sep)
445
+
446
+
447
+ def run_checks(snap: ClientSnapshot,
448
+ layout_base: Optional[str] = None) -> list:
449
+ """All doctor checks over one snapshot. Read-only; touches only the local
450
+ filesystem (lstat/exists) for the layout checks."""
451
+ layout_base = os.path.abspath(
452
+ os.path.expanduser(layout_base or DEFAULT_BASE_DIR))
453
+ out: list = []
454
+
455
+ # 1. client reachable ---------------------------------------------------
456
+ if not snap.reachable:
457
+ out.append(CheckResult(
458
+ "client-reachable", "client", FAIL,
459
+ f"{snap.kind} at {safe_text(snap.endpoint)} did not respond: "
460
+ f"{snap.reach_error}",
461
+ "check the WebUI/RPC URL, credentials, and that the client is "
462
+ "running (--qb-url/--tr-url or LLMT_QB_URL/LLMT_TR_URL)"))
463
+ for check in _CLIENT_CHECKS[1:]:
464
+ out.append(CheckResult(check, "client", NA,
465
+ "client unreachable; check not run",
466
+ "fix client-reachable first"))
467
+ return out
468
+ out.append(CheckResult(
469
+ "client-reachable", "client", PASS,
470
+ f"{snap.kind} {snap.version or '(unknown version)'} responded at "
471
+ f"{safe_text(snap.endpoint)}"))
472
+
473
+ # 2. port reachability (client's own view; NOT an independent probe) ----
474
+ if snap.port_open is not None:
475
+ if snap.port_open:
476
+ out.append(CheckResult(
477
+ "port-reachability", "client", PASS,
478
+ "the daemon's own port-test reports the peer port OPEN "
479
+ "(tested by the daemon, not independently verified by llmt)"))
480
+ else:
481
+ out.append(CheckResult(
482
+ "port-reachability", "client", FAIL,
483
+ "the daemon's own port-test reports the peer port CLOSED",
484
+ "forward the listen port on your router/VPN; CGNAT or a VPN "
485
+ "without port-forwarding leaves you connectable-out only"))
486
+ elif snap.connection_status is not None:
487
+ cs = snap.connection_status
488
+ if cs == "connected":
489
+ out.append(CheckResult(
490
+ "port-reachability", "client", PASS,
491
+ "client self-reports connection status 'connected' (the "
492
+ "client's own view — llmt did not probe the port from "
493
+ "outside)"))
494
+ elif cs == "firewalled":
495
+ out.append(CheckResult(
496
+ "port-reachability", "client", FAIL,
497
+ "client self-reports connection status 'firewalled' — "
498
+ "inbound peers cannot reach you",
499
+ "forward the listen port (router/VPN port-forwarding); "
500
+ "CGNAT needs a VPN or seedbox that supports forwarding"))
501
+ elif cs == "disconnected":
502
+ out.append(CheckResult(
503
+ "port-reachability", "client", FAIL,
504
+ "client self-reports connection status 'disconnected'",
505
+ "check the client's network binding / VPN state"))
506
+ else:
507
+ out.append(CheckResult(
508
+ "port-reachability", "client", WARN,
509
+ f"client reports unrecognized connection status "
510
+ f"'{safe_text(cs)}' — not assumed healthy",
511
+ "inspect the client's connection status manually"))
512
+ else:
513
+ out.append(CheckResult(
514
+ "port-reachability", "client", NA,
515
+ "this client's API exposes no port/connection signal llmt can "
516
+ "read", "test the port manually (e.g. the client's built-in "
517
+ "port test or your tracker's peer view)"))
518
+
519
+ # 3./4. DHT / PEX -------------------------------------------------------
520
+ for check, val in (("dht", snap.dht_enabled), ("pex", snap.pex_enabled)):
521
+ label = check.upper()
522
+ if val is True:
523
+ out.append(CheckResult(check, "client", PASS,
524
+ f"{label} enabled (per client settings)"))
525
+ elif val is False:
526
+ out.append(CheckResult(
527
+ check, "client", WARN,
528
+ f"{label} is disabled in the client settings",
529
+ f"enable {label} unless you seed private trackers only; "
530
+ f"without it many peers will never discover your seed"))
531
+ else:
532
+ out.append(CheckResult(
533
+ check, "client", NA,
534
+ f"this client's API did not expose the {label} setting",
535
+ f"verify {label} is on in the client's options"))
536
+
537
+ # 5. upload cap ----------------------------------------------------------
538
+ cap = snap.upload_limit_bps
539
+ if cap is None:
540
+ out.append(CheckResult(
541
+ "upload-cap", "client", NA,
542
+ "this client's API did not expose the global upload limit",
543
+ "check the client's global upload limit manually"))
544
+ elif cap <= 0:
545
+ out.append(CheckResult("upload-cap", "client", PASS,
546
+ "no global upload cap set"))
547
+ elif cap <= PATHOLOGICAL_UP_CAP_BPS:
548
+ out.append(CheckResult(
549
+ "upload-cap", "client", FAIL,
550
+ f"global upload cap is {human_size(cap)}/s — effectively "
551
+ f"useless for seeding multi-GB models",
552
+ "raise or remove the global upload limit (and check "
553
+ "alternative/scheduled speed limits)"))
554
+ else:
555
+ out.append(CheckResult(
556
+ "upload-cap", "client", PASS,
557
+ f"global upload cap {human_size(cap)}/s is a sane, "
558
+ f"deliberate limit (note: llmt cannot see scheduler/alternative "
559
+ f"limits on every client)"))
560
+
561
+ # 6. VPN bind ------------------------------------------------------------
562
+ iface = snap.bound_interface
563
+ if iface is None:
564
+ out.append(CheckResult(
565
+ "vpn-bind", "client", NA,
566
+ "this client's API does not expose interface binding",
567
+ "if you bind the client to a VPN interface, verify it in the "
568
+ "client's network options"))
569
+ elif iface == "":
570
+ out.append(CheckResult(
571
+ "vpn-bind", "client", PASS,
572
+ "client is not bound to a specific network interface "
573
+ "(per client settings)"))
574
+ else:
575
+ connected = (snap.connection_status == "connected"
576
+ or snap.port_open is True)
577
+ broken = (snap.connection_status in ("disconnected", "firewalled")
578
+ or snap.port_open is False)
579
+ if connected:
580
+ out.append(CheckResult(
581
+ "vpn-bind", "client", PASS,
582
+ f"bound to interface '{safe_text(iface)}' and the client "
583
+ f"reports itself connected — llmt cannot verify this is the "
584
+ f"interface you intended (check it is your VPN)"))
585
+ elif broken:
586
+ out.append(CheckResult(
587
+ "vpn-bind", "client", FAIL,
588
+ f"bound to interface '{safe_text(iface)}' while the client "
589
+ f"reports itself not connected — a stale or misbound VPN "
590
+ f"interface silently stops all seeding",
591
+ "confirm the interface still exists and the VPN is up "
592
+ "(qBittorrent: Options > Advanced > Network interface)"))
593
+ else:
594
+ out.append(CheckResult(
595
+ "vpn-bind", "client", WARN,
596
+ f"bound to interface '{safe_text(iface)}'; llmt cannot "
597
+ f"confirm from the API whether that interface is up",
598
+ "verify the bound interface matches your active VPN"))
599
+
600
+ # 7. queueing interference ----------------------------------------------
601
+ queued = [t for t in snap.torrents if t.category == "queued"]
602
+ if snap.queueing_enabled is None and not queued:
603
+ out.append(CheckResult(
604
+ "queueing", "client", NA,
605
+ "this client's API did not expose the queueing setting and no "
606
+ "examined torrent is queued",
607
+ "check the client's queueing/active-torrent limits manually"))
608
+ elif queued:
609
+ out.append(CheckResult(
610
+ "queueing", "client", FAIL,
611
+ f"{len(queued)} examined torrent(s) are queue-stalled "
612
+ f"(waiting, not seeding): "
613
+ + ", ".join(t.label() for t in queued[:5]),
614
+ "raise the active-torrents / active-uploads limits or disable "
615
+ "queueing so complete torrents actually seed"))
616
+ elif snap.queueing_enabled:
617
+ out.append(CheckResult(
618
+ "queueing", "client", PASS,
619
+ "queueing is enabled but no examined torrent is currently "
620
+ "queued (per client state)"))
621
+ else:
622
+ out.append(CheckResult("queueing", "client", PASS,
623
+ "torrent queueing disabled"))
624
+
625
+ # 8. ratio-limit interference ---------------------------------------------
626
+ stopped_by_ratio = []
627
+ for t in snap.torrents:
628
+ lim = _effective_ratio_limit(t, snap)
629
+ if (lim is not None and lim >= 0 and t.category == "paused"
630
+ and t.progress is not None and t.progress >= 1.0
631
+ and t.ratio is not None and t.ratio >= lim):
632
+ stopped_by_ratio.append(t)
633
+ if stopped_by_ratio:
634
+ out.append(CheckResult(
635
+ "ratio-limit", "client", FAIL,
636
+ f"{len(stopped_by_ratio)} complete torrent(s) appear stopped by "
637
+ f"a share-ratio limit (ratio >= limit and paused): "
638
+ + ", ".join(t.label() for t in stopped_by_ratio[:5]),
639
+ "raise/remove the share-ratio limit (or set its action to "
640
+ "nothing) so finished torrents keep seeding"))
641
+ elif snap.global_ratio_limit is not None:
642
+ out.append(CheckResult(
643
+ "ratio-limit", "client", PASS,
644
+ f"a global share-ratio limit of {snap.global_ratio_limit:g} is "
645
+ f"set but has not stopped any examined torrent (per client "
646
+ f"state)"))
647
+ else:
648
+ out.append(CheckResult(
649
+ "ratio-limit", "client", PASS,
650
+ "no global share-ratio limit reported stopping anything"))
651
+
652
+ # layout-base visibility from the client's filesystem namespace (S3):
653
+ # if the client's own default save path does not exist on the machine
654
+ # doctor runs on, llmt and the client are on different filesystems, so a
655
+ # link farm under layout_base (a local path) may be invisible to the
656
+ # client and a seed handoff would recheck to 0% and fail closed.
657
+ default_path = snap.client_default_save_path
658
+ if not default_path:
659
+ out.append(CheckResult(
660
+ "layout-base-visible", "client", NA,
661
+ "the client did not report a default save path, so llmt cannot "
662
+ "tell whether it shares a filesystem with this machine",
663
+ "run 'llmt doctor' on the machine the client runs on"))
664
+ elif os.path.isdir(os.path.expanduser(default_path)):
665
+ out.append(CheckResult(
666
+ "layout-base-visible", "client", PASS,
667
+ f"the client's own default save path "
668
+ f"({safe_text(default_path)}) exists on this machine, so an llmt "
669
+ f"link farm under {safe_text(layout_base)} should be visible to "
670
+ f"the client (filesystem-namespace check only, not a per-torrent "
671
+ f"probe)"))
672
+ else:
673
+ out.append(CheckResult(
674
+ "layout-base-visible", "client", WARN,
675
+ f"the client's own default save path "
676
+ f"({safe_text(default_path)}) does not exist on the machine llmt "
677
+ f"is running on -- the client looks dockerized or remote "
678
+ f"(different filesystem). An llmt link farm under "
679
+ f"{safe_text(layout_base)} is a path on THIS machine, so the "
680
+ f"client may not be able to see it and a seed handoff could "
681
+ f"recheck to 0% and fail closed",
682
+ "point 'llmt scan --layout-base' at a directory the client can "
683
+ "see (one of its mounted volumes), or run llmt on the same "
684
+ "host/namespace as the client"))
685
+
686
+ # Per-torrent checks -----------------------------------------------------
687
+ for t in snap.torrents:
688
+ out.extend(_torrent_checks(t, snap, layout_base))
689
+ return out
690
+
691
+
692
+ def _torrent_checks(t: TorrentHealth, snap: ClientSnapshot,
693
+ layout_base: str) -> list:
694
+ out = [_check_complete_seeding(t), _check_trackers(t)]
695
+ out.extend(_check_layout(t, layout_base))
696
+ return out
697
+
698
+
699
+ def _check_complete_seeding(t: TorrentHealth) -> CheckResult:
700
+ scope = t.label()
701
+ check = "complete-and-seeding"
702
+ if t.category == "errored":
703
+ return CheckResult(
704
+ check, scope, FAIL,
705
+ f"client reports an error state ({t.raw_state}): "
706
+ f"{t.error_message or 'no message'}",
707
+ "open the torrent in the client; if files went missing, "
708
+ "re-run 'llmt scan' to rebuild the layout, then force-recheck")
709
+ if t.progress is None:
710
+ return CheckResult(
711
+ check, scope, FAIL,
712
+ "client did not report completion progress — fail-closed, not "
713
+ "assumed complete",
714
+ "force-recheck the torrent in the client and inspect its state")
715
+ if t.progress < 1.0:
716
+ return CheckResult(
717
+ check, scope, FAIL,
718
+ f"only {t.progress * 100:.1f}% complete per the client — a "
719
+ f"partial file is never seedable and is NOT healthy",
720
+ "let the download finish, or if the data exists on disk "
721
+ "force-recheck (llmt scan adds paused -> recheck -> resume)")
722
+ if t.category == "seeding":
723
+ return CheckResult(
724
+ check, scope, PASS,
725
+ f"100% complete and state '{t.raw_state}' — the client reports "
726
+ f"it as actively offered to peers (upload activity itself "
727
+ f"depends on demand)")
728
+ if t.category == "checking":
729
+ return CheckResult(
730
+ check, scope, WARN,
731
+ f"recheck/verification in progress (state '{t.raw_state}')",
732
+ "wait for the recheck to finish, then confirm it resumes "
733
+ "seeding")
734
+ if t.category == "paused":
735
+ return CheckResult(
736
+ check, scope, FAIL,
737
+ f"complete but paused/stopped (state '{t.raw_state}') — "
738
+ f"seeding nothing",
739
+ "resume/start the torrent (check ratio limits and queueing if "
740
+ "it re-pauses itself)")
741
+ if t.category == "queued":
742
+ return CheckResult(
743
+ check, scope, FAIL,
744
+ f"complete but queue-stalled (state '{t.raw_state}')",
745
+ "raise the client's active-torrent limits (see the queueing "
746
+ "check)")
747
+ if t.category == "downloading":
748
+ return CheckResult(
749
+ check, scope, WARN,
750
+ f"client says 100% but state '{t.raw_state}' is a download "
751
+ f"state — inconsistent, not assumed seeding",
752
+ "force-recheck the torrent so the client re-evaluates it")
753
+ return CheckResult(
754
+ check, scope, FAIL,
755
+ f"unrecognized client state '{t.raw_state}' — fail-closed: an "
756
+ f"unknown state is never reported healthy",
757
+ "inspect the torrent in the client; force-recheck and resume if "
758
+ "complete")
759
+
760
+
761
+ def _check_trackers(t: TorrentHealth) -> CheckResult:
762
+ scope = t.label()
763
+ check = "tracker-announce"
764
+ if t.trackers is None:
765
+ return CheckResult(
766
+ check, scope, NA,
767
+ "tracker status not available from the client for this torrent",
768
+ "check the torrent's Trackers tab in the client")
769
+ real = [tr for tr in t.trackers if tr.state != "disabled"]
770
+ if not real:
771
+ return CheckResult(
772
+ check, scope, NA,
773
+ "no trackers on this torrent (DHT/PEX only) — announce not "
774
+ "applicable",
775
+ "keep DHT/PEX enabled or peers cannot find this seed")
776
+ working = [tr for tr in real if tr.state == "working"]
777
+ dead = [tr for tr in real if tr.state == "not-working"]
778
+ if working:
779
+ if dead:
780
+ named = "; ".join(
781
+ tr.url + (f" ({tr.message})" if tr.message else "")
782
+ for tr in dead)[:300]
783
+ return CheckResult(
784
+ check, scope, WARN,
785
+ f"{len(working)}/{len(real)} tracker tier(s) working, but "
786
+ f"{len(dead)} not working FROM THIS MACHINE: {named} — often a "
787
+ f"UDP tier blocked by a container/NAT/firewall while an HTTP "
788
+ f"tier is fine (per the client's last announce, not "
789
+ f"independently verified by llmt); seeding still works over "
790
+ f"the working tier(s)",
791
+ "if the unreachable tier is UDP, allow outbound UDP to it "
792
+ "from your network/container; seeding continues over the "
793
+ "working tier meanwhile")
794
+ return CheckResult(
795
+ check, scope, PASS,
796
+ f"{len(working)}/{len(real)} tracker(s) report 'working' — as "
797
+ f"reported by the client's last announce, not independently "
798
+ f"verified by llmt")
799
+ pending = [tr for tr in real if tr.state in ("not-contacted", "updating")]
800
+ if pending and len(pending) == len(real):
801
+ return CheckResult(
802
+ check, scope, WARN,
803
+ "trackers not contacted yet (per the client) — too early to "
804
+ "call the announce healthy",
805
+ "wait for the first announce, then re-run doctor")
806
+ msgs = "; ".join(tr.message for tr in real if tr.message)[:200]
807
+ return CheckResult(
808
+ check, scope, FAIL,
809
+ "no tracker reports a successful announce (per the client)"
810
+ + (f" — client says: {msgs}" if msgs else ""),
811
+ "check the tracker URL and that your network/VPN allows the "
812
+ "announce; a failed announce means the tracker never lists you")
813
+
814
+
815
+ def _check_layout(t: TorrentHealth, layout_base: str) -> list:
816
+ """Content-path / layout sanity + duplicate-copy check.
817
+
818
+ Uses the frozen WP-14.2b plan semantics: llmt-planned torrents keep
819
+ ``savepath == plan.save_path`` (autoTMM off, QBITTORRENT_ADD_RECIPE) and,
820
+ in link-farm mode, present the bytes through links under the layout base
821
+ that resolve to the user's original file. Doctor can only inspect the
822
+ filesystem it runs on: when the client runs on another host the paths
823
+ are reported N-A, never PASS.
824
+ """
825
+ scope = t.label()
826
+ out = []
827
+ path = t.content_path or (
828
+ os.path.join(t.save_path, t.name) if t.save_path and t.name else "")
829
+ in_farm = _under(layout_base, t.save_path) or _under(layout_base, path)
830
+
831
+ if not path:
832
+ out.append(CheckResult(
833
+ "layout-path", scope, NA,
834
+ "client did not report a content path",
835
+ "verify the torrent's save path in the client"))
836
+ out.append(CheckResult(
837
+ "duplicate-copy", scope, NA,
838
+ "no content path to inspect", ""))
839
+ return out
840
+
841
+ exists = os.path.lexists(path)
842
+ if not exists:
843
+ if in_farm:
844
+ out.append(CheckResult(
845
+ "layout-path", scope, FAIL,
846
+ f"expected llmt link-farm path is missing: "
847
+ f"{safe_text(path)}",
848
+ "re-run 'llmt scan' to rebuild the link farm, then "
849
+ "force-recheck in the client"))
850
+ else:
851
+ out.append(CheckResult(
852
+ "layout-path", scope, NA,
853
+ f"path {safe_text(path)} is not visible from this machine "
854
+ f"— if the client runs on another host that may be fine; "
855
+ f"doctor can only inspect the local filesystem",
856
+ "run 'llmt doctor' on the machine the client runs on"))
857
+ out.append(CheckResult(
858
+ "duplicate-copy", scope, NA,
859
+ "content path not inspectable from this machine", ""))
860
+ return out
861
+
862
+ broken = _broken_links(path)
863
+ if broken:
864
+ out.append(CheckResult(
865
+ "layout-path", scope, FAIL,
866
+ f"broken link(s) in the content path — the original file was "
867
+ f"moved or deleted: {safe_text(broken[0])}"
868
+ + (f" (+{len(broken) - 1} more)" if len(broken) > 1 else ""),
869
+ "restore/relocate the original file or re-run 'llmt scan', "
870
+ "then force-recheck"))
871
+ elif t.save_path and t.content_path and not _under(t.save_path,
872
+ t.content_path):
873
+ out.append(CheckResult(
874
+ "layout-path", scope, WARN,
875
+ f"content path {safe_text(t.content_path)} lies outside the "
876
+ f"save path {safe_text(t.save_path)} — the client appears to "
877
+ f"have relocated it (autoTMM?), which breaks the frozen "
878
+ f"add-recipe assumption (savepath == plan.save_path)",
879
+ "disable Automatic Torrent Management for llmt-added torrents "
880
+ "and move the torrent back to its planned save path"))
881
+ else:
882
+ via = (f" via the llmt link farm ({MODE_LINKFARM} mode under "
883
+ f"{safe_text(layout_base)})" if in_farm
884
+ else f" ({MODE_DIRECT}: the client reads the file in place)")
885
+ out.append(CheckResult(
886
+ "layout-path", scope, PASS,
887
+ f"content present at {safe_text(path)}{via} — presence and "
888
+ f"link integrity only; byte-correctness is the client "
889
+ f"recheck's job, not doctor's"))
890
+
891
+ out.append(_check_duplicate(path, scope, in_farm))
892
+ return out
893
+
894
+
895
+ def _broken_links(path: str) -> list:
896
+ """Symlinks under ``path`` (bounded walk) whose targets are gone."""
897
+ broken = []
898
+ if os.path.islink(path) and not os.path.exists(path):
899
+ return [path]
900
+ if os.path.isdir(path):
901
+ seen = 0
902
+ for root, dirs, files in os.walk(path):
903
+ for nm in files + dirs:
904
+ if seen >= MAX_LAYOUT_WALK:
905
+ return broken
906
+ seen += 1
907
+ p = os.path.join(root, nm)
908
+ if os.path.islink(p) and not os.path.exists(p):
909
+ broken.append(p)
910
+ return broken
911
+
912
+
913
+ def _check_duplicate(path: str, scope: str, in_farm: bool) -> CheckResult:
914
+ check = "duplicate-copy"
915
+ try:
916
+ target = path
917
+ if os.path.isdir(path) and not os.path.islink(path):
918
+ # inspect the first regular file found (bounded)
919
+ found = None
920
+ seen = 0
921
+ for root, _dirs, files in os.walk(path):
922
+ for nm in files:
923
+ seen += 1
924
+ if seen > MAX_LAYOUT_WALK:
925
+ break
926
+ found = os.path.join(root, nm)
927
+ break
928
+ if found or seen > MAX_LAYOUT_WALK:
929
+ break
930
+ if not found:
931
+ return CheckResult(check, scope, NA,
932
+ "no files found under the content path "
933
+ "to inspect", "")
934
+ target = found
935
+ st = os.lstat(target)
936
+ except OSError as exc:
937
+ return CheckResult(check, scope, NA,
938
+ f"could not stat content: {safe_text(str(exc))}",
939
+ "")
940
+ if os.path.islink(target):
941
+ return CheckResult(
942
+ check, scope, PASS,
943
+ "content is presented through a symlink — no duplicate copy of "
944
+ "the bytes exists")
945
+ if getattr(st, "st_nlink", 1) > 1:
946
+ return CheckResult(
947
+ check, scope, PASS,
948
+ "content is a hardlink (link count > 1) — no duplicate copy of "
949
+ "the bytes exists")
950
+ if in_farm:
951
+ return CheckResult(
952
+ check, scope, WARN,
953
+ "a real (non-linked) copy sits inside the llmt layout "
954
+ "directory — the link farm should reference your original "
955
+ "file, not duplicate it",
956
+ "re-run 'llmt scan' so the layout is rebuilt as links, then "
957
+ "remove the duplicate copy")
958
+ return CheckResult(
959
+ check, scope, PASS,
960
+ "direct use of the file in place — no llmt-created duplicate "
961
+ "(llmt cannot rule out copies elsewhere on disk)")
962
+
963
+
964
+ def summarize(results: Iterable) -> dict:
965
+ counts = {PASS: 0, WARN: 0, FAIL: 0, NA: 0}
966
+ for r in results:
967
+ counts[r.status] = counts.get(r.status, 0) + 1
968
+ return {"pass": counts[PASS], "warn": counts[WARN],
969
+ "fail": counts[FAIL], "na": counts[NA],
970
+ "ok": counts[FAIL] == 0}