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,59 @@
1
+ """Unit3DWebUp bridge endpoints.
2
+
3
+ - GET /api/webup/health → reachability + version readout
4
+ - POST /api/webup/sync → push shared .env mapped subset to webup runtime via /setenv
5
+ - GET /api/webup/setting → proxy webup's /setting (read user preferences)
6
+ - POST /api/webup/filter → proxy webup's /filter (search tracker for title)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from fastapi import APIRouter, Request
11
+ from fastapi.responses import JSONResponse
12
+ from pydantic import BaseModel
13
+
14
+ from ..config import bootstrap_webup_env
15
+ from ..webup_client import get_client
16
+
17
+
18
+ router = APIRouter(prefix="/api", tags=["webup"])
19
+
20
+
21
+ @router.get("/webup/health")
22
+ async def health(request: Request):
23
+ client = getattr(request.app.state, "webup", None) or get_client()
24
+ info = await client.health(force=True)
25
+ info["base_url"] = client.base
26
+ ws = getattr(request.app.state, "webup_ws", None)
27
+ info["ws_connected"] = bool(ws and ws.connected)
28
+ return JSONResponse(info)
29
+
30
+
31
+ @router.post("/webup/sync")
32
+ async def sync(request: Request):
33
+ client = getattr(request.app.state, "webup", None) or get_client()
34
+ pushed = await bootstrap_webup_env(client)
35
+ return JSONResponse({"pushed": pushed, "count": len(pushed)})
36
+
37
+
38
+ @router.get("/webup/setting")
39
+ async def setting(request: Request):
40
+ client = getattr(request.app.state, "webup", None) or get_client()
41
+ try:
42
+ data = await client.setting()
43
+ return JSONResponse(data)
44
+ except Exception as e:
45
+ return JSONResponse({"error": str(e)}, status_code=502)
46
+
47
+
48
+ class FilterBody(BaseModel):
49
+ title: str
50
+
51
+
52
+ @router.post("/webup/filter")
53
+ async def filter_search(request: Request, body: FilterBody):
54
+ client = getattr(request.app.state, "webup", None) or get_client()
55
+ try:
56
+ data = await client.filter_search(body.title)
57
+ return JSONResponse(data)
58
+ except Exception as e:
59
+ return JSONResponse({"error": str(e)}, status_code=502)
@@ -0,0 +1,512 @@
1
+ """Wizard upload flow: audio → TMDB → names → hardlink → upload (SSE)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import secrets
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any, AsyncGenerator
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from fastapi.responses import JSONResponse
14
+ from pydantic import BaseModel
15
+ from sse_starlette.sse import EventSourceResponse
16
+
17
+ from ...core import (
18
+ extract_specs,
19
+ has_italian_audio,
20
+ iter_video_files,
21
+ map_source,
22
+ tmdb_fetch_bilingual,
23
+ tmdb_poster_url,
24
+ tmdb_year,
25
+ build_name,
26
+ )
27
+ from ...upload import (
28
+ build_episode_names,
29
+ build_movie_name_from_file,
30
+ do_hardlink_movie,
31
+ do_hardlink_series,
32
+ )
33
+ from ...i18n import get_request_lang, t as _i18n_t
34
+ from .. import config as web_config
35
+ from ..db import record_upload, update_exit_code
36
+ from ..duplicate_check import find_duplicate
37
+ from ..logbuf import emit as log_emit
38
+ from ..webup_orchestrator import stream_webup
39
+
40
+ TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "")
41
+
42
+ router = APIRouter(prefix="/api", tags=["wizard"])
43
+
44
+ _sessions: dict[str, dict[str, Any]] = {}
45
+ _created: dict[str, float] = {}
46
+ _TTL = 3600
47
+
48
+
49
+ def _cleanup():
50
+ now = time.time()
51
+ for t in [t for t, ct in _created.items() if now - ct > _TTL]:
52
+ _sessions.pop(t, None)
53
+ _created.pop(t, None)
54
+
55
+
56
+ def _create(state: dict[str, Any]) -> str:
57
+ _cleanup()
58
+ tok = secrets.token_urlsafe(24)
59
+ _sessions[tok] = state
60
+ _created[tok] = time.time()
61
+ return tok
62
+
63
+
64
+ def _get(tok: str, lang: str | None = None) -> dict[str, Any]:
65
+ s = _sessions.get(tok)
66
+ if s is None:
67
+ raise HTTPException(404, _i18n_t("err.wizard_session_expired", lang))
68
+ return s
69
+
70
+
71
+ def _validate_path(p: str, lang: str | None = None) -> Path:
72
+ from ...media import media_root, seedings_root
73
+ resolved = Path(p).resolve()
74
+ allowed = [media_root().resolve(), seedings_root().resolve()]
75
+ if not any(str(resolved).startswith(str(a)) for a in allowed):
76
+ raise HTTPException(403, _i18n_t("err.path_outside", lang))
77
+ if not resolved.exists():
78
+ raise HTTPException(404, _i18n_t("err.path_not_found_at", lang, path=str(resolved)))
79
+ return resolved
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Bodies
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ class StartBody(BaseModel):
88
+ path: str
89
+ category: str
90
+ kind: str # movie|series|episode
91
+ tmdb_id: str = ""
92
+ tmdb_kind: str = ""
93
+ hardlink_only: bool = False
94
+
95
+
96
+ class TmdbBody(BaseModel):
97
+ tmdb_id: str
98
+ tmdb_kind: str = "movie"
99
+
100
+
101
+ class NamesBody(BaseModel):
102
+ final_names: dict[str, str]
103
+ folder_name: str = ""
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Routes
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ @router.post("/wizard/start")
112
+ async def wizard_start(request: Request, body: StartBody):
113
+ lang = get_request_lang(request)
114
+ p = _validate_path(body.path, lang)
115
+ if body.kind not in {"movie", "series", "episode"}:
116
+ raise HTTPException(400, _i18n_t("err.invalid_kind", lang))
117
+ if body.kind == "episode" and not p.is_file():
118
+ raise HTTPException(400, _i18n_t("err.episode_requires_file", lang))
119
+ state: dict[str, Any] = {
120
+ "path": str(p),
121
+ "category": body.category,
122
+ "kind": body.kind,
123
+ "step": "audio",
124
+ "audio_ok": False,
125
+ "audio_override": False,
126
+ "tmdb_id": body.tmdb_id.strip(),
127
+ "tmdb_kind": body.tmdb_kind or ("tv" if body.kind != "movie" else "movie"),
128
+ "tmdb_title": "",
129
+ "tmdb_year": "",
130
+ "tmdb_poster": "",
131
+ "tmdb_overview": "",
132
+ "final_names": {},
133
+ "folder_name": "",
134
+ "seeding_path": "",
135
+ "upload_done": False,
136
+ "exit_code": None,
137
+ "hardlink_only": body.hardlink_only,
138
+ "duplicate": None,
139
+ "duplicate_confirmed": False,
140
+ }
141
+ tok = _create(state)
142
+ return JSONResponse({"token": tok, "state": state})
143
+
144
+
145
+ @router.get("/wizard/{tok}")
146
+ async def wizard_state(tok: str):
147
+ return JSONResponse(_get(tok))
148
+
149
+
150
+ @router.get("/wizard/{tok}/audio")
151
+ async def wizard_audio(tok: str):
152
+ state = _get(tok)
153
+ path = Path(state["path"])
154
+ files = [path] if path.is_file() else list(iter_video_files(path))
155
+
156
+ async def generate() -> AsyncGenerator[dict, None]:
157
+ loop = asyncio.get_event_loop()
158
+ all_ok = True
159
+ for f in files:
160
+ try:
161
+ ok = await loop.run_in_executor(None, has_italian_audio, f)
162
+ payload = {"file": f.name, "ok": ok}
163
+ except Exception as e:
164
+ ok = False
165
+ payload = {"file": f.name, "ok": False, "error": str(e)}
166
+ if not ok:
167
+ all_ok = False
168
+ yield {"event": "file_result", "data": json.dumps(payload)}
169
+ await asyncio.sleep(0)
170
+ state["audio_ok"] = all_ok
171
+ state["step"] = "tmdb" if all_ok else "audio_failed"
172
+ yield {"event": "done", "data": json.dumps({"all_ok": all_ok, "total": len(files)})}
173
+
174
+ return EventSourceResponse(generate())
175
+
176
+
177
+ @router.post("/wizard/{tok}/audio-override")
178
+ async def wizard_audio_override(tok: str):
179
+ state = _get(tok)
180
+ state["audio_ok"] = True
181
+ state["audio_override"] = True
182
+ state["step"] = "tmdb"
183
+ return JSONResponse({"ok": True})
184
+
185
+
186
+ @router.post("/wizard/{tok}/tmdb")
187
+ async def wizard_tmdb(request: Request, tok: str, body: TmdbBody):
188
+ lang = get_request_lang(request)
189
+ state = _get(tok, lang)
190
+ if not state["audio_ok"]:
191
+ raise HTTPException(400, _i18n_t("err.audio_check_not_passed", lang))
192
+ loop = asyncio.get_event_loop()
193
+ try:
194
+ data = await loop.run_in_executor(
195
+ None, tmdb_fetch_bilingual, body.tmdb_kind, body.tmdb_id, TMDB_API_KEY
196
+ )
197
+ except Exception as e:
198
+ raise HTTPException(502, _i18n_t("err.tmdb_fetch_failed", lang, error=str(e)))
199
+ title = data.get("title") or ""
200
+ year = tmdb_year(data, body.tmdb_kind)
201
+ state["tmdb_id"] = body.tmdb_id
202
+ state["tmdb_kind"] = body.tmdb_kind
203
+ state["tmdb_title"] = title
204
+ state["tmdb_year"] = year
205
+ state["tmdb_poster"] = tmdb_poster_url(data)
206
+ state["tmdb_overview"] = (data.get("overview") or "")[:300]
207
+ state["step"] = "names"
208
+
209
+ proposed = await _build_proposed_names(state)
210
+ state["final_names"] = proposed
211
+ return JSONResponse({
212
+ "ok": True,
213
+ "tmdb": {
214
+ "title": title,
215
+ "title_en": data.get("title_en", ""),
216
+ "original_title": data.get("original_title", ""),
217
+ "year": year,
218
+ "overview": state["tmdb_overview"],
219
+ "overview_en": (data.get("overview_en") or "")[:300],
220
+ "poster": state["tmdb_poster"],
221
+ },
222
+ "proposed": proposed,
223
+ "folder_name": state["folder_name"],
224
+ })
225
+
226
+
227
+ async def _build_proposed_names(state: dict[str, Any]) -> dict[str, str]:
228
+ from guessit import guessit as _guessit
229
+ loop = asyncio.get_event_loop()
230
+ path = Path(state["path"])
231
+ kind = state["kind"]
232
+ title = state["tmdb_title"]
233
+ year = state["tmdb_year"]
234
+ if kind == "movie":
235
+ files = [path] if path.is_file() else list(iter_video_files(path))
236
+ proposed: dict[str, str] = {}
237
+ for vf in files:
238
+ name = await loop.run_in_executor(
239
+ None, build_movie_name_from_file, vf, title, year
240
+ )
241
+ proposed[str(vf)] = name
242
+ return proposed
243
+ if kind == "episode":
244
+ files = [path] if path.is_file() else list(iter_video_files(path))
245
+ if not files:
246
+ raise HTTPException(400, _i18n_t("err.no_video_episode"))
247
+ episode_file = files[0]
248
+ season_folder = episode_file.parent
249
+ folder_guess = dict(_guessit(season_folder.name))
250
+ result = await loop.run_in_executor(
251
+ None,
252
+ lambda: {str(k): v for k, v in build_episode_names(
253
+ season_folder, [episode_file], title, year, folder_guess
254
+ ).items()},
255
+ )
256
+ if not result:
257
+ fallback = await loop.run_in_executor(
258
+ None, build_movie_name_from_file, episode_file, title, ""
259
+ )
260
+ result = {str(episode_file): fallback}
261
+ return result
262
+ # series
263
+ folder_guess = dict(_guessit(path.name))
264
+ files = list(iter_video_files(path))
265
+ result = await loop.run_in_executor(
266
+ None,
267
+ lambda: {str(k): v for k, v in build_episode_names(
268
+ path, files, title, year, folder_guess
269
+ ).items()},
270
+ )
271
+ if files:
272
+ first = files[0]
273
+ g = dict(_guessit(first.name))
274
+ specs = extract_specs(first)
275
+ source, src_type = map_source(g)
276
+ tag = g.get("release_group", "") or folder_guess.get("release_group", "") or ""
277
+ # Season-pack folder name: include "S<NN>" right after the title.
278
+ # Prefer the season inferred from the first episode's filename;
279
+ # fall back to the folder's own guessit (`Season 1`, `S01`, etc.).
280
+ season = g.get("season") if g.get("season") is not None else folder_guess.get("season")
281
+ if isinstance(season, list):
282
+ season = season[0] if season else None
283
+ season_label = f"S{int(season):02d}" if season is not None else ""
284
+ folder_nm = build_name(
285
+ title=title, year="", se=season_label,
286
+ specs=specs, source=source, src_type=src_type, tag=tag,
287
+ )
288
+ state["folder_name"] = folder_nm
289
+ return result
290
+
291
+
292
+ @router.post("/wizard/{tok}/names")
293
+ async def wizard_names(tok: str, body: NamesBody):
294
+ state = _get(tok)
295
+ state["final_names"] = {k: v.strip() for k, v in body.final_names.items()}
296
+ if body.folder_name:
297
+ state["folder_name"] = body.folder_name.strip()
298
+ state["step"] = "duplicate_check"
299
+ return JSONResponse({"ok": True})
300
+
301
+
302
+ def _primary_source_size(state: dict[str, Any]) -> int | None:
303
+ """Bytes of the file we're about to upload (None for season packs)."""
304
+ if state["kind"] not in {"movie", "episode"}:
305
+ return None
306
+ path = Path(state["path"])
307
+ src = path if path.is_file() else next(iter(iter_video_files(path)), None)
308
+ if src is None:
309
+ return None
310
+ try:
311
+ return src.stat().st_size
312
+ except OSError:
313
+ return None
314
+
315
+
316
+ @router.post("/wizard/{tok}/duplicate-check")
317
+ async def wizard_duplicate_check(tok: str):
318
+ state = _get(tok)
319
+ cfg = web_config.load()
320
+ enabled = bool(cfg.get("W_DUPLICATE_CHECK", True))
321
+ state["duplicate"] = None
322
+ if not enabled or state["kind"] not in {"movie", "episode"}:
323
+ state["step"] = "hardlink"
324
+ return JSONResponse({"enabled": enabled, "duplicate": None})
325
+ size = _primary_source_size(state)
326
+ tmdb_id = state.get("tmdb_id", "")
327
+ tracker_url = (cfg.get("ITT_URL") or "").strip()
328
+ api_token = (cfg.get("ITT_APIKEY") or "").strip()
329
+ match = await find_duplicate(
330
+ tracker_url=tracker_url,
331
+ api_token=api_token,
332
+ tmdb_id=tmdb_id,
333
+ size_bytes=size,
334
+ )
335
+ if match is None:
336
+ state["step"] = "hardlink"
337
+ return JSONResponse({"enabled": True, "duplicate": None})
338
+ state["duplicate"] = match
339
+ return JSONResponse({"enabled": True, "duplicate": match})
340
+
341
+
342
+ @router.post("/wizard/{tok}/duplicate-confirm")
343
+ async def wizard_duplicate_confirm(tok: str):
344
+ state = _get(tok)
345
+ state["duplicate_confirmed"] = True
346
+ state["step"] = "hardlink"
347
+ return JSONResponse({"ok": True})
348
+
349
+
350
+ @router.post("/wizard/{tok}/duplicate-skip")
351
+ async def wizard_duplicate_skip(tok: str):
352
+ """User declined to upload after a duplicate was detected.
353
+
354
+ Records a "skipped" entry against the source path so the Library
355
+ hides the item (via the W_HIDE_UPLOADED filter that keys off
356
+ ``source_path``) and the Uploaded history shows a dedicated badge.
357
+ The hardlink is NOT created — the wizard ends here.
358
+ """
359
+ state = _get(tok)
360
+ duplicate = state.get("duplicate")
361
+ if not duplicate:
362
+ raise HTTPException(400, "no duplicate to skip")
363
+ path = Path(state["path"])
364
+ # Match the wizard_hardlink convention so the Library hide filter
365
+ # (which keys off uploaded_paths = {r.source_path}) picks it up:
366
+ # movie/series → state["path"] itself; episode → resolved video file.
367
+ if state["kind"] == "episode":
368
+ src = path if path.is_file() else next(iter(iter_video_files(path)), path)
369
+ source_path = str(src.resolve())
370
+ fallback_name = src.stem
371
+ else:
372
+ source_path = str(path.resolve())
373
+ fallback_name = path.name
374
+ final_name = next(iter(state.get("final_names", {}).values()), "") or fallback_name
375
+ await record_upload(
376
+ category=state["category"],
377
+ kind=state["kind"],
378
+ source_path=source_path,
379
+ seeding_path=source_path, # no hardlink — reuse src so the row is unique
380
+ tmdb_id=state.get("tmdb_id", ""),
381
+ title=state.get("tmdb_title", ""),
382
+ year=state.get("tmdb_year", ""),
383
+ final_name=final_name,
384
+ exit_code=0,
385
+ hardlink_only=False,
386
+ duplicate_skipped=True,
387
+ duplicate_info=duplicate,
388
+ )
389
+ log_emit(
390
+ "warn",
391
+ f"Duplicate skipped → {duplicate.get('name') or duplicate.get('id')}",
392
+ "wizard",
393
+ )
394
+ state["upload_done"] = True
395
+ state["exit_code"] = 0
396
+ state["step"] = "done"
397
+ return JSONResponse({"ok": True})
398
+
399
+
400
+ @router.post("/wizard/{tok}/hardlink")
401
+ async def wizard_hardlink(request: Request, tok: str):
402
+ lang = get_request_lang(request)
403
+ state = _get(tok, lang)
404
+ path = Path(state["path"])
405
+ kind = state["kind"]
406
+ final_names = state["final_names"]
407
+ loop = asyncio.get_event_loop()
408
+ try:
409
+ if kind == "movie":
410
+ if path.is_file():
411
+ src = path
412
+ else:
413
+ files = list(iter_video_files(path))
414
+ if not files:
415
+ raise HTTPException(400, _i18n_t("err.no_video", lang))
416
+ src = files[0]
417
+ final_name = next(iter(final_names.values()), src.stem)
418
+ target = await loop.run_in_executor(None, do_hardlink_movie, src, final_name)
419
+ state["seeding_path"] = str(target)
420
+ source_path = str(path.resolve())
421
+ elif kind == "episode":
422
+ src = path if path.is_file() else list(iter_video_files(path))[0]
423
+ final_name = next(iter(final_names.values()), src.stem)
424
+ target = await loop.run_in_executor(None, do_hardlink_movie, src, final_name)
425
+ state["seeding_path"] = str(target)
426
+ source_path = str(src.resolve())
427
+ else:
428
+ rename = {Path(k): v for k, v in final_names.items()}
429
+ folder = state.get("folder_name", path.name)
430
+ target = await loop.run_in_executor(None, do_hardlink_series, path, folder, rename)
431
+ state["seeding_path"] = str(target)
432
+ source_path = str(path.resolve())
433
+ state["step"] = "upload"
434
+ await record_upload(
435
+ category=state["category"], kind=kind,
436
+ source_path=source_path, seeding_path=state["seeding_path"],
437
+ tmdb_id=state.get("tmdb_id", ""),
438
+ title=state.get("tmdb_title", ""),
439
+ year=state.get("tmdb_year", ""),
440
+ final_name=state.get("folder_name") or next(iter(final_names.values()), ""),
441
+ hardlink_only=state.get("hardlink_only", False),
442
+ )
443
+ log_emit("ok", f"Hardlink done → {state['seeding_path']}", "wizard")
444
+ return JSONResponse({"ok": True, "seeding_path": state["seeding_path"]})
445
+ except HTTPException:
446
+ raise
447
+ except Exception as e:
448
+ log_emit("error", f"Hardlink failed: {e}", "wizard")
449
+ raise HTTPException(500, _i18n_t("err.hardlink_failed", lang, error=str(e)))
450
+
451
+
452
+ @router.get("/wizard/{tok}/upload")
453
+ async def wizard_upload(tok: str, request: Request):
454
+ state = _get(tok)
455
+ seeding_path = state.get("seeding_path", "")
456
+ if not seeding_path:
457
+ async def _err():
458
+ yield {"event": "error", "data": "No seeding path set"}
459
+ return EventSourceResponse(_err())
460
+ kind = state["kind"]
461
+ tmdb_id = state.get("tmdb_id", "")
462
+
463
+ app = request.app
464
+
465
+ async def generate() -> AsyncGenerator[dict, None]:
466
+ async for ev in stream_webup(
467
+ client=app.state.webup,
468
+ ws=app.state.webup_ws,
469
+ scan_lock=app.state.webup_scan_lock,
470
+ seeding_path=seeding_path,
471
+ kind=kind,
472
+ tmdb_id=tmdb_id,
473
+ ):
474
+ et = ev["type"]
475
+ if et == "log":
476
+ ev_kind = ev.get("kind", "info")
477
+ event_slug = ev.get("event")
478
+ log_emit(ev_kind, ev["data"], "webup", source="webup", event=event_slug)
479
+ yield {"event": "log", "data": ev["data"]}
480
+ elif et == "progress":
481
+ yield {"event": "progress", "data": json.dumps({
482
+ "phase": ev.get("phase"),
483
+ "label": ev.get("label"),
484
+ "pct": ev.get("pct", 0),
485
+ "sub_pct": ev.get("sub_pct", 0),
486
+ })}
487
+ elif et == "error":
488
+ log_emit("error", ev["data"], "webup", source="webup")
489
+ yield {"event": "error", "data": ev["data"]}
490
+ elif et == "done":
491
+ code = ev.get("exit_code", -1)
492
+ state["exit_code"] = code
493
+ state["upload_done"] = True
494
+ await update_exit_code(seeding_path, code)
495
+ log_emit(
496
+ "ok" if code == 0 else "error",
497
+ f"webup exit {code}", "wizard",
498
+ )
499
+ yield {"event": "done", "data": json.dumps({"exit_code": code})}
500
+
501
+ return EventSourceResponse(generate())
502
+
503
+
504
+ @router.post("/wizard/{tok}/finish-hardlink")
505
+ async def wizard_finish(tok: str):
506
+ state = _get(tok)
507
+ state["upload_done"] = True
508
+ state["exit_code"] = 0
509
+ seeding_path = state.get("seeding_path", "")
510
+ if seeding_path:
511
+ await update_exit_code(seeding_path, 0)
512
+ return JSONResponse({"ok": True})