llmt-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llmt/__init__.py +7 -0
- llmt/api.py +138 -0
- llmt/cli.py +562 -0
- llmt/clients.py +1316 -0
- llmt/data/manifest-pubkey.json +7 -0
- llmt/doctor.py +970 -0
- llmt/doctorcmd.py +142 -0
- llmt/download.py +321 -0
- llmt/errors.py +132 -0
- llmt/layout.py +676 -0
- llmt/manifest.py +314 -0
- llmt/matcher.py +358 -0
- llmt/models.py +130 -0
- llmt/output.py +51 -0
- llmt/permissions.py +154 -0
- llmt/scancmd.py +556 -0
- llmt/scanner.py +328 -0
- llmt/telemetry/__init__.py +15 -0
- llmt/telemetry/artifacts.py +217 -0
- llmt/telemetry/beat.py +221 -0
- llmt/telemetry/cmd.py +358 -0
- llmt/telemetry/collect.py +252 -0
- llmt/telemetry/config.py +237 -0
- llmt/telemetry/consent.py +57 -0
- llmt/telemetry/errors.py +41 -0
- llmt/telemetry/wire.py +194 -0
- llmt/verify.py +129 -0
- llmt_cli-0.2.0.dist-info/METADATA +307 -0
- llmt_cli-0.2.0.dist-info/RECORD +33 -0
- llmt_cli-0.2.0.dist-info/WHEEL +5 -0
- llmt_cli-0.2.0.dist-info/entry_points.txt +2 -0
- llmt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmt_cli-0.2.0.dist-info/top_level.txt +1 -0
llmt/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}
|