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,754 @@
1
+ """Versioning + auto-update endpoints.
2
+
3
+ - /api/version/info → cached {app, webup} current/latest/newer
4
+ - /api/version/refresh → force-refresh cache
5
+ - /api/version/changelog → GitHub release body for a given app version
6
+ - /api/version/update/webup/stream → SSE: git pull + pip install --upgrade Unit3DwebUp + systemctl restart unit3dwebup.service
7
+ - /api/version/update/app/stream → SSE: git pull + pip install -e . + systemctl restart
8
+
9
+ Current app version: importlib.metadata (authoritative) with pyproject fallback.
10
+ Remote: GitHub releases/latest. 10-min in-memory cache, errors swallowed
11
+ (latest=None). Subprocess output streamed line-by-line.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any, AsyncGenerator
24
+
25
+ import httpx
26
+ from fastapi import APIRouter, HTTPException, Query
27
+ from fastapi.responses import JSONResponse
28
+ from sse_starlette.sse import EventSourceResponse
29
+
30
+ from ..config import runtime_setting
31
+ from .._env import env as _env
32
+
33
+ try:
34
+ from packaging.version import InvalidVersion, Version
35
+ except ImportError:
36
+ Version = None
37
+ InvalidVersion = Exception
38
+
39
+ router = APIRouter(prefix="/api", tags=["version"])
40
+
41
+ GITHUB_REPO = _env("U3DP_GITHUB_REPO", "ITA_GITHUB_REPO", "davidesidoti/unit3dprep") or "davidesidoti/unit3dprep"
42
+ WEBUP_GITHUB_REPO = "31December99/Unit3DWebUp"
43
+
44
+
45
+ def _systemd_unit() -> str:
46
+ """Runtime-resolved systemd unit name. Reads from env → shared .env → default."""
47
+ return runtime_setting("U3DP_SYSTEMD_UNIT", "unit3dprep-web.service")
48
+
49
+
50
+ def _webup_systemd_unit() -> str:
51
+ return runtime_setting("WEBUP_SYSTEMD_UNIT", "unit3dwebup.service")
52
+
53
+
54
+ def _webup_repo_path() -> Path:
55
+ return Path(runtime_setting("WEBUP_REPO_PATH", str(Path.home() / "dev" / "Unit3DWebUp"))).expanduser()
56
+
57
+
58
+ def _webup_python() -> str:
59
+ """Resolve the python interpreter that has Unit3DwebUp installed.
60
+
61
+ Precedence: WEBUP_VENV_BIN → <WEBUP_REPO_PATH>/.venv/bin → sys.executable
62
+ (the running unit3dprep interpreter — works when both packages share a
63
+ single venv, which is the canonical PyPI install layout).
64
+ """
65
+ explicit = runtime_setting("WEBUP_VENV_BIN", "")
66
+ if explicit:
67
+ return str(Path(explicit).expanduser() / "python")
68
+ legacy = _webup_repo_path() / ".venv" / "bin" / "python"
69
+ if legacy.exists():
70
+ return str(legacy)
71
+ return sys.executable
72
+ USER_AGENT = "unit3dprep/version-check"
73
+
74
+ _CACHE_TTL = 600.0
75
+ _CHANGELOG_TTL = 3600.0
76
+ _cache: dict[str, Any] = {"at": 0.0, "data": None}
77
+ _changelog_cache: dict[str, tuple[float, dict]] = {}
78
+
79
+
80
+ # ---------------------------------------------------------------- current --
81
+
82
+ def _current_app_version() -> str:
83
+ # In git-checkout installs, pyproject.toml at the repo root is the authoritative
84
+ # source — `git pull` updates it atomically. importlib.metadata can return stale
85
+ # values when an orphan dist-info/egg-info shadows the fresh install.
86
+ if _repo_root() is not None:
87
+ try:
88
+ import tomllib
89
+ pyproject = _repo_root() / "pyproject.toml"
90
+ v = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
91
+ if v:
92
+ return v
93
+ except Exception:
94
+ pass
95
+ try:
96
+ from importlib.metadata import version
97
+ return version("unit3dprep")
98
+ except Exception:
99
+ pass
100
+ try:
101
+ import tomllib
102
+ pyproject = Path(__file__).resolve().parents[3] / "pyproject.toml"
103
+ return tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
104
+ except Exception:
105
+ return "0.0.0"
106
+
107
+
108
+ async def _current_webup_version() -> str | None:
109
+ """Read VERSION from the running Unit3DWebUp instance via /setting."""
110
+ try:
111
+ from ..webup_client import get_client
112
+ h = await get_client().health(force=False)
113
+ if h.get("ok"):
114
+ return h.get("version") or None
115
+ except Exception:
116
+ pass
117
+ return None
118
+
119
+
120
+ def _current_webup_repo_version() -> str | None:
121
+ """Best-effort: parse UNIT3DWEBUP__VERSION from .env(example) at the cloned repo."""
122
+ repo = _webup_repo_path()
123
+ for fname in (".env", ".env(example)"):
124
+ f = repo / fname
125
+ if not f.exists():
126
+ continue
127
+ try:
128
+ for line in f.read_text(encoding="utf-8", errors="replace").splitlines():
129
+ if line.startswith("UNIT3DWEBUP__VERSION="):
130
+ return line.split("=", 1)[1].strip()
131
+ except Exception:
132
+ continue
133
+ return None
134
+
135
+
136
+ def _current_webup_pip_version() -> str | None:
137
+ """Read installed Unit3DwebUp version from the webup venv's site-packages.
138
+
139
+ Webup 0.0.x is distributed via PyPI and no longer exposes a VERSION key in
140
+ /setting nor in .env(example), so importlib.metadata against the webup venv
141
+ is the canonical source.
142
+ """
143
+ py = _webup_python()
144
+ if not Path(py).exists():
145
+ return None
146
+ try:
147
+ r = subprocess.run(
148
+ [py, "-c", "import importlib.metadata as m; print(m.version('Unit3DwebUp'))"],
149
+ capture_output=True, text=True, timeout=5,
150
+ )
151
+ if r.returncode != 0:
152
+ return None
153
+ v = (r.stdout or "").strip()
154
+ return v or None
155
+ except Exception:
156
+ return None
157
+
158
+
159
+ # ---------------------------------------------------------------- remote --
160
+
161
+ async def _fetch_github_latest(client: httpx.AsyncClient) -> dict | None:
162
+ try:
163
+ r = await client.get(
164
+ f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
165
+ headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
166
+ timeout=10.0,
167
+ follow_redirects=True,
168
+ )
169
+ if r.status_code == 404:
170
+ return None
171
+ r.raise_for_status()
172
+ j = r.json()
173
+ tag = (j.get("tag_name") or "").lstrip("v")
174
+ if not tag:
175
+ return None
176
+ return {
177
+ "version": tag,
178
+ "body": j.get("body") or "",
179
+ "html_url": j.get("html_url") or "",
180
+ "published_at": j.get("published_at") or "",
181
+ "name": j.get("name") or tag,
182
+ }
183
+ except Exception:
184
+ return None
185
+
186
+
187
+ async def _fetch_webup_latest(client: httpx.AsyncClient) -> str | None:
188
+ try:
189
+ r = await client.get(
190
+ f"https://api.github.com/repos/{WEBUP_GITHUB_REPO}/releases/latest",
191
+ headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
192
+ timeout=10.0,
193
+ follow_redirects=True,
194
+ )
195
+ if r.status_code == 404:
196
+ # No release published — fall back to default branch HEAD via tags
197
+ r2 = await client.get(
198
+ f"https://api.github.com/repos/{WEBUP_GITHUB_REPO}/tags",
199
+ headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
200
+ timeout=10.0,
201
+ follow_redirects=True,
202
+ )
203
+ r2.raise_for_status()
204
+ tags = r2.json() or []
205
+ if tags:
206
+ return (tags[0].get("name") or "").lstrip("v") or None
207
+ return None
208
+ r.raise_for_status()
209
+ return (r.json().get("tag_name") or "").lstrip("v") or None
210
+ except Exception:
211
+ return None
212
+
213
+
214
+ def _is_newer(current: str | None, latest: str | None) -> bool:
215
+ if not current or not latest:
216
+ return False
217
+ if Version is None:
218
+ return current != latest
219
+ try:
220
+ return Version(current) < Version(latest)
221
+ except InvalidVersion:
222
+ return current != latest
223
+
224
+
225
+ async def _compute_info() -> dict:
226
+ async with httpx.AsyncClient() as client:
227
+ release, webup_latest = await asyncio.gather(
228
+ _fetch_github_latest(client),
229
+ _fetch_webup_latest(client),
230
+ )
231
+ app_current = _current_app_version()
232
+ app_latest = release["version"] if release else None
233
+ webup_current = (
234
+ await _current_webup_version()
235
+ or _current_webup_pip_version()
236
+ or _current_webup_repo_version()
237
+ )
238
+ return {
239
+ "app": {
240
+ "current": app_current,
241
+ "latest": app_latest,
242
+ "newer": _is_newer(app_current, app_latest),
243
+ "release": release,
244
+ },
245
+ "webup": {
246
+ "current": webup_current,
247
+ "latest": webup_latest,
248
+ "newer": _is_newer(webup_current, webup_latest),
249
+ "installed": webup_current is not None,
250
+ "repo_path": str(_webup_repo_path()),
251
+ },
252
+ "can_update_app": _systemd_available(),
253
+ "can_update_webup": _webup_can_update(),
254
+ }
255
+
256
+
257
+ def _webup_can_update() -> bool:
258
+ """True iff we have a python interpreter able to `pip install --upgrade
259
+ Unit3DwebUp` AND a reachable systemd user unit for the webup service.
260
+
261
+ The legacy git-clone path (`<WEBUP_REPO_PATH>/.git`) is no longer required
262
+ — Unit3DwebUp 0.0.x is distributed via PyPI, so a pip install is the
263
+ canonical update path. If a checkout exists we'll still `git pull` it
264
+ before pip-installing, but its absence is not a failure.
265
+ """
266
+ if not Path(_webup_python()).exists():
267
+ return False
268
+ if not shutil.which("systemctl"):
269
+ return False
270
+ try:
271
+ r = subprocess.run(
272
+ ["systemctl", "--user", "cat", _webup_systemd_unit()],
273
+ capture_output=True, text=True, timeout=5,
274
+ )
275
+ return r.returncode == 0
276
+ except Exception:
277
+ return False
278
+
279
+
280
+ def _systemd_available() -> bool:
281
+ """True iff the systemd unit file exists and is loadable.
282
+
283
+ Uses `systemctl --user cat` which returns 0 for any valid state
284
+ (enabled, linked, static, disabled) and non-zero only when the unit
285
+ cannot be found. Previous `is-enabled` check incorrectly rejected
286
+ symlinked (`linked`) units.
287
+ """
288
+ if not shutil.which("systemctl"):
289
+ return False
290
+ try:
291
+ r = subprocess.run(
292
+ ["systemctl", "--user", "cat", _systemd_unit()],
293
+ capture_output=True, text=True, timeout=5,
294
+ )
295
+ return r.returncode == 0
296
+ except Exception:
297
+ return False
298
+
299
+
300
+ def _install_mode() -> str:
301
+ """Returns 'git' if the package was installed from a git checkout
302
+ (has a reachable `.git` at repo root) or 'pip' otherwise (e.g. installed
303
+ via `pip install git+https://...@tag`, which drops the `.git` folder).
304
+ """
305
+ if _repo_root() is not None:
306
+ return "git"
307
+ return "pip"
308
+
309
+
310
+ async def _get_info(force: bool = False) -> dict:
311
+ now = time.time()
312
+ if not force and _cache["data"] and (now - _cache["at"]) < _CACHE_TTL:
313
+ return _cache["data"]
314
+ data = await _compute_info()
315
+ _cache["at"] = now
316
+ _cache["data"] = data
317
+ return data
318
+
319
+
320
+ # ---------------------------------------------------------------- routes --
321
+
322
+ @router.get("/version/info")
323
+ async def info():
324
+ return await _get_info()
325
+
326
+
327
+ @router.post("/version/refresh")
328
+ async def refresh():
329
+ return await _get_info(force=True)
330
+
331
+
332
+ @router.get("/version/changelog")
333
+ async def changelog(v: str = Query(..., min_length=1, max_length=64)):
334
+ v = v.strip().lstrip("v")
335
+ now = time.time()
336
+ cached = _changelog_cache.get(v)
337
+ if cached and (now - cached[0]) < _CHANGELOG_TTL:
338
+ return cached[1]
339
+ async with httpx.AsyncClient() as client:
340
+ try:
341
+ r = await client.get(
342
+ f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{v}",
343
+ headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
344
+ timeout=10.0,
345
+ follow_redirects=True,
346
+ )
347
+ if r.status_code == 404:
348
+ r = await client.get(
349
+ f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/{v}",
350
+ headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
351
+ timeout=10.0,
352
+ follow_redirects=True,
353
+ )
354
+ r.raise_for_status()
355
+ j = r.json()
356
+ out = {
357
+ "version": v,
358
+ "name": j.get("name") or v,
359
+ "body": j.get("body") or "",
360
+ "html_url": j.get("html_url") or "",
361
+ "published_at": j.get("published_at") or "",
362
+ }
363
+ except Exception as exc:
364
+ raise HTTPException(404, f"Release not found: {exc}")
365
+ _changelog_cache[v] = (now, out)
366
+ return out
367
+
368
+
369
+ # ---------------------------------------------------------------- update --
370
+
371
+ def _sse(event: str, payload: Any) -> dict:
372
+ return {"event": event, "data": json.dumps(payload) if not isinstance(payload, str) else payload}
373
+
374
+
375
+ async def _stream_subprocess(argv: list[str], cwd: Path | None = None) -> AsyncGenerator[dict, None]:
376
+ yield _sse("log", f"$ {' '.join(argv)}")
377
+ try:
378
+ proc = await asyncio.create_subprocess_exec(
379
+ *argv,
380
+ stdout=asyncio.subprocess.PIPE,
381
+ stderr=asyncio.subprocess.STDOUT,
382
+ cwd=str(cwd) if cwd else None,
383
+ )
384
+ except FileNotFoundError as exc:
385
+ yield _sse("error", {"message": f"command not found: {exc}"})
386
+ return
387
+ assert proc.stdout is not None
388
+ while True:
389
+ line = await proc.stdout.readline()
390
+ if not line:
391
+ break
392
+ try:
393
+ text = line.decode("utf-8", errors="replace").rstrip()
394
+ except Exception:
395
+ text = repr(line)
396
+ if text:
397
+ yield _sse("log", text)
398
+ code = await proc.wait()
399
+ yield _sse("exit", {"code": code})
400
+
401
+
402
+ @router.get("/version/update/webup/stream")
403
+ async def update_webup():
404
+ """Update Unit3DWebUp: git pull → pip install → systemctl restart."""
405
+ async def gen_inner() -> AsyncGenerator[dict, None]:
406
+ before = (
407
+ await _current_webup_version()
408
+ or _current_webup_pip_version()
409
+ or _current_webup_repo_version()
410
+ )
411
+ yield _sse("start", {"target": "webup", "current": before})
412
+
413
+ if not _webup_can_update():
414
+ py = _webup_python()
415
+ if not Path(py).exists():
416
+ yield _sse("error", {"message": f"webup python not found at {py} (set WEBUP_VENV_BIN)"})
417
+ else:
418
+ yield _sse("error", {"message": f"webup systemd unit '{_webup_systemd_unit()}' not available"})
419
+ yield _sse("done", {"ok": False})
420
+ return
421
+
422
+ repo = _webup_repo_path()
423
+ # Optional: if a git checkout exists at WEBUP_REPO_PATH, refresh it first.
424
+ # PyPI install does not require this; we only do it for users who run
425
+ # webup from a local checkout (legacy / dev setups).
426
+ if (repo / ".git").exists():
427
+ for argv in (
428
+ ["git", "fetch", "--all"],
429
+ ["git", "pull", "--ff-only"],
430
+ ):
431
+ async for ev in _stream_subprocess(argv, cwd=repo):
432
+ if ev["event"] == "exit":
433
+ if json.loads(ev["data"])["code"] != 0:
434
+ yield _sse("error", {"message": f"{argv[0]} {argv[1]} failed"})
435
+ yield _sse("done", {"ok": False})
436
+ return
437
+ break
438
+ yield ev
439
+
440
+ py = _webup_python()
441
+ pip_cwd = repo if repo.exists() else None
442
+ async for ev in _stream_subprocess(
443
+ [py, "-m", "pip", "install", "--upgrade", "Unit3DwebUp"], cwd=pip_cwd
444
+ ):
445
+ if ev["event"] == "exit":
446
+ if json.loads(ev["data"])["code"] != 0:
447
+ yield _sse("error", {"message": "pip install failed"})
448
+ yield _sse("done", {"ok": False})
449
+ return
450
+ break
451
+ yield ev
452
+
453
+ # Restart webup service via systemd-run timer (fire-and-forget;
454
+ # outside our cgroup, won't be killed when this request finishes).
455
+ unit = _webup_systemd_unit()
456
+ if shutil.which("systemd-run"):
457
+ try:
458
+ subprocess.run(
459
+ [
460
+ "systemd-run", "--user", "--on-active=2s",
461
+ "--unit", f"webup-restart-{int(time.time())}",
462
+ "systemctl", "--user", "restart", unit,
463
+ ],
464
+ stdin=subprocess.DEVNULL,
465
+ stdout=subprocess.DEVNULL,
466
+ stderr=subprocess.PIPE,
467
+ timeout=10,
468
+ check=True,
469
+ )
470
+ yield _sse("log", f"restart scheduled for {unit} (2s)")
471
+ except Exception as exc:
472
+ yield _sse("log", f"systemd-run failed, fallback Popen: {exc}")
473
+ try:
474
+ subprocess.Popen(
475
+ ["systemctl", "--user", "restart", unit],
476
+ stdin=subprocess.DEVNULL,
477
+ stdout=subprocess.DEVNULL,
478
+ stderr=subprocess.DEVNULL,
479
+ start_new_session=True,
480
+ )
481
+ except Exception as exc2:
482
+ yield _sse("error", {"message": f"failed to spawn systemctl: {exc2}"})
483
+
484
+ # Invalidate cache so the next /version/info reflects the new version.
485
+ _cache["data"] = None
486
+ _cache["at"] = 0.0
487
+ # webup HTTP probably down right now (just restarted) — fall back to
488
+ # the pip-installed version, which is updated synchronously above.
489
+ after = _current_webup_pip_version() or _current_webup_repo_version()
490
+ yield _sse("done", {"ok": True, "target": "webup", "from": before, "to": after})
491
+
492
+ async def gen() -> AsyncGenerator[dict, None]:
493
+ try:
494
+ async for ev in gen_inner():
495
+ yield ev
496
+ except Exception as exc:
497
+ import traceback
498
+ tb = traceback.format_exc()
499
+ yield _sse("error", {"message": f"unexpected error: {exc!r}", "traceback": tb})
500
+ yield _sse("done", {"ok": False})
501
+
502
+ return EventSourceResponse(gen())
503
+
504
+
505
+ async def _git(argv: list[str], cwd: Path) -> tuple[int, str]:
506
+ try:
507
+ proc = await asyncio.create_subprocess_exec(
508
+ "git", *argv,
509
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
510
+ cwd=str(cwd),
511
+ )
512
+ except FileNotFoundError:
513
+ return 127, "git executable not found in PATH"
514
+ except Exception as exc:
515
+ return 1, f"failed to spawn git: {exc!r}"
516
+ out, _ = await proc.communicate()
517
+ return proc.returncode or 0, out.decode("utf-8", errors="replace").strip()
518
+
519
+
520
+ def _repo_root() -> Path | None:
521
+ candidate = Path(__file__).resolve().parents[3]
522
+ if (candidate / ".git").exists():
523
+ return candidate
524
+ return None
525
+
526
+
527
+ @router.get("/version/update/app/stream")
528
+ async def update_app():
529
+ async def gen() -> AsyncGenerator[dict, None]:
530
+ try:
531
+ before = _current_app_version()
532
+ mode = _install_mode()
533
+ yield _sse("start", {"target": "app", "current": before, "mode": mode})
534
+
535
+ if not _systemd_available():
536
+ yield _sse("error", {"message": f"systemd unit '{_systemd_unit()}' not available"})
537
+ yield _sse("done", {"ok": False})
538
+ return
539
+
540
+ if mode == "git":
541
+ async for ev in _update_app_from_git():
542
+ yield ev
543
+ data = ev.get("data") or ""
544
+ if ev["event"] == "done":
545
+ try:
546
+ if not json.loads(data).get("ok"):
547
+ return
548
+ except Exception:
549
+ return
550
+ break
551
+ else:
552
+ async for ev in _update_app_from_pip():
553
+ yield ev
554
+ data = ev.get("data") or ""
555
+ if ev["event"] == "done":
556
+ try:
557
+ if not json.loads(data).get("ok"):
558
+ return
559
+ except Exception:
560
+ return
561
+ break
562
+ except Exception as exc:
563
+ # Surface unexpected errors as proper SSE events instead of an
564
+ # abrupt stream close, which the frontend would render as
565
+ # "(stream closed)" with no actionable diagnostic.
566
+ import traceback
567
+ tb = traceback.format_exc()
568
+ yield _sse("error", {"message": f"unexpected error: {exc!r}", "traceback": tb})
569
+ yield _sse("done", {"ok": False})
570
+ return
571
+
572
+ await asyncio.sleep(1.5)
573
+
574
+ # Schedule the restart via a transient systemd-run timer so the restart
575
+ # command lives OUTSIDE this service's cgroup. A plain `Popen(...,
576
+ # start_new_session=True)` child stays in the parent unit's cgroup and
577
+ # gets killed together with it when systemd stops the service.
578
+ restart_scheduled = False
579
+ if shutil.which("systemd-run"):
580
+ try:
581
+ subprocess.run(
582
+ [
583
+ "systemd-run", "--user", "--on-active=3s",
584
+ "--unit", f"unit3dprep-restart-{int(time.time())}",
585
+ "systemctl", "--user", "restart", _systemd_unit(),
586
+ ],
587
+ stdin=subprocess.DEVNULL,
588
+ stdout=subprocess.DEVNULL,
589
+ stderr=subprocess.PIPE,
590
+ timeout=10,
591
+ check=True,
592
+ )
593
+ restart_scheduled = True
594
+ yield _sse("log", "restart scheduled via systemd-run (3s)")
595
+ except Exception as exc:
596
+ yield _sse("log", f"systemd-run failed, falling back: {exc}")
597
+
598
+ if not restart_scheduled:
599
+ try:
600
+ subprocess.Popen(
601
+ ["systemctl", "--user", "restart", _systemd_unit()],
602
+ stdin=subprocess.DEVNULL,
603
+ stdout=subprocess.DEVNULL,
604
+ stderr=subprocess.DEVNULL,
605
+ start_new_session=True,
606
+ )
607
+ except Exception as exc:
608
+ yield _sse("error", {"message": f"failed to spawn systemctl: {exc}"})
609
+
610
+ return EventSourceResponse(gen())
611
+
612
+
613
+ async def _clean_stale_metadata(repo: Path) -> AsyncGenerator[dict, None]:
614
+ """Remove stale egg-info (source tree) and orphan dist-info (site-packages)
615
+ that can shadow the fresh install and confuse importlib.metadata.
616
+ """
617
+ for egg_name in ("unit3dprep.egg-info", "itatorrents.egg-info"):
618
+ egg_info = repo / egg_name
619
+ if egg_info.exists():
620
+ yield _sse("log", f"removing stale egg-info: {egg_info}")
621
+ shutil.rmtree(egg_info, ignore_errors=True)
622
+
623
+ try:
624
+ import site
625
+ roots: list[Path] = []
626
+ for p in [site.getusersitepackages(), *site.getsitepackages()]:
627
+ rp = Path(p)
628
+ if rp.exists() and rp not in roots:
629
+ roots.append(rp)
630
+ for root in roots:
631
+ for pkg in ("unit3dprep", "itatorrents"):
632
+ for d in root.glob(f"{pkg}-*.dist-info"):
633
+ yield _sse("log", f"removing dist-info: {d}")
634
+ shutil.rmtree(d, ignore_errors=True)
635
+ for pth in root.glob(f"__editable__.{pkg}-*.pth"):
636
+ yield _sse("log", f"removing editable pth: {pth}")
637
+ try:
638
+ pth.unlink()
639
+ except Exception:
640
+ pass
641
+ except Exception as exc:
642
+ yield _sse("log", f"cleanup warning: {exc}")
643
+
644
+
645
+ async def _update_app_from_git() -> AsyncGenerator[dict, None]:
646
+ before = _current_app_version()
647
+ repo = _repo_root()
648
+ assert repo is not None
649
+ yield _sse("log", f"install mode: git checkout at {repo}")
650
+
651
+ code, branch = await _git(["rev-parse", "--abbrev-ref", "HEAD"], repo)
652
+ if code != 0:
653
+ yield _sse("error", {"message": f"git failed: {branch}"})
654
+ yield _sse("done", {"ok": False})
655
+ return
656
+ if branch != "main":
657
+ yield _sse("error", {"message": f"refuse to update: on branch '{branch}', expected 'main'"})
658
+ yield _sse("done", {"ok": False})
659
+ return
660
+
661
+ code, dirty = await _git(["status", "--porcelain"], repo)
662
+ if code != 0:
663
+ yield _sse("error", {"message": f"git status failed: {dirty}"})
664
+ yield _sse("done", {"ok": False})
665
+ return
666
+ if dirty:
667
+ yield _sse("error", {"message": "working tree has uncommitted changes — refuse to update"})
668
+ yield _sse("done", {"ok": False})
669
+ return
670
+
671
+ # Fetch + pull first, THEN clean metadata so pip install creates fresh
672
+ # dist-info without any leftovers shadowing it.
673
+ for argv in (
674
+ ["git", "fetch", "origin", "main"],
675
+ ["git", "pull", "--ff-only", "origin", "main"],
676
+ ):
677
+ async for ev in _stream_subprocess(argv, cwd=repo):
678
+ if ev["event"] == "exit":
679
+ if json.loads(ev["data"])["code"] != 0:
680
+ yield _sse("error", {"message": f"{argv[0]} failed"})
681
+ yield _sse("done", {"ok": False})
682
+ return
683
+ break
684
+ yield ev
685
+
686
+ # Uninstall in a loop (pip doesn't remove orphan dist-info reliably).
687
+ # Target both the new name and the legacy one for mid-rename upgrades.
688
+ for pkg_name in ("unit3dprep", "itatorrents"):
689
+ for _ in range(3):
690
+ done_uninstall = False
691
+ async for ev in _stream_subprocess(
692
+ [sys.executable, "-m", "pip", "uninstall", "-y", pkg_name], cwd=repo
693
+ ):
694
+ if ev["event"] == "exit":
695
+ done_uninstall = True
696
+ break
697
+ yield ev
698
+ if done_uninstall:
699
+ break
700
+
701
+ async for ev in _clean_stale_metadata(repo):
702
+ yield ev
703
+
704
+ async for ev in _stream_subprocess(
705
+ [sys.executable, "-m", "pip", "install", "-e", "."], cwd=repo
706
+ ):
707
+ if ev["event"] == "exit":
708
+ if json.loads(ev["data"])["code"] != 0:
709
+ yield _sse("error", {"message": "pip install failed"})
710
+ yield _sse("done", {"ok": False})
711
+ return
712
+ break
713
+ yield ev
714
+
715
+ after = _current_app_version()
716
+ # Invalidate /version/info cache so post-reload poll re-computes against
717
+ # the freshly installed version — otherwise the old process (if still
718
+ # answering during the restart race) returns stale {newer: true}.
719
+ _cache["data"] = None
720
+ _cache["at"] = 0.0
721
+ yield _sse("log", f"restarting systemd unit {_systemd_unit()}…")
722
+ yield _sse("done", {"ok": True, "target": "app", "from": before, "to": after, "mode": "git"})
723
+
724
+
725
+ async def _update_app_from_pip() -> AsyncGenerator[dict, None]:
726
+ before = _current_app_version()
727
+ yield _sse("log", "install mode: pip — will upgrade from PyPI")
728
+
729
+ info = await _get_info(force=True)
730
+ latest = (info.get("app") or {}).get("latest")
731
+ if not latest:
732
+ yield _sse("error", {"message": "cannot determine latest release (network issue or no GitHub release yet)"})
733
+ yield _sse("done", {"ok": False})
734
+ return
735
+
736
+ # Published on PyPI as `unit3dprep`; pin to the version advertised by the
737
+ # GitHub release so we install exactly what's expected (CI publishes the
738
+ # wheel to PyPI on the same tag).
739
+ async for ev in _stream_subprocess(
740
+ [sys.executable, "-m", "pip", "install", "--upgrade", f"unit3dprep=={latest}"]
741
+ ):
742
+ if ev["event"] == "exit":
743
+ if json.loads(ev["data"])["code"] != 0:
744
+ yield _sse("error", {"message": "pip install failed (PyPI may not have this version yet — retry shortly)"})
745
+ yield _sse("done", {"ok": False})
746
+ return
747
+ break
748
+ yield ev
749
+
750
+ after = _current_app_version()
751
+ _cache["data"] = None
752
+ _cache["at"] = 0.0
753
+ yield _sse("log", f"restarting systemd unit {_systemd_unit()}…")
754
+ yield _sse("done", {"ok": True, "target": "app", "from": before, "to": after, "mode": "pip"})