unit3dprep 1.0.4__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.
Files changed (50) hide show
  1. unit3dprep/__init__.py +0 -0
  2. unit3dprep/cli.py +256 -0
  3. unit3dprep/core.py +556 -0
  4. unit3dprep/i18n.py +151 -0
  5. unit3dprep/media.py +278 -0
  6. unit3dprep/upload.py +146 -0
  7. unit3dprep/web/__init__.py +0 -0
  8. unit3dprep/web/_env.py +40 -0
  9. unit3dprep/web/api/__init__.py +1 -0
  10. unit3dprep/web/api/auth.py +36 -0
  11. unit3dprep/web/api/fs.py +64 -0
  12. unit3dprep/web/api/library.py +503 -0
  13. unit3dprep/web/api/logs.py +42 -0
  14. unit3dprep/web/api/queue.py +54 -0
  15. unit3dprep/web/api/quickupload.py +153 -0
  16. unit3dprep/web/api/search.py +40 -0
  17. unit3dprep/web/api/settings.py +77 -0
  18. unit3dprep/web/api/tmdb.py +77 -0
  19. unit3dprep/web/api/trackers.py +30 -0
  20. unit3dprep/web/api/uploaded.py +26 -0
  21. unit3dprep/web/api/version.py +754 -0
  22. unit3dprep/web/api/webup.py +59 -0
  23. unit3dprep/web/api/wizard.py +512 -0
  24. unit3dprep/web/app.py +201 -0
  25. unit3dprep/web/auth.py +41 -0
  26. unit3dprep/web/clients.py +167 -0
  27. unit3dprep/web/config.py +932 -0
  28. unit3dprep/web/db.py +184 -0
  29. unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  30. unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  31. unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  32. unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
  33. unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
  34. unit3dprep/web/dist/index.html +14 -0
  35. unit3dprep/web/duplicate_check.py +92 -0
  36. unit3dprep/web/lang_cache.py +118 -0
  37. unit3dprep/web/logbuf.py +164 -0
  38. unit3dprep/web/tmdb_cache.py +116 -0
  39. unit3dprep/web/trackers.py +177 -0
  40. unit3dprep/web/webup_client.py +190 -0
  41. unit3dprep/web/webup_job_fix.py +231 -0
  42. unit3dprep/web/webup_logclass.py +107 -0
  43. unit3dprep/web/webup_orchestrator.py +796 -0
  44. unit3dprep/web/webup_ws.py +141 -0
  45. unit3dprep-1.0.4.dist-info/METADATA +819 -0
  46. unit3dprep-1.0.4.dist-info/RECORD +50 -0
  47. unit3dprep-1.0.4.dist-info/WHEEL +5 -0
  48. unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
  49. unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
  50. unit3dprep-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,796 @@
