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.
- unit3dprep/__init__.py +0 -0
- unit3dprep/cli.py +256 -0
- unit3dprep/core.py +556 -0
- unit3dprep/i18n.py +151 -0
- unit3dprep/media.py +278 -0
- unit3dprep/upload.py +146 -0
- unit3dprep/web/__init__.py +0 -0
- unit3dprep/web/_env.py +40 -0
- unit3dprep/web/api/__init__.py +1 -0
- unit3dprep/web/api/auth.py +36 -0
- unit3dprep/web/api/fs.py +64 -0
- unit3dprep/web/api/library.py +503 -0
- unit3dprep/web/api/logs.py +42 -0
- unit3dprep/web/api/queue.py +54 -0
- unit3dprep/web/api/quickupload.py +153 -0
- unit3dprep/web/api/search.py +40 -0
- unit3dprep/web/api/settings.py +77 -0
- unit3dprep/web/api/tmdb.py +77 -0
- unit3dprep/web/api/trackers.py +30 -0
- unit3dprep/web/api/uploaded.py +26 -0
- unit3dprep/web/api/version.py +754 -0
- unit3dprep/web/api/webup.py +59 -0
- unit3dprep/web/api/wizard.py +512 -0
- unit3dprep/web/app.py +201 -0
- unit3dprep/web/auth.py +41 -0
- unit3dprep/web/clients.py +167 -0
- unit3dprep/web/config.py +932 -0
- unit3dprep/web/db.py +184 -0
- unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
- unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
- unit3dprep/web/dist/index.html +14 -0
- unit3dprep/web/duplicate_check.py +92 -0
- unit3dprep/web/lang_cache.py +118 -0
- unit3dprep/web/logbuf.py +164 -0
- unit3dprep/web/tmdb_cache.py +116 -0
- unit3dprep/web/trackers.py +177 -0
- unit3dprep/web/webup_client.py +190 -0
- unit3dprep/web/webup_job_fix.py +231 -0
- unit3dprep/web/webup_logclass.py +107 -0
- unit3dprep/web/webup_orchestrator.py +796 -0
- unit3dprep/web/webup_ws.py +141 -0
- unit3dprep-1.0.4.dist-info/METADATA +819 -0
- unit3dprep-1.0.4.dist-info/RECORD +50 -0
- unit3dprep-1.0.4.dist-info/WHEEL +5 -0
- unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
- unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
- 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()
|