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/scancmd.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""``llmt scan`` — "you already have these models; seed them."
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Resolve scan roots (auto-detected known layouts, or explicit --path).
|
|
5
|
+
2. Prefilter + hash local candidate files (full-file SHA-256).
|
|
6
|
+
3. Look them up against the registry (POST /api/artifacts/lookup, <=100/chunk).
|
|
7
|
+
4. Offer only catalog-matched, seedable matches (the local file's
|
|
8
|
+
full-file SHA-256 equals a SHA-256 the catalog returned over the
|
|
9
|
+
TLS lookup — this is NOT an Ed25519 signed-manifest check).
|
|
10
|
+
5. On accept: plan the layout (link farm, no copy/move) via the frozen
|
|
11
|
+
plan_layout/apply_plan, then add to the user's client (qBit/Transmission/
|
|
12
|
+
fallback) with the add-paused -> recheck -> resume recipe.
|
|
13
|
+
|
|
14
|
+
No telemetry, no ledger, no account, no heartbeat (DR-27): the only network the
|
|
15
|
+
MVP performs is the anonymous public lookup plus whatever a normal BitTorrent
|
|
16
|
+
client does once the torrent is added.
|
|
17
|
+
|
|
18
|
+
Multi-file containers (M5, size-aware Option C, Pat 2026-07-13): before a
|
|
19
|
+
multi-file torrent is handed to a client, scan reads the torrent's full file
|
|
20
|
+
list from its Ed25519-signed manifest and checks which files are already
|
|
21
|
+
present at the layout. Missing license/README-class siblings (<= 10 MiB
|
|
22
|
+
cumulatively per container) stay wanted and are fetched by the client, with a
|
|
23
|
+
one-line notice naming them and the directory they land in; any larger
|
|
24
|
+
missing file is marked do-not-download (qBt filePrio 0 / Transmission
|
|
25
|
+
files-unwanted) BEFORE the torrent can resume, with a warning that the
|
|
26
|
+
torrent will seed only what is present and will show as incomplete. Model
|
|
27
|
+
weights are never re-downloaded.
|
|
28
|
+
|
|
29
|
+
Trust level (honest scoping): a scan "match" means the local file's
|
|
30
|
+
full-file SHA-256 equals a SHA-256 the catalog returned over the TLS-only,
|
|
31
|
+
unauthenticated lookup. Unlike ``pull``/``verify``/``seed`` (Fix M2), scan
|
|
32
|
+
runs NO Ed25519 signed-manifest verification, so it reports matches as
|
|
33
|
+
"catalog-matched", never "verified". Signed-manifest gating for ``scan``
|
|
34
|
+
is a roadmap item.
|
|
35
|
+
|
|
36
|
+
Exit status (D15, Fix 11): scan processes every accepted container to
|
|
37
|
+
completion ("make -k") -- a handoff that fails or cannot be verified
|
|
38
|
+
(``AddResult.ok`` False, or a ``ClientError`` raised for one container) is
|
|
39
|
+
recorded and does NOT abort the others. Each ``--json`` ``added[]`` record
|
|
40
|
+
carries ``handoff_ok`` (True = confirmed seeding, False = failed or not
|
|
41
|
+
confirmed, null = FallbackClient manual instructions) plus
|
|
42
|
+
``handoff_message``; a
|
|
43
|
+
top-level ``handoff_summary`` counts each state. If ANY handoff failed the
|
|
44
|
+
whole run exits 14 (the client-error code); an all-success and/or
|
|
45
|
+
fallback-only run exits 0.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
import sys
|
|
52
|
+
from typing import Optional
|
|
53
|
+
|
|
54
|
+
from . import scanner, matcher
|
|
55
|
+
from .api import ApiClient, DEFAULT_API_BASE
|
|
56
|
+
from .clients import ClientError, FileSelection, SeedClient, build_client
|
|
57
|
+
from .errors import LayoutError, ManifestVerificationError
|
|
58
|
+
from .layout import (
|
|
59
|
+
CLIENT_LINK_SUPPORT,
|
|
60
|
+
DEFAULT_BASE_DIR,
|
|
61
|
+
M5_AUTO_FETCH_MAX_BYTES,
|
|
62
|
+
apply_plan,
|
|
63
|
+
classify_missing_files,
|
|
64
|
+
plan_layout,
|
|
65
|
+
safe_join,
|
|
66
|
+
sanitize_relpath,
|
|
67
|
+
)
|
|
68
|
+
from .manifest import fetch_verified_manifest, manifest_files
|
|
69
|
+
from .output import emit_json, human_size, safe_text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------- #
|
|
73
|
+
# argparse wiring (called from cli.build_parser)
|
|
74
|
+
# --------------------------------------------------------------------------- #
|
|
75
|
+
|
|
76
|
+
def _size_arg(raw: str) -> int:
|
|
77
|
+
s = raw.strip().upper()
|
|
78
|
+
mult = 1
|
|
79
|
+
for suf, m in (("K", 1024), ("M", 1024**2), ("G", 1024**3), ("T", 1024**4)):
|
|
80
|
+
if s.endswith(suf) or s.endswith(suf + "B"):
|
|
81
|
+
mult = m
|
|
82
|
+
s = s.rstrip("B").rstrip(suf)
|
|
83
|
+
break
|
|
84
|
+
try:
|
|
85
|
+
return int(float(s) * mult)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise ValueError(f"invalid size: {raw!r}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_scan_args(sp) -> None:
|
|
91
|
+
sp.add_argument("--path", action="append", default=[],
|
|
92
|
+
help="Explicit directory to scan (repeatable). If given, "
|
|
93
|
+
"only these paths are scanned (bounds scope). "
|
|
94
|
+
"Otherwise known layouts are auto-detected.")
|
|
95
|
+
sp.add_argument("--min-size", type=_size_arg, default=scanner.DEFAULT_MIN_SIZE,
|
|
96
|
+
help="Prefilter: ignore files smaller than this "
|
|
97
|
+
"(e.g. 100M, 2G). Default 1M.")
|
|
98
|
+
sp.add_argument("--all-files", action="store_true",
|
|
99
|
+
help="Hash every file passing the size filter, not just "
|
|
100
|
+
"known model extensions.")
|
|
101
|
+
sp.add_argument("--max-depth", type=int, default=scanner.DEFAULT_MAX_DEPTH,
|
|
102
|
+
help="Max directory recursion depth (loop/DoS guard).")
|
|
103
|
+
sp.add_argument("--layout-base", default=None,
|
|
104
|
+
help="Root for generated seeding link farms "
|
|
105
|
+
"(default ~/.llmt/seeding-layouts). Must be a path "
|
|
106
|
+
"your torrent client can see: a dockerized/remote "
|
|
107
|
+
"client (seedbox/unRAID) only sees its mounted "
|
|
108
|
+
"volumes, so point this at one of them or the "
|
|
109
|
+
"handoff rechecks empty and fails closed.")
|
|
110
|
+
sp.add_argument("--client", choices=["qbittorrent", "transmission", "none"],
|
|
111
|
+
default=None,
|
|
112
|
+
help="Torrent client to add matches to. Default: auto "
|
|
113
|
+
"(qBit/Transmission if a URL is configured, else "
|
|
114
|
+
"fallback to printing the magnet).")
|
|
115
|
+
sp.add_argument("--qb-url", default=None, help="qBittorrent WebUI base URL "
|
|
116
|
+
"(env LLMT_QB_URL).")
|
|
117
|
+
sp.add_argument("--qb-user", default=None, help="qBittorrent user (env "
|
|
118
|
+
"LLMT_QB_USER).")
|
|
119
|
+
sp.add_argument("--qb-pass", default=None, help="qBittorrent password (env "
|
|
120
|
+
"LLMT_QB_PASS).")
|
|
121
|
+
sp.add_argument("--tr-url", default=None, help="Transmission RPC URL (env "
|
|
122
|
+
"LLMT_TR_URL).")
|
|
123
|
+
sp.add_argument("--tr-user", default=None, help="Transmission user (env "
|
|
124
|
+
"LLMT_TR_USER).")
|
|
125
|
+
sp.add_argument("--tr-pass", default=None, help="Transmission password (env "
|
|
126
|
+
"LLMT_TR_PASS).")
|
|
127
|
+
sp.add_argument("--yes", "-y", action="store_true",
|
|
128
|
+
help="Non-interactive: accept and seed every match.")
|
|
129
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
130
|
+
help="Show matches and the plan; create no links and add "
|
|
131
|
+
"nothing to any client.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
# helpers
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
|
|
138
|
+
def _resolve_roots(args) -> list[tuple[str, str]]:
|
|
139
|
+
if args.path:
|
|
140
|
+
return [(os.path.abspath(os.path.expanduser(p)), scanner.LAYOUT_PATH)
|
|
141
|
+
for p in args.path]
|
|
142
|
+
return scanner.default_roots()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _layout_client_hint(client: SeedClient) -> str:
|
|
146
|
+
return client.name if client.name in CLIENT_LINK_SUPPORT else "generic"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _client_visible_path(plan) -> str:
|
|
150
|
+
"""Per the frozen layout contract, the path the client will present."""
|
|
151
|
+
if plan.mode == "link-farm":
|
|
152
|
+
for act in reversed(plan.actions):
|
|
153
|
+
if act.kind in ("hardlink", "symlink"):
|
|
154
|
+
return act.target
|
|
155
|
+
return plan.source_file
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _fmt_files(pairs) -> str:
|
|
159
|
+
return ", ".join(f"{safe_text(path)} ({human_size(size)})"
|
|
160
|
+
for path, size in pairs)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _plan_file_selection(api, pairs):
|
|
164
|
+
"""M5 (size-aware Option C, Pat 2026-07-13): classify a container's files.
|
|
165
|
+
|
|
166
|
+
``pairs`` is ``[(match, plan), ...]`` -- ALL accepted matches that belong
|
|
167
|
+
to ONE container (same info hash), sharing one save path. The matcher
|
|
168
|
+
yields one Match per LOCAL FILE, so a container the user holds several
|
|
169
|
+
files of arrives here as several pairs; every one of them counts as
|
|
170
|
+
PRESENT (review fix for 6d548a2: previously each match was classified in
|
|
171
|
+
isolation, so a user-held sibling was falsely marked missing and the same
|
|
172
|
+
info-hash was added once per match).
|
|
173
|
+
|
|
174
|
+
For a multi-file torrent (the sanitised container path has a root
|
|
175
|
+
folder), fetch the torrent's full file list from its Ed25519-SIGNED
|
|
176
|
+
manifest, determine which files are already present at the layout, and
|
|
177
|
+
classify the missing ones via :func:`llmt.layout.classify_missing_files`.
|
|
178
|
+
|
|
179
|
+
Returns ``(selection, missing_report, human_lines)``:
|
|
180
|
+
* ``selection`` -- a :class:`FileSelection` for the client
|
|
181
|
+
adapters, or None when nothing is missing
|
|
182
|
+
(fully-present container / single-file torrent
|
|
183
|
+
-- those flows are UNCHANGED, no manifest
|
|
184
|
+
fetch happens for single-file torrents);
|
|
185
|
+
* ``missing_report`` -- ``{"fetched": [...], "skipped": [...]}`` with
|
|
186
|
+
path + size_bytes for ``--json``;
|
|
187
|
+
* ``human_lines`` -- the notice / warning lines for humans.
|
|
188
|
+
|
|
189
|
+
Fail closed: if the manifest cannot be fetched/verified, lists any path
|
|
190
|
+
twice (after normalization -- ambiguous bookkeeping), or does not list
|
|
191
|
+
every matched file with its local digest and size, the container is
|
|
192
|
+
refused (ManifestVerificationError) rather than handed to a client that
|
|
193
|
+
would download unknown siblings.
|
|
194
|
+
"""
|
|
195
|
+
empty = {"fetched": [], "skipped": []}
|
|
196
|
+
m0, plan0 = pairs[0]
|
|
197
|
+
if not plan0.torrent_root:
|
|
198
|
+
# Single-file torrent: no siblings can exist -- unchanged flow.
|
|
199
|
+
return None, empty, []
|
|
200
|
+
|
|
201
|
+
container = m0.container
|
|
202
|
+
doc = fetch_verified_manifest(api, container.info_hash_v1)
|
|
203
|
+
files = manifest_files(doc)
|
|
204
|
+
|
|
205
|
+
def norm(path):
|
|
206
|
+
return "/".join(sanitize_relpath(path))
|
|
207
|
+
|
|
208
|
+
by_path = {}
|
|
209
|
+
for f in files:
|
|
210
|
+
rel = norm(f["path"])
|
|
211
|
+
if rel in by_path:
|
|
212
|
+
raise ManifestVerificationError(
|
|
213
|
+
f"the Ed25519-signed manifest for torrent "
|
|
214
|
+
f"{container.info_hash_v1} lists {rel!r} more than once "
|
|
215
|
+
"(after path normalization) -- ambiguous file list; refusing "
|
|
216
|
+
"to seed this container (nothing was added).")
|
|
217
|
+
by_path[rel] = f
|
|
218
|
+
|
|
219
|
+
matched = set()
|
|
220
|
+
for m, plan in pairs:
|
|
221
|
+
entry = by_path.get(plan.in_torrent_path)
|
|
222
|
+
if (entry is None or entry["sha256"] != m.file.sha256
|
|
223
|
+
or (entry["size"] is not None
|
|
224
|
+
and entry["size"] != m.file.size)):
|
|
225
|
+
raise ManifestVerificationError(
|
|
226
|
+
f"the Ed25519-signed manifest for torrent "
|
|
227
|
+
f"{container.info_hash_v1} does not list the matched file "
|
|
228
|
+
f"{plan.in_torrent_path!r} with the locally-computed digest "
|
|
229
|
+
"and size -- catalog/manifest disagreement; refusing to seed "
|
|
230
|
+
"this container (nothing was added).")
|
|
231
|
+
matched.add(plan.in_torrent_path)
|
|
232
|
+
|
|
233
|
+
missing = []
|
|
234
|
+
for rel, f in by_path.items():
|
|
235
|
+
if rel in matched:
|
|
236
|
+
continue # a user-held, catalog-matched file of this container
|
|
237
|
+
expected = safe_join(plan0.save_path, sanitize_relpath(f["path"]))
|
|
238
|
+
present = (os.path.isfile(expected)
|
|
239
|
+
and (f["size"] is None
|
|
240
|
+
or os.path.getsize(expected) == f["size"]))
|
|
241
|
+
if not present:
|
|
242
|
+
missing.append((rel, f["size"]))
|
|
243
|
+
|
|
244
|
+
if not missing:
|
|
245
|
+
# Fully-present multi-file container: unchanged flow, no warning.
|
|
246
|
+
return None, empty, []
|
|
247
|
+
|
|
248
|
+
fetch, skip = classify_missing_files(missing)
|
|
249
|
+
selection = FileSelection(
|
|
250
|
+
unwanted=tuple(path for path, _ in skip),
|
|
251
|
+
expect_fetch=tuple(path for path, _ in fetch),
|
|
252
|
+
)
|
|
253
|
+
report = {
|
|
254
|
+
"fetched": [{"path": path, "size_bytes": size}
|
|
255
|
+
for path, size in fetch],
|
|
256
|
+
"skipped": [{"path": path, "size_bytes": size}
|
|
257
|
+
for path, size in skip],
|
|
258
|
+
}
|
|
259
|
+
land_dir = os.path.join(plan0.save_path, plan0.torrent_root)
|
|
260
|
+
cap = human_size(M5_AUTO_FETCH_MAX_BYTES)
|
|
261
|
+
lines = []
|
|
262
|
+
if fetch:
|
|
263
|
+
total = human_size(sum(size or 0 for _, size in fetch))
|
|
264
|
+
lines.append(
|
|
265
|
+
f"note: {len(fetch)} small companion file(s) missing locally "
|
|
266
|
+
f"will be fetched by your client into {land_dir}: "
|
|
267
|
+
f"{_fmt_files(fetch)} ({total} total, <={cap} cap) -- model "
|
|
268
|
+
f"weights are never re-downloaded.")
|
|
269
|
+
if skip:
|
|
270
|
+
lines.append(
|
|
271
|
+
f"WARNING: {len(skip)} missing file(s) exceed the {cap} "
|
|
272
|
+
f"auto-fetch cap and will NOT be downloaded: "
|
|
273
|
+
f"{_fmt_files(skip)}. They are set to do-not-download before "
|
|
274
|
+
f"the torrent can resume; it will seed only the files you have "
|
|
275
|
+
f"and will show as incomplete in your client.")
|
|
276
|
+
return selection, report, lines
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --------------------------------------------------------------------------- #
|
|
280
|
+
# command
|
|
281
|
+
# --------------------------------------------------------------------------- #
|
|
282
|
+
|
|
283
|
+
def cmd_scan(args) -> int:
|
|
284
|
+
return run_scan(args)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def run_scan(args, *, client: Optional[SeedClient] = None,
|
|
288
|
+
api_client: Optional[ApiClient] = None) -> int:
|
|
289
|
+
as_json = getattr(args, "json", False)
|
|
290
|
+
|
|
291
|
+
def note(msg: str) -> None:
|
|
292
|
+
if not as_json:
|
|
293
|
+
print(msg, file=sys.stderr)
|
|
294
|
+
|
|
295
|
+
roots = _resolve_roots(args)
|
|
296
|
+
if not roots:
|
|
297
|
+
if as_json:
|
|
298
|
+
emit_json({"scanned": 0, "matches": [], "note": "no roots"})
|
|
299
|
+
else:
|
|
300
|
+
print("No model directories found. Pass --path DIR to scan a "
|
|
301
|
+
"specific location.", file=sys.stderr)
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
note("Scanning: " + ", ".join(r for r, _ in roots))
|
|
305
|
+
candidates = list(scanner.iter_candidates(
|
|
306
|
+
roots, min_size=args.min_size, all_files=args.all_files,
|
|
307
|
+
max_depth=args.max_depth,
|
|
308
|
+
))
|
|
309
|
+
note(f"Found {len(candidates)} candidate file(s) to hash "
|
|
310
|
+
f"({human_size(sum(c.size for c in candidates))}).")
|
|
311
|
+
|
|
312
|
+
labels: dict[str, str] = {}
|
|
313
|
+
for root, layout in roots:
|
|
314
|
+
if layout == scanner.LAYOUT_OLLAMA:
|
|
315
|
+
labels.update(scanner.ollama_labels(root))
|
|
316
|
+
|
|
317
|
+
def progress(i: int, total: int, path: str) -> None:
|
|
318
|
+
note(f" [hash {i}/{total}] {os.path.basename(path)}")
|
|
319
|
+
|
|
320
|
+
hashed = scanner.hash_candidates(candidates, labels=labels, progress=progress)
|
|
321
|
+
|
|
322
|
+
api = api_client or ApiClient(base=args.api_base, key=args.api_key,
|
|
323
|
+
timeout=args.timeout)
|
|
324
|
+
results = matcher.lookup_shas(api, [h.sha256 for h in hashed])
|
|
325
|
+
matches, unmatched, size_bad = matcher.assemble_matches(hashed, results)
|
|
326
|
+
|
|
327
|
+
if as_json:
|
|
328
|
+
emit_json({
|
|
329
|
+
"scanned": len(candidates),
|
|
330
|
+
"hashed": len(hashed),
|
|
331
|
+
"matches": [_match_json(m) for m in matches],
|
|
332
|
+
"unmatched": len(unmatched),
|
|
333
|
+
"size_mismatch": [
|
|
334
|
+
{"path": hf.path, "local_size": hf.size,
|
|
335
|
+
"declared_size": e.size_bytes} for hf, e in size_bad],
|
|
336
|
+
})
|
|
337
|
+
else:
|
|
338
|
+
_print_report(matches, hashed, unmatched, size_bad)
|
|
339
|
+
|
|
340
|
+
if not matches:
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
# --- accept / confirm -------------------------------------------------- #
|
|
344
|
+
if args.dry_run or args.yes:
|
|
345
|
+
accepted = matches
|
|
346
|
+
else:
|
|
347
|
+
# Never prompt in --json mode: input()'s prompt would corrupt the JSON
|
|
348
|
+
# stream on stdout. Require explicit --yes/--dry-run instead.
|
|
349
|
+
if as_json or not sys.stdin.isatty():
|
|
350
|
+
note("Refusing to prompt on a non-interactive/JSON invocation; "
|
|
351
|
+
"re-run with --yes to seed or --dry-run to preview.")
|
|
352
|
+
return 0
|
|
353
|
+
try:
|
|
354
|
+
ans = input(f"Seed these {len(matches)} file(s)? [y/N] ").strip().lower()
|
|
355
|
+
except EOFError:
|
|
356
|
+
ans = ""
|
|
357
|
+
if ans not in ("y", "yes"):
|
|
358
|
+
print("Aborted; nothing was added.")
|
|
359
|
+
return 0
|
|
360
|
+
accepted = matches
|
|
361
|
+
|
|
362
|
+
client = client or build_client(args)
|
|
363
|
+
if not as_json:
|
|
364
|
+
print(f"\nUsing client: {client.name}"
|
|
365
|
+
+ (" (dry-run)" if args.dry_run else ""))
|
|
366
|
+
|
|
367
|
+
# S3 pre-flight: warn early if the client looks path-blind (dockerized or
|
|
368
|
+
# remote) so a 0% recheck below is not the FIRST hint the user gets.
|
|
369
|
+
# Advisory only -- it never blocks; the per-container recheck stays the
|
|
370
|
+
# authoritative, fail-closed gate. Skipped under --json (its advisory
|
|
371
|
+
# goes to stderr only, so the extra login+preferences round-trip would be
|
|
372
|
+
# discarded).
|
|
373
|
+
if not args.dry_run and not as_json:
|
|
374
|
+
probe_fn = getattr(client, "probe_local_visibility", None)
|
|
375
|
+
if callable(probe_fn):
|
|
376
|
+
probe = probe_fn()
|
|
377
|
+
if probe.supported and probe.visible_locally is False:
|
|
378
|
+
eff_base = os.path.abspath(os.path.expanduser(
|
|
379
|
+
args.layout_base or DEFAULT_BASE_DIR))
|
|
380
|
+
note(
|
|
381
|
+
"WARNING: your torrent client's own default save path "
|
|
382
|
+
f"({probe.client_default_path}) does not exist on the "
|
|
383
|
+
"machine llmt is running on -- llmt and the client look "
|
|
384
|
+
"like they are on different filesystems (a dockerized "
|
|
385
|
+
"seedbox/unRAID or a remote client). llmt's link farm at "
|
|
386
|
+
f"{eff_base} is a path on THIS machine, so the client may "
|
|
387
|
+
"not be able to see it and the seed handoff below would "
|
|
388
|
+
"recheck to 0% and fail closed. If that happens, re-run "
|
|
389
|
+
"with --layout-base pointing at a directory the client "
|
|
390
|
+
"can see (one of its mounted volumes).")
|
|
391
|
+
|
|
392
|
+
# M5 (review fix for 6d548a2): the matcher yields one Match per LOCAL
|
|
393
|
+
# FILE, so a container the user holds several files of appears as several
|
|
394
|
+
# matches. Group by torrent identity and hand each torrent to the client
|
|
395
|
+
# exactly ONCE, with every user-held file counted as present -- adding
|
|
396
|
+
# the same info-hash once per match double-adds (qBittorrent answers
|
|
397
|
+
# HTTP 200 "Fails.", Transmission returns torrent-duplicate) and falsely
|
|
398
|
+
# classifies later-matched siblings as missing.
|
|
399
|
+
groups: "dict[str, list]" = {}
|
|
400
|
+
for m in accepted:
|
|
401
|
+
key = (m.container.info_hash_v1 or m.container.magnet
|
|
402
|
+
or f"anon-{id(m.container)}")
|
|
403
|
+
groups.setdefault(key, []).append(m)
|
|
404
|
+
|
|
405
|
+
add_reports = []
|
|
406
|
+
for group in groups.values():
|
|
407
|
+
pairs = []
|
|
408
|
+
for m in group:
|
|
409
|
+
plan = plan_layout(
|
|
410
|
+
m.file.path, m.container.container_dict(),
|
|
411
|
+
base_dir=args.layout_base, client=_layout_client_hint(client),
|
|
412
|
+
)
|
|
413
|
+
pairs.append((m, plan))
|
|
414
|
+
save_paths = sorted({plan.save_path for _, plan in pairs})
|
|
415
|
+
if len(save_paths) > 1:
|
|
416
|
+
# One torrent cannot be added at two save paths (mixed
|
|
417
|
+
# direct-use/link-farm layouts across this container's matched
|
|
418
|
+
# files). Fail closed with an honest error instead of guessing.
|
|
419
|
+
raise LayoutError(
|
|
420
|
+
f"the {len(pairs)} matched files of "
|
|
421
|
+
f"'{safe_text(pairs[0][0].container.describe())}' resolve to "
|
|
422
|
+
"different save paths (mixed direct-use/link-farm layouts): "
|
|
423
|
+
+ ", ".join(save_paths) + " -- one torrent cannot be added "
|
|
424
|
+
"at two save paths, so nothing was added for this container. "
|
|
425
|
+
"Keep those files under a single layout and re-run.")
|
|
426
|
+
m0, plan0 = pairs[0]
|
|
427
|
+
# M5: classify missing siblings BEFORE the client handoff so large
|
|
428
|
+
# missing files are do-not-download before any resume is possible;
|
|
429
|
+
# every matched file of this container counts as present.
|
|
430
|
+
selection, missing_report, m5_lines = _plan_file_selection(api, pairs)
|
|
431
|
+
logs = [apply_plan(plan, dry_run=args.dry_run) for _, plan in pairs]
|
|
432
|
+
# D15 (Fix 11): the handoff is the only per-container failure
|
|
433
|
+
# point. "make -k": a failed/refused handoff (ok=False returned, or
|
|
434
|
+
# ClientError raised) is recorded and MUST NOT abort the remaining
|
|
435
|
+
# containers. handoff_ok is tri-state -- True = confirmed seeding,
|
|
436
|
+
# False = failed/unverified (forces a non-zero exit), None =
|
|
437
|
+
# FallbackClient manual instructions (told the user what to do; not
|
|
438
|
+
# a success, not a failure, no non-zero exit for a fallback alone).
|
|
439
|
+
res = None
|
|
440
|
+
handoff_message = ""
|
|
441
|
+
method = client.name
|
|
442
|
+
try:
|
|
443
|
+
res = client.add(
|
|
444
|
+
magnet=m0.container.magnet,
|
|
445
|
+
info_hash=m0.container.info_hash_v1,
|
|
446
|
+
save_path=plan0.save_path,
|
|
447
|
+
torrent_name=m0.container.torrent_name,
|
|
448
|
+
dry_run=args.dry_run, file_selection=selection,
|
|
449
|
+
)
|
|
450
|
+
except ClientError as exc:
|
|
451
|
+
handoff_ok = False
|
|
452
|
+
handoff_message = str(exc)
|
|
453
|
+
else:
|
|
454
|
+
method = res.method
|
|
455
|
+
if res.method == "fallback-magnet":
|
|
456
|
+
handoff_ok = None
|
|
457
|
+
handoff_message = res.message
|
|
458
|
+
else:
|
|
459
|
+
handoff_ok = bool(res.ok)
|
|
460
|
+
handoff_message = res.message
|
|
461
|
+
add_reports.append({
|
|
462
|
+
"pairs": pairs, "logs": logs, "res": res,
|
|
463
|
+
"missing_report": missing_report, "method": method,
|
|
464
|
+
"handoff_ok": handoff_ok, "handoff_message": handoff_message,
|
|
465
|
+
})
|
|
466
|
+
if not as_json:
|
|
467
|
+
print(f"\n {safe_text(m0.container.describe())}")
|
|
468
|
+
for m, _plan in pairs:
|
|
469
|
+
print(f" file : {m.file.path}")
|
|
470
|
+
print(f" plan : {plan0.mode} -> save_path {plan0.save_path}")
|
|
471
|
+
for line in m5_lines:
|
|
472
|
+
print(f" {line}")
|
|
473
|
+
if res is not None:
|
|
474
|
+
for line in res.steps:
|
|
475
|
+
print(f" - {line}")
|
|
476
|
+
if res.method == "fallback-magnet":
|
|
477
|
+
print(f" magnet: {res.message}")
|
|
478
|
+
if handoff_ok is False:
|
|
479
|
+
print(f" [HANDOFF FAILED] not seeding: {handoff_message}")
|
|
480
|
+
elif handoff_ok is None:
|
|
481
|
+
print(" [MANUAL] added, but you must finish the handoff "
|
|
482
|
+
"shown above; NOT yet seeding")
|
|
483
|
+
|
|
484
|
+
n_ok = sum(1 for r in add_reports if r["handoff_ok"] is True)
|
|
485
|
+
n_manual = sum(1 for r in add_reports if r["handoff_ok"] is None)
|
|
486
|
+
n_failed = sum(1 for r in add_reports if r["handoff_ok"] is False)
|
|
487
|
+
|
|
488
|
+
if as_json:
|
|
489
|
+
emit_json({
|
|
490
|
+
"added": [{
|
|
491
|
+
"sha256": r["pairs"][0][0].file.sha256,
|
|
492
|
+
"path": r["pairs"][0][0].file.path,
|
|
493
|
+
"matched_files": [
|
|
494
|
+
{"sha256": m.file.sha256, "path": m.file.path}
|
|
495
|
+
for m, _plan in r["pairs"]],
|
|
496
|
+
"mode": r["pairs"][0][1].mode,
|
|
497
|
+
"save_path": r["pairs"][0][1].save_path,
|
|
498
|
+
"client": r["method"],
|
|
499
|
+
"steps": r["res"].steps if r["res"] is not None else [],
|
|
500
|
+
"dry_run": (r["res"].dry_run if r["res"] is not None
|
|
501
|
+
else bool(args.dry_run)),
|
|
502
|
+
"missing_files": r["missing_report"],
|
|
503
|
+
"handoff_ok": r["handoff_ok"],
|
|
504
|
+
"handoff_message": r["handoff_message"],
|
|
505
|
+
} for r in add_reports],
|
|
506
|
+
"handoff_summary": {
|
|
507
|
+
"ok": n_ok, "manual": n_manual, "failed": n_failed},
|
|
508
|
+
})
|
|
509
|
+
else:
|
|
510
|
+
parts = [f"{n_ok} confirmed seeding"]
|
|
511
|
+
if n_manual:
|
|
512
|
+
parts.append(
|
|
513
|
+
f"{n_manual} awaiting manual action (not yet seeding)")
|
|
514
|
+
if n_failed:
|
|
515
|
+
parts.append(f"{n_failed} FAILED (not seeding)")
|
|
516
|
+
print("\nHandoff summary: " + ", ".join(parts) + ".")
|
|
517
|
+
|
|
518
|
+
# D15: any failed/unverified handoff makes the whole run exit non-zero
|
|
519
|
+
# (reuse the client-error code 14). Manual (fallback) and all-ok do not.
|
|
520
|
+
return ClientError.exit_code if n_failed else 0
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _match_json(m: matcher.Match) -> dict:
|
|
524
|
+
return {
|
|
525
|
+
"sha256": m.file.sha256,
|
|
526
|
+
"path": m.file.path,
|
|
527
|
+
"size": m.file.size,
|
|
528
|
+
"label": m.file.label,
|
|
529
|
+
"layout": m.file.layout,
|
|
530
|
+
"model": m.container.describe(),
|
|
531
|
+
"torrent_name": m.container.torrent_name,
|
|
532
|
+
"status": m.container.status,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _print_report(matches, hashed, unmatched, size_bad) -> None:
|
|
537
|
+
print()
|
|
538
|
+
if size_bad:
|
|
539
|
+
print("Refused (size disagreement between disk and registry — not "
|
|
540
|
+
"offered):")
|
|
541
|
+
for hf, entry in size_bad:
|
|
542
|
+
print(f" ! {hf.path} (on disk {human_size(hf.size)}, registry "
|
|
543
|
+
f"declared {human_size(entry.size_bytes)})")
|
|
544
|
+
print()
|
|
545
|
+
if not matches:
|
|
546
|
+
print(f"Scanned {len(hashed)} file(s); no seedable catalog matches "
|
|
547
|
+
f"(no local file's full-file SHA-256 matched the catalog).")
|
|
548
|
+
return
|
|
549
|
+
print(f"You already have {len(matches)} model file(s) whose full-file "
|
|
550
|
+
f"SHA-256 matches the catalog (TLS lookup, not an Ed25519 "
|
|
551
|
+
f"signature). Seed these?")
|
|
552
|
+
for i, m in enumerate(matches, 1):
|
|
553
|
+
lbl = f" ({safe_text(m.file.label)})" if m.file.label else ""
|
|
554
|
+
print(f" {i}. {safe_text(m.container.describe())}{lbl}")
|
|
555
|
+
print(f" {m.file.path} [{human_size(m.file.size)}]")
|
|
556
|
+
print()
|