1
+ """Orchestrate Unit3DWebUp upload pipeline.
2
+
3
+ Replaces `unit3dprep.upload.stream_unit3dup`. Public entry point:
4
+
5
+ async for ev in stream_webup(seeding_path, kind, tmdb_id):
6
+ ...
7
+
8
+ Yields the same dict shape consumed by wizard/quickupload SSE handlers:
9
+ {"type": "log", "data": str}
10
+ {"type": "progress", "data": str}
11
+ {"type": "error", "data": str}
12
+ {"type": "done", "exit_code": int}
13
+
14
+ The pipeline (per upload):
15
+ 1. acquire SCAN_PATH lock (serialize concurrent uploads — see plan).
16
+ 2. open WS subscription (wildcard, then rekey to job_id).
17
+ 3. POST /setenv {PREFS__SCAN_PATH: <parent_dir>} so /scan picks the right folder.
18
+ 4. POST /scan and await results.
19
+ 5. compute job_id deterministically from seeding_path; verify it's in scan results.
20
+ 6. POST /settmdbid if our tmdb_id differs from webup's resolution.
21
+ 7. POST /maketorrent → drain WS until torrent.created/exists or error.
22
+ 8. POST /upload → drain WS until upload.done success or failure.
23
+ 9. POST /seed → 200 ok / 503/409/404 mapped to warning or error.
24
+ 10. emit {"type":"done","exit_code": 0|1}.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import logging
30
+ from pathlib import Path
31
+ from typing import Any, AsyncGenerator
32
+
33
+ from .config import runtime_setting
34
+ from .webup_client import WebupClient, compute_job_id
35
+ from .webup_job_fix import maybe_force_can_upload, maybe_inject_season
36
+ from .webup_logclass import classify_msg, is_terminal_failure, is_terminal_success
37
+ from .webup_ws import WILDCARD, WebupWSManager
38
+
39
+
40
+ _log = logging.getLogger(__name__)
41
+
42
+ # Hard ceilings to avoid hung uploads.
43
+ SCAN_TIMEOUT = 300.0 # 5 min for /scan + TMDB/TVDB lookup + screenshots
44
+ PHASE_TIMEOUT = 1800.0 # 30 min per phase (maketorrent / upload)
45
+
46
+
47
+ # Coarse phase weights for the wizard's overall progress bar. They don't have
48
+ # to match wall-clock proportions exactly — they just give the user a sense of
49
+ # motion. Sum is 100.
50
+ _PHASE_WEIGHTS: dict[str, float] = {
51
+ "setenv": 3.0,
52
+ "scan": 27.0,
53
+ "maketorrent": 45.0,
54
+ "upload": 15.0,
55
+ "seed": 10.0,
56
+ }
57
+ _PHASE_ORDER = list(_PHASE_WEIGHTS.keys())
58
+ _PHASE_LABELS: dict[str, str] = {
59
+ "setenv": "Configuro path",
60
+ "scan": "Scansione + TMDB + screenshots",
61
+ "maketorrent": "Creo torrent",
62
+ "upload": "Upload al tracker",
63
+ "seed": "Aggiungo a qBittorrent",
64
+ }
65
+
66
+ # Webup emits posterLogMessage like "[New torrent] FILE - 12.34" during
67
+ # maketorrent. The tail "- N" carries the percentage.
68
+ import re
69
+ _RX_MAKETORRENT_PCT = re.compile(r"-\s*(\d+(?:\.\d+)?)\s*$")
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # .torrent announce-URL workaround
74
+ #
75
+ # Webup builds the announce URL with `f"{settings.tracker.ITT_URL}/announce/
76
+ # {settings.tracker.ITT_PID}"` (see unit3dwup/config/api_data.py). Pydantic-
77
+ # settings normalizes `HttpUrl` fields by appending a trailing slash, so the
78
+ # resulting URL contains a doubled slash:
79
+ #
80
+ # https://itatorrents.xyz//announce/<pid>
81
+ #
82
+ # ItaTorrents (and other Unit3D trackers) returns 404 on that path. qBittorrent
83
+ # reports "not found" on the announce, the torrent never registers, and the
84
+ # upload appears completed but is invisible on the tracker. We patch the
85
+ # .torrent file on disk between /maketorrent and /seed so qBit receives a
86
+ # clean announce URL. The info-dict bytes are preserved verbatim to keep the
87
+ # infohash stable.
88
+ # ---------------------------------------------------------------------------
89
+
90
+ _BAD_ANNOUNCE = b"//announce/"
91
+ _GOOD_ANNOUNCE = b"/announce/"
92
+
93
+
94
+ def _bdecode(data: bytes, idx: int):
95
+ c = data[idx:idx+1]
96
+ if c.isdigit():
97
+ colon = data.index(b":", idx)
98
+ n = int(data[idx:colon])
99
+ start = colon + 1
100
+ return data[start:start+n], start + n
101
+ if c == b"i":
102
+ end = data.index(b"e", idx)
103
+ return int(data[idx+1:end]), end + 1
104
+ if c == b"l":
105
+ items = []
106
+ idx += 1
107
+ while data[idx:idx+1] != b"e":
108
+ v, idx = _bdecode(data, idx)
109
+ items.append(v)
110
+ return items, idx + 1
111
+ if c == b"d":
112
+ d: dict = {}
113
+ idx += 1
114
+ while data[idx:idx+1] != b"e":
115
+ k, idx = _bdecode(data, idx)
116
+ v, idx = _bdecode(data, idx)
117
+ d[k] = v
118
+ return d, idx + 1
119
+ raise ValueError(f"invalid bencode at {idx}: {c!r}")
120
+
121
+
122
+ def _bencode(v) -> bytes:
123
+ if isinstance(v, bytes):
124
+ return f"{len(v)}:".encode() + v
125
+ if isinstance(v, int):
126
+ return f"i{v}e".encode()
127
+ if isinstance(v, list):
128
+ return b"l" + b"".join(_bencode(x) for x in v) + b"e"
129
+ if isinstance(v, dict):
130
+ out = b"d"
131
+ for k in sorted(v.keys()):
132
+ out += _bencode(k) + _bencode(v[k])
133
+ return out + b"e"
134
+ raise TypeError(type(v))
135
+
136
+
137
+ def _candidate_torrent_paths(media: dict[str, Any], match_path: Path):
138
+ """Yield possible on-disk paths for the .torrent webup just built.
139
+
140
+ Webup's media dict ships several path-shaped fields with confusingly
141
+ similar names:
142
+
143
+ - ``torrent_file_path`` — the .torrent file webup just generated
144
+ (canonical for our use case).
145
+ - ``torrent_path`` — the *source* media file path (NOT a .torrent;
146
+ typically the multi-GiB .mkv inside the sandbox).
147
+ - ``file_name`` — same as ``torrent_path``.
148
+
149
+ We try ``torrent_file_path`` first, then fall back to deriving the path
150
+ from ``<TORRENT_ARCHIVE_PATH>/<TRACKER>/<filename>.torrent`` using
151
+ webup's published archive root and the tracker shorthand
152
+ (``ITT``/``PTT``/``SIS``). Caller filters by ``Path.exists()`` and the
153
+ bencode magic-byte check inside ``_normalize_announce_in_torrent``.
154
+ """
155
+ seen: set[str] = set()
156
+
157
+ def _emit(p):
158
+ s = str(p)
159
+ if s and s not in seen:
160
+ seen.add(s)
161
+ yield Path(p)
162
+
163
+ # 1. Canonical field for the .torrent file webup just wrote
164
+ tfp = media.get("torrent_file_path")
165
+ if isinstance(tfp, str) and tfp:
166
+ yield from _emit(tfp)
167
+
168
+ # 2. Derived from torrent_name (or file name) + tracker archive root
169
+ base_name = (
170
+ media.get("torrent_name")
171
+ or media.get("file_name") and Path(str(media["file_name"])).name
172
+ or match_path.name
173
+ )
174
+ archive = runtime_setting("WEBUP_TORRENT_ARCHIVE", "")
175
+ if not archive:
176
+ # Webup defaults to its working directory; on Ultra.cc HOME is the
177
+ # canonical archive root since systemd unit doesn't override it.
178
+ archive = str(Path.home())
179
+ for tracker in ("ITT", "PTT", "SIS"):
180
+ yield from _emit(Path(archive) / tracker / f"{base_name}.torrent")
181
+
182
+ # 3. Inside the sandbox (webup occasionally writes alongside the source)
183
+ yield from _emit(match_path.parent / f"{base_name}.torrent")
184
+ yield from _emit(match_path.with_suffix(match_path.suffix + ".torrent"))
185
+
186
+
187
+ _TORRENT_MAX_BYTES = 16 * 1024 * 1024 # 16 MiB; .torrent files are tiny
188
+
189
+
190
+ def _normalize_announce_in_torrent(path: Path) -> bool:
191
+ """Strip ``//announce/`` -> ``/announce/`` in a .torrent file's tracker URLs.
192
+
193
+ Preserves the info dict bytes verbatim so the infohash stays stable.
194
+ Returns True iff the file was modified. Fails fast on non-bencode files
195
+ (e.g. when a candidate path accidentally points at a multi-GiB media
196
+ source) by checking the magic byte and the file size before reading.
197
+ """
198
+ try:
199
+ size = path.stat().st_size
200
+ except OSError:
201
+ return False
202
+ if size == 0 or size > _TORRENT_MAX_BYTES:
203
+ return False
204
+ with path.open("rb") as f:
205
+ magic = f.read(1)
206
+ if magic != b"d":
207
+ return False
208
+ raw = magic + f.read()
209
+ if not raw.startswith(b"d"):
210
+ return False
211
+ try:
212
+ decoded, end = _bdecode(raw, 0)
213
+ except (ValueError, IndexError):
214
+ return False
215
+ if end != len(raw) or not isinstance(decoded, dict):
216
+ return False
217
+
218
+ changed = False
219
+
220
+ if isinstance(decoded.get(b"announce"), bytes) and _BAD_ANNOUNCE in decoded[b"announce"]:
221
+ decoded[b"announce"] = decoded[b"announce"].replace(_BAD_ANNOUNCE, _GOOD_ANNOUNCE)
222
+ changed = True
223
+
224
+ if isinstance(decoded.get(b"announce-list"), list):
225
+ new_list = []
226
+ for tier in decoded[b"announce-list"]:
227
+ if isinstance(tier, list):
228
+ new_tier = []
229
+ for u in tier:
230
+ if isinstance(u, bytes) and _BAD_ANNOUNCE in u:
231
+ new_tier.append(u.replace(_BAD_ANNOUNCE, _GOOD_ANNOUNCE))
232
+ changed = True
233
+ else:
234
+ new_tier.append(u)
235
+ new_list.append(new_tier)
236
+ else:
237
+ new_list.append(tier)
238
+ decoded[b"announce-list"] = new_list
239
+
240
+ if not changed:
241
+ return False
242
+
243
+ # Find the byte range of the original "info" value to copy it verbatim,
244
+ # avoiding any infohash drift from re-encoding nested structures.
245
+ info_value_bytes: bytes | None = None
246
+ idx = 1
247
+ while idx < len(raw) and raw[idx:idx+1] != b"e":
248
+ colon = raw.index(b":", idx)
249
+ klen = int(raw[idx:colon])
250
+ kstart = colon + 1
251
+ kend = kstart + klen
252
+ key = raw[kstart:kend]
253
+ _, vend = _bdecode(raw, kend)
254
+ if key == b"info":
255
+ info_value_bytes = raw[kend:vend]
256
+ idx = vend
257
+
258
+ out = bytearray(b"d")
259
+ for key in sorted(decoded.keys()):
260
+ out += _bencode(key)
261
+ if key == b"info" and info_value_bytes is not None:
262
+ out += info_value_bytes
263
+ else:
264
+ out += _bencode(decoded[key])
265
+ out += b"e"
266
+
267
+ path.write_bytes(bytes(out))
268
+ return True
269
+
270
+
271
+ def _overall_pct(phase: str, sub_pct: float = 0.0) -> float:
272
+ """Map (phase, 0..100 sub-progress) to overall 0..100 across all phases."""
273
+ cumulative = 0.0
274
+ for p in _PHASE_ORDER:
275
+ if p == phase:
276
+ return min(100.0, cumulative + (max(0.0, min(sub_pct, 100.0)) / 100.0) * _PHASE_WEIGHTS[p])
277
+ cumulative += _PHASE_WEIGHTS[p]
278
+ return 100.0
279
+
280
+
281
+ def _progress_event(phase: str, sub_pct: float = 0.0, *, label: str | None = None) -> dict[str, Any]:
282
+ return {
283
+ "type": "progress",
284
+ "phase": phase,
285
+ "label": label or _PHASE_LABELS.get(phase, phase),
286
+ "pct": round(_overall_pct(phase, sub_pct), 1),
287
+ "sub_pct": round(max(0.0, min(sub_pct, 100.0)), 1),
288
+ }
289
+
290
+
291
+ def _ensure_str(v: Any) -> str:
292
+ return "" if v is None else str(v)
293
+
294
+
295
+ def _media_for_path(scan_results: dict[str, Any], target: Path) -> dict[str, Any] | None:
296
+ """Find the Media dict in /scan response that matches the requested path.
297
+
298
+ Webup builds Media with folder=scan_path and subfolder=<entry name>; the
299
+ full path is `folder / subfolder`. Match by job_id (deterministic) or fall
300
+ back to path comparison. Path strings are normpath'd because webup applies
301
+ `os.path.normpath` to the scan path before deriving the job_id.
302
+ """
303
+ import os
304
+ s_target = os.path.normpath(str(target))
305
+ expected_id = compute_job_id(s_target)
306
+ items = (scan_results.get("results") or [])
307
+ for it in items:
308
+ if it.get("job_id") == expected_id:
309
+ return it
310
+ for it in items:
311
+ folder = it.get("folder") or ""
312
+ sub = it.get("subfolder") or ""
313
+ if os.path.normpath(str(Path(folder) / sub)) == s_target:
314
+ return it
315
+ if os.path.normpath(it.get("torrent_path") or "") == s_target:
316
+ return it
317
+ return None
318
+
319
+
320
+ async def _drain_buffered(
321
+ queue: asyncio.Queue, job_id: str, *, window: float = 1.5,
322
+ ):
323
+ """Yield (ev_dict, raw_msg) for messages currently in queue + arriving within
324
+ `window` seconds. Filters by job_id (or no job_id). Stops when queue is
325
+ empty AND `window` seconds have passed since the last message.
326
+ """
327
+ loop = asyncio.get_event_loop()
328
+ last_seen = loop.time()
329
+ while True:
330
+ try:
331
+ timeout = max(0.0, window - (loop.time() - last_seen))
332
+ msg = await asyncio.wait_for(queue.get(), timeout=timeout)
333
+ except asyncio.TimeoutError:
334
+ return
335
+ last_seen = loop.time()
336
+ jid = msg.get("job_id")
337
+ if jid not in (None, "", job_id):
338
+ continue
339
+ ev_kind, slug, text = classify_msg(msg)
340
+ yield {"type": "log", "data": text, "kind": ev_kind, "event": slug}, msg
341
+
342
+
343
+ async def stream_webup(
344
+ *,
345
+ client: WebupClient,
346
+ ws: WebupWSManager,
347
+ scan_lock: asyncio.Lock,
348
+ seeding_path: str,
349
+ kind: str,
350
+ tmdb_id: str = "",
351
+ do_seed: bool = True,
352
+ ) -> AsyncGenerator[dict, None]:
353
+ """Drive the webup pipeline for a single hardlinked path.
354
+
355
+ `kind`: 'movie' | 'episode' | 'series'. Webup figures out category by itself
356
+ from filename; we use this only to pick the scan parent dir:
357
+ - movie/episode → parent of the file
358
+ - series → the folder itself
359
+ """
360
+ target = Path(seeding_path)
361
+ is_series = kind == "series"
362
+ if is_series:
363
+ # For a series pack, webup builds ONE Media object per *subfolder* of
364
+ # SCAN_PATH (containing all episodes) — NOT per file inside that
365
+ # folder. So we point SCAN_PATH at the parent of the series folder
366
+ # and the match_path is the series folder itself.
367
+ target_dir = target if target.is_dir() else target.parent
368
+ scan_dir = str(target_dir.parent)
369
+ match_path = str(target_dir)
370
+ else:
371
+ scan_dir = str(target.parent)
372
+ match_path = str(target)
373
+
374
+ expected_job_id = compute_job_id(match_path)
375
+
376
+ queue = await ws.subscribe(WILDCARD)
377
+ locked = False
378
+
379
+ async def emit_msg_to_log(msg: dict[str, Any]) -> dict[str, Any]:
380
+ ev_kind, slug, text = classify_msg(msg)
381
+ return {"type": "log", "data": text, "kind": ev_kind, "event": slug}
382
+
383
+ try:
384
+ await scan_lock.acquire()
385
+ locked = True
386
+
387
+ yield _progress_event("setenv", 0)
388
+ yield {"type": "log", "data": f"webup: setting SCAN_PATH={scan_dir}"}
389
+ try:
390
+ await client.setenv("PREFS__SCAN_PATH", scan_dir)
391
+ except Exception as e:
392
+ yield {"type": "error", "data": f"setenv SCAN_PATH failed: {e}"}
393
+ yield {"type": "done", "exit_code": 1}
394
+ return
395
+
396
+ # Push PREFERRED_LANG before /scan so the language-gate check in
397
+ # tags_service.mediainfo_audio uses the correct value even when the
398
+ # Media object is reconstructed from a stale Redis cache entry
399
+ # (same job_id = same path → Redis hit with old can_upload=False).
400
+ preferred_lang = runtime_setting("PREFERRED_LANG", "ita")
401
+ try:
402
+ await client.setenv("PREFS__PREFERRED_LANG", preferred_lang)
403
+ except Exception as e:
404
+ yield {"type": "log", "data": f"webup: setenv PREFERRED_LANG failed (non-fatal): {e}", "kind": "warn"}
405
+ yield _progress_event("setenv", 100)
406
+
407
+ yield _progress_event("scan", 0)
408
+ yield {"type": "log", "data": "webup: /scan…"}
409
+ try:
410
+ scan_result = await asyncio.wait_for(client.scan(), timeout=SCAN_TIMEOUT)
411
+ except asyncio.TimeoutError:
412
+ yield {"type": "error", "data": f"/scan timeout after {SCAN_TIMEOUT}s"}
413
+ yield {"type": "done", "exit_code": 1}
414
+ return
415
+ except Exception as e:
416
+ yield {"type": "error", "data": f"/scan failed: {e}"}
417
+ yield {"type": "done", "exit_code": 1}
418
+ return
419
+
420
+ media = _media_for_path(scan_result, Path(match_path))
421
+ if not media:
422
+ n_items = len(scan_result.get("results") or [])
423
+ hint = ""
424
+ if n_items == 0:
425
+ hint = (
426
+ " — webup's /scan dropped it. Common causes: "
427
+ "ffmpeg missing (screenshots fail), TMDB/TVDB API key invalid, "
428
+ "or image host upload failed. Check webup logs."
429
+ )
430
+ yield {
431
+ "type": "error",
432
+ "data": f"webup: no Media for {match_path} in scan results "
433
+ f"(got {n_items} items){hint}",
434
+ }
435
+ yield {"type": "done", "exit_code": 1}
436
+ return
437
+
438
+ job_id = str(media.get("job_id") or expected_job_id)
439
+ await ws.rekey(WILDCARD, job_id, queue)
440
+
441
+ webup_tmdb = _ensure_str(media.get("tmdb_id"))
442
+ wanted_tmdb = _ensure_str(tmdb_id)
443
+ if wanted_tmdb and wanted_tmdb != webup_tmdb:
444
+ yield {"type": "log", "data": f"webup: override tmdb_id {webup_tmdb} → {wanted_tmdb}"}
445
+ try:
446
+ await client.set_tmdbid(job_id, wanted_tmdb)
447
+ except Exception as e:
448
+ yield {"type": "log", "data": f"webup: settmdbid failed (continuing): {e}"}
449
+
450
+ yield {"type": "log", "data": f"webup: job_id={job_id} title={media.get('title')!r}"}
451
+
452
+ # Webup 0.0.25 bug workaround: `tags_service.mediainfo_audio` only
453
+ # ever flips can_upload to False — never back to True — so a file
454
+ # whose FIRST audio track isn't in PREFERRED_LANG is silently
455
+ # rejected even when a later track matches. Re-check ourselves and
456
+ # patch the Redis-backed Media record before /maketorrent.
457
+ try:
458
+ fix = await maybe_force_can_upload(job_id, preferred_lang)
459
+ if fix.get("patched"):
460
+ yield {
461
+ "type": "log", "kind": "ok",
462
+ "data": f"webup: {fix['reason']}",
463
+ }
464
+ except Exception as exc:
465
+ yield {
466
+ "type": "log", "kind": "warn",
467
+ "data": f"webup: can_upload patch skipped ({exc!r})",
468
+ }
469
+
470
+ # Webup 0.0.25 bug workaround: the tracker display_name is built from
471
+ # `PREFS__TAG_POSITION_SERIE`, but webup reads that into a module-global
472
+ # `settings` captured at import (media.py / tags_service.py), so a
473
+ # corrected tag order only takes effect after a webup *restart*. To make
474
+ # the season label appear without restarting webup, patch the Redis job's
475
+ # display_name directly, inserting the S##(E##) token after the title.
476
+ try:
477
+ sinj = await maybe_inject_season(job_id)
478
+ if sinj.get("patched"):
479
+ yield {
480
+ "type": "log", "kind": "ok",
481
+ "data": f"webup: {sinj['reason']}",
482
+ }
483
+ except Exception as exc:
484
+ yield {
485
+ "type": "log", "kind": "warn",
486
+ "data": f"webup: season inject skipped ({exc!r})",
487
+ }
488
+
489
+ yield _progress_event("scan", 100)
490
+
491
+ # ---- Maketorrent ----
492
+ # Webup runs the torrent build inside the HTTP request; HTTP 200 means
493
+ # it's done. Progress messages stream via WS while the call is in
494
+ # flight; we drain them after the HTTP returns.
495
+ yield _progress_event("maketorrent", 0)
496
+ yield {"type": "log", "data": "webup: /maketorrent…"}
497
+ maketorrent_task = asyncio.create_task(
498
+ asyncio.wait_for(client.maketorrent(job_id), timeout=PHASE_TIMEOUT)
499
+ )
500
+
501
+ # Drain WS messages while maketorrent is running so the user sees the
502
+ # progress bar move in real time. Webup emits posterLogMessages with
503
+ # "[New torrent] FILE - N" where N is the percentage.
504
+ loop = asyncio.get_event_loop()
505
+ while not maketorrent_task.done():
506
+ try:
507
+ msg = await asyncio.wait_for(queue.get(), timeout=0.5)
508
+ except asyncio.TimeoutError:
509
+ continue
510
+ jid = msg.get("job_id")
511
+ if jid not in (None, "", job_id):
512
+ continue
513
+ ev_kind, slug, text = classify_msg(msg)
514
+ yield {"type": "log", "data": text, "kind": ev_kind, "event": slug}
515
+ m = _RX_MAKETORRENT_PCT.search(text)
516
+ if m:
517
+ try:
518
+ yield _progress_event("maketorrent", float(m.group(1)))
519
+ except ValueError:
520
+ pass
521
+ if is_terminal_failure(msg):
522
+ maketorrent_task.cancel()
523
+ yield {"type": "error", "data": f"/maketorrent reported failure: {msg.get('message')!r}"}
524
+ yield {"type": "done", "exit_code": 1}
525
+ return
526
+
527
+ try:
528
+ await maketorrent_task
529
+ except asyncio.TimeoutError:
530
+ yield {"type": "error", "data": f"/maketorrent timeout after {PHASE_TIMEOUT}s"}
531
+ yield {"type": "done", "exit_code": 1}
532
+ return
533
+ except Exception as e:
534
+ yield {"type": "error", "data": f"/maketorrent failed: {e}"}
535
+ yield {"type": "done", "exit_code": 1}
536
+ return
537
+
538
+ # Drain any final messages buffered after HTTP returned.
539
+ async for ev, msg in _drain_buffered(queue, job_id, window=0.8):
540
+ yield ev
541
+ if is_terminal_failure(msg):
542
+ yield {"type": "error", "data": f"/maketorrent reported failure: {msg.get('message')!r}"}
543
+ yield {"type": "done", "exit_code": 1}
544
+ return
545
+
546
+ # Workaround for upstream webup bug: announce URL has `//announce/`
547
+ # because pydantic HttpUrl appends a trailing slash before webup's
548
+ # f-string concatenation in api_data.py. Patch the .torrent on disk
549
+ # before /seed so qBittorrent and the tracker see a clean URL.
550
+ candidates = list(_candidate_torrent_paths(media, Path(match_path)))
551
+ patched_any = False
552
+ for tp in candidates:
553
+ try:
554
+ if tp.exists() and _normalize_announce_in_torrent(tp):
555
+ patched_any = True
556
+ yield {
557
+ "type": "log",
558
+ "data": f"webup: patched announce URL in {tp.name} "
559
+ "(workaround pydantic HttpUrl trailing-slash bug)",
560
+ "kind": "warn",
561
+ "event": "torrent.announce_patch",
562
+ }
563
+ except Exception as e:
564
+ yield {
565
+ "type": "log",
566
+ "data": f"webup: announce-URL patch failed for {tp}: {e}",
567
+ "kind": "warn",
568
+ }
569
+ if not patched_any and candidates:
570
+ _log.debug("announce patch: no candidate matched. tried=%s", [str(p) for p in candidates])
571
+
572
+ yield _progress_event("maketorrent", 100)
573
+
574
+ # ---- Upload ----
575
+ # Dry-run mode (U3DP_DRY_RUN_TRACKER=1) skips the actual tracker call so
576
+ # WSL/dev can exercise the full pipeline without polluting the live
577
+ # tracker. Maketorrent and seed still run, the .torrent ends up in qBit.
578
+ dry_run = runtime_setting("U3DP_DRY_RUN_TRACKER", "0") in {"1", "true", "True", "yes"}
579
+ if dry_run:
580
+ yield _progress_event("upload", 0)
581
+ yield {
582
+ "type": "log",
583
+ "data": "webup: /upload SKIPPED (U3DP_DRY_RUN_TRACKER=1)",
584
+ "kind": "warn",
585
+ "event": "upload.dryrun",
586
+ }
587
+ yield _progress_event("upload", 100)
588
+ else:
589
+ yield _progress_event("upload", 0)
590
+ yield {"type": "log", "data": "webup: /upload…"}
591
+ try:
592
+ upload_http_resp = await asyncio.wait_for(client.upload(job_id), timeout=PHASE_TIMEOUT)
593
+ except asyncio.TimeoutError:
594
+ yield {"type": "error", "data": f"/upload timeout after {PHASE_TIMEOUT}s"}
595
+ yield {"type": "done", "exit_code": 1}
596
+ return
597
+ except Exception as e:
598
+ yield {"type": "error", "data": f"/upload failed: {e}"}
599
+ yield {"type": "done", "exit_code": 1}
600
+ return
601
+
602
+ # webup's /upload endpoint returns JSON null regardless of outcome
603
+ # (FastAPI default for endpoints with no explicit return value).
604
+ # The actual tracker result comes exclusively through WebSocket
605
+ # posterLogMessage events broadcast inside UploadUseCase.execute().
606
+ # Do NOT treat None here as an error — drain WS for the real status.
607
+
608
+ _log.info(
609
+ "upload: /upload HTTP done, ws_connected=%s queue_size=%d — draining WS",
610
+ ws.connected, queue.qsize(),
611
+ )
612
+
613
+ upload_failed = False
614
+ upload_succeeded = False
615
+ async for ev, msg in _drain_buffered(queue, job_id, window=8.0):
616
+ yield ev
617
+ if is_terminal_failure(msg):
618
+ upload_failed = True
619
+ elif is_terminal_success(msg):
620
+ upload_succeeded = True
621
+
622
+ _log.info(
623
+ "upload: drain done — succeeded=%s failed=%s queue_size=%d ws_connected=%s",
624
+ upload_succeeded, upload_failed, queue.qsize(), ws.connected,
625
+ )
626
+
627
+ if upload_succeeded:
628
+ pass # WS confirmed success
629
+ elif upload_failed:
630
+ yield {"type": "error", "data": "/upload tracker rejected — see log above for details"}
631
+ yield {"type": "done", "exit_code": 1}
632
+ return
633
+ else:
634
+ # No posterLogMessage arrived within 8 s.
635
+ # This is a WS delivery issue (timing race, connection glitch)
636
+ # rather than a definitive upload failure — the HTTP 200 from
637
+ # webup means execute() completed and the tracker call was made.
638
+ # Log a warning and proceed to seed so the workflow still
639
+ # completes; the operator should verify the tracker manually.
640
+ yield {
641
+ "type": "log",
642
+ "data": (
643
+ f"webup: /upload — no WS status received within 8 s "
644
+ f"(ws_connected={ws.connected}, queue_size={queue.qsize()}). "
645
+ "Proceeding to seed. Check the tracker to confirm the upload succeeded."
646
+ ),
647
+ "kind": "warn",
648
+ "event": "upload.tracker_response",
649
+ }
650
+ yield _progress_event("upload", 100)
651
+
652
+ # ---- Seed (optional) ----
653
+ if do_seed:
654
+ yield _progress_event("seed", 0)
655
+ yield {"type": "log", "data": "webup: /seed…"}
656
+ try:
657
+ code, body = await client.seed(job_id)
658
+ except Exception as e:
659
+ yield {"type": "log", "data": f"webup: /seed failed (non-fatal): {e}", "kind": "warn"}
660
+ code = 599
661
+ if code == 200:
662
+ yield {"type": "log", "data": "webup: torrent seeded", "kind": "ok", "event": "upload.qbit"}
663
+ elif code in (503, 409, 404):
664
+ yield {
665
+ "type": "log",
666
+ "data": f"webup: /seed returned {code} (not fatal)",
667
+ "kind": "warn",
668
+ "event": "upload.qbit",
669
+ }
670
+ else:
671
+ yield {"type": "log", "data": f"webup: /seed returned {code}", "kind": "warn"}
672
+ yield _progress_event("seed", 100)
673
+
674
+ yield {"type": "done", "exit_code": 0}
675
+ finally:
676
+ try:
677
+ await ws.unsubscribe(WILDCARD, queue)
678
+ except Exception:
679
+ pass
680
+ try:
681
+ await ws.unsubscribe(expected_job_id, queue)
682
+ except Exception:
683
+ pass
684
+ if locked:
685
+ scan_lock.release()
686
+
687
+
688
+ async def stream_webup_batch(
689
+ *,
690
+ client: WebupClient,
691
+ ws: WebupWSManager,
692
+ scan_lock: asyncio.Lock,
693
+ folder: str,
694
+ ) -> AsyncGenerator[dict, None]:
695
+ """Recursive scan + processall for a folder of media.
696
+
697
+ Maps to the legacy `unit3dup -b -scan <folder>` mode used by quickupload.
698
+ Yields a stream of log/progress events; emits one synthetic 'done' per
699
+ completed job and a final 'done_all' event with overall counts.
700
+ """
701
+ target = Path(folder)
702
+ queue = await ws.subscribe(WILDCARD)
703
+ locked = False
704
+ try:
705
+ await scan_lock.acquire()
706
+ locked = True
707
+
708
+ yield {"type": "log", "data": f"webup: setting SCAN_PATH={target}"}
709
+ await client.setenv("PREFS__SCAN_PATH", str(target))
710
+
711
+ yield {"type": "log", "data": "webup: /scan (batch)…"}
712
+ scan_result = await asyncio.wait_for(client.scan(), timeout=SCAN_TIMEOUT)
713
+ items = scan_result.get("results") or []
714
+ if not items:
715
+ yield {"type": "error", "data": "webup: /scan returned no items"}
716
+ yield {"type": "done", "exit_code": 1}
717
+ return
718
+
719
+ job_ids = [str(it.get("job_id")) for it in items if it.get("job_id")]
720
+ path_by_job = {str(it.get("job_id")): str(Path(it.get("folder") or "") / (it.get("subfolder") or ""))
721
+ for it in items if it.get("job_id")}
722
+ yield {"type": "log", "data": f"webup: scan found {len(job_ids)} items"}
723
+
724
+ from .webup_client import compute_job_list_id
725
+ job_list_id = compute_job_list_id(str(target))
726
+ # In dry-run mode `/processall` would still call the tracker for every
727
+ # item. Run only `/maketorrent` + `/seed` per job instead, skipping the
728
+ # tracker upload. Loops sequentially to keep the WS log readable.
729
+ dry_run = runtime_setting("U3DP_DRY_RUN_TRACKER", "0") in {"1", "true", "True", "yes"}
730
+ if dry_run:
731
+ yield {
732
+ "type": "log",
733
+ "data": "webup: batch /upload SKIPPED (U3DP_DRY_RUN_TRACKER=1)",
734
+ "kind": "warn",
735
+ "event": "upload.dryrun",
736
+ }
737
+ for jid in job_ids:
738
+ try:
739
+ await asyncio.wait_for(client.maketorrent(jid), timeout=PHASE_TIMEOUT)
740
+ except Exception as e:
741
+ yield {"type": "log", "data": f"maketorrent {jid[:8]} failed: {e}", "kind": "error"}
742
+ try:
743
+ await client.seed(jid)
744
+ except Exception as e:
745
+ yield {"type": "log", "data": f"seed {jid[:8]} failed: {e}", "kind": "warn"}
746
+ yield {"type": "job_done", "job_id": jid, "path": path_by_job.get(jid, ""), "exit_code": 0}
747
+ yield {"type": "done", "exit_code": 0, "ok": len(job_ids), "fail": 0, "dry_run": True}
748
+ return
749
+
750
+ yield {"type": "log", "data": "webup: /processall…"}
751
+ await client.processall(job_list_id)
752
+
753
+ # Drain until each job_id has a terminal success/failure.
754
+ loop = asyncio.get_event_loop()
755
+ pending = set(job_ids)
756
+ results: dict[str, int] = {}
757
+ deadline = loop.time() + PHASE_TIMEOUT * 2
758
+ while pending:
759
+ remaining = deadline - loop.time()
760
+ if remaining <= 0:
761
+ for j in pending:
762
+ yield {"type": "log", "data": f"webup: timeout for job {j}", "kind": "warn"}
763
+ results[j] = 1
764
+ break
765
+ try:
766
+ msg = await asyncio.wait_for(queue.get(), timeout=remaining)
767
+ except asyncio.TimeoutError:
768
+ continue
769
+ ev_kind, slug, text = classify_msg(msg)
770
+ yield {"type": "log", "data": text, "kind": ev_kind, "event": slug}
771
+ jid = msg.get("job_id")
772
+ if jid in pending:
773
+ if is_terminal_success(msg):
774
+ pending.discard(jid)
775
+ results[jid] = 0
776
+ yield {"type": "job_done", "job_id": jid, "path": path_by_job.get(jid, ""), "exit_code": 0}
777
+ elif is_terminal_failure(msg):
778
+ pending.discard(jid)
779
+ results[jid] = 1
780
+ yield {"type": "job_done", "job_id": jid, "path": path_by_job.get(jid, ""), "exit_code": 1}
781
+
782
+ ok_count = sum(1 for v in results.values() if v == 0)
783
+ fail_count = len(results) - ok_count
784
+ yield {
785
+ "type": "done",
786
+ "exit_code": 0 if fail_count == 0 else 1,
787
+ "ok": ok_count,
788
+ "fail": fail_count,
789
+ }
790
+ finally:
791
+ try:
792
+ await ws.unsubscribe(WILDCARD, queue)
793
+ except Exception:
794
+ pass
795
+ if locked:
796
+ scan_lock.release()