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,153 @@
1
+ """Quick upload (UploadModal flow) — wraps Unit3DWebUp without the full wizard.
2
+
3
+ Takes a path + mode (u|f|scan), drives the webup HTTP API, streams logs via SSE.
4
+ No audio check, no hardlink: for power users who already staged files in
5
+ their seeding path. DB record is created on start; exit code updated on done.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import secrets
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, AsyncGenerator
15
+
16
+ from fastapi import APIRouter, HTTPException, Request
17
+ from fastapi.responses import JSONResponse
18
+ from pydantic import BaseModel
19
+ from sse_starlette.sse import EventSourceResponse
20
+
21
+ from ...i18n import get_request_lang, t
22
+ from ..db import update_exit_code
23
+ from ..logbuf import emit as log_emit
24
+ from ..webup_orchestrator import stream_webup, stream_webup_batch
25
+
26
+ router = APIRouter(prefix="/api", tags=["quickupload"])
27
+
28
+ _jobs: dict[str, dict[str, Any]] = {}
29
+ _created: dict[str, float] = {}
30
+ _TTL = 3600
31
+
32
+
33
+ def _cleanup():
34
+ now = time.time()
35
+ for j in [j for j, ct in _created.items() if now - ct > _TTL]:
36
+ _jobs.pop(j, None)
37
+ _created.pop(j, None)
38
+
39
+
40
+ class QuickBody(BaseModel):
41
+ path: str
42
+ mode: str = "u" # u|f|scan
43
+ tracker: str = "ITT"
44
+ tmdb_id: str = ""
45
+ skip_tmdb: bool = False
46
+ skip_youtube: bool = False
47
+ anon: bool = False
48
+ webp: bool = False
49
+ screenshots: bool = True
50
+
51
+
52
+ @router.post("/upload/quick")
53
+ async def create(request: Request, body: QuickBody):
54
+ lang = get_request_lang(request)
55
+ p = Path(body.path).resolve()
56
+ if not p.exists():
57
+ raise HTTPException(404, t("err.path_not_found", lang))
58
+ if body.mode not in {"u", "f", "scan"}:
59
+ raise HTTPException(400, t("err.invalid_mode", lang))
60
+ _cleanup()
61
+ job_id = secrets.token_urlsafe(16)
62
+ _jobs[job_id] = {
63
+ "path": str(p),
64
+ "mode": body.mode,
65
+ "tmdb_id": body.tmdb_id,
66
+ }
67
+ _created[job_id] = time.time()
68
+ return JSONResponse({"job": job_id})
69
+
70
+
71
+ @router.get("/upload/{job}/stream")
72
+ async def stream(request: Request, job: str):
73
+ state = _jobs.get(job)
74
+ if state is None:
75
+ raise HTTPException(404, t("err.job_not_found", get_request_lang(request)))
76
+ path: str = state["path"]
77
+ mode: str = state["mode"]
78
+ tmdb_id: str = state.get("tmdb_id", "")
79
+ app = request.app
80
+
81
+ async def gen() -> AsyncGenerator[dict, None]:
82
+ if mode == "scan":
83
+ async for ev in stream_webup_batch(
84
+ client=app.state.webup,
85
+ ws=app.state.webup_ws,
86
+ scan_lock=app.state.webup_scan_lock,
87
+ folder=path,
88
+ ):
89
+ et = ev["type"]
90
+ if et == "log":
91
+ ev_kind = ev.get("kind", "info")
92
+ event_slug = ev.get("event")
93
+ log_emit(ev_kind, ev["data"], "webup", source="webup", event=event_slug)
94
+ yield {"event": "log", "data": ev["data"]}
95
+ elif et == "job_done":
96
+ job_path = ev.get("path") or ""
97
+ if job_path:
98
+ await update_exit_code(job_path, ev.get("exit_code", -1))
99
+ yield {"event": "job_done", "data": json.dumps(ev)}
100
+ elif et == "error":
101
+ log_emit("error", ev["data"], "webup", source="webup")
102
+ yield {"event": "error", "data": ev["data"]}
103
+ elif et == "done":
104
+ code = ev.get("exit_code", -1)
105
+ state["exit_code"] = code
106
+ log_emit(
107
+ "ok" if code == 0 else "error",
108
+ f"webup batch exit {code} (ok={ev.get('ok',0)} fail={ev.get('fail',0)})",
109
+ "quickupload",
110
+ )
111
+ yield {"event": "done", "data": json.dumps({
112
+ "exit_code": code,
113
+ "ok": ev.get("ok", 0),
114
+ "fail": ev.get("fail", 0),
115
+ })}
116
+ return
117
+
118
+ kind = "series" if mode == "f" else "movie"
119
+ async for ev in stream_webup(
120
+ client=app.state.webup,
121
+ ws=app.state.webup_ws,
122
+ scan_lock=app.state.webup_scan_lock,
123
+ seeding_path=path,
124
+ kind=kind,
125
+ tmdb_id=tmdb_id,
126
+ ):
127
+ et = ev["type"]
128
+ if et == "log":
129
+ ev_kind = ev.get("kind", "info")
130
+ event_slug = ev.get("event")
131
+ log_emit(ev_kind, ev["data"], "webup", source="webup", event=event_slug)
132
+ yield {"event": "log", "data": ev["data"]}
133
+ elif et == "progress":
134
+ yield {"event": "progress", "data": json.dumps({
135
+ "phase": ev.get("phase"),
136
+ "label": ev.get("label"),
137
+ "pct": ev.get("pct", 0),
138
+ "sub_pct": ev.get("sub_pct", 0),
139
+ })}
140
+ elif et == "error":
141
+ log_emit("error", ev["data"], "webup", source="webup")
142
+ yield {"event": "error", "data": ev["data"]}
143
+ elif et == "done":
144
+ code = ev.get("exit_code", -1)
145
+ state["exit_code"] = code
146
+ await update_exit_code(state["path"], code)
147
+ log_emit(
148
+ "ok" if code == 0 else "error",
149
+ f"webup exit {code}", "quickupload",
150
+ )
151
+ yield {"event": "done", "data": json.dumps({"exit_code": code})}
152
+
153
+ return EventSourceResponse(gen())
@@ -0,0 +1,40 @@
1
+ """Tracker search + reseed triggers."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from ...i18n import get_request_lang, t
8
+ from .. import config, trackers
9
+
10
+ router = APIRouter(prefix="/api", tags=["search"])
11
+
12
+
13
+ def _build():
14
+ return trackers.build_trackers(config.load())
15
+
16
+
17
+ @router.get("/search")
18
+ async def search(request: Request, q: str, tracker: str = "ITT"):
19
+ if not q.strip():
20
+ return JSONResponse({"results": [], "tracker": tracker})
21
+ trk_map = _build()
22
+ trk = trk_map.get(tracker.upper())
23
+ if trk is None:
24
+ raise HTTPException(404, t("err.tracker_unknown", get_request_lang(request), tracker=tracker))
25
+ try:
26
+ results = await trk.search(q)
27
+ except NotImplementedError as e:
28
+ raise HTTPException(501, str(e))
29
+ except Exception as e:
30
+ raise HTTPException(502, str(e))
31
+ return JSONResponse({
32
+ "tracker": tracker.upper(),
33
+ "results": [r.to_dict() for r in results],
34
+ })
35
+
36
+
37
+ @router.post("/reseed/{tracker}/{torrent_id}")
38
+ async def reseed(request: Request, tracker: str, torrent_id: int):
39
+ # Placeholder: trigger a unit3dup reseed run for a known torrent id.
40
+ raise HTTPException(501, t("err.reseed_not_implemented", get_request_lang(request)))
@@ -0,0 +1,77 @@
1
+ """Shared .env read/write + runtime U3DP_* env view + filesystem checks."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from .. import config
11
+ from ...media import media_root, seedings_root
12
+
13
+ router = APIRouter(prefix="/api", tags=["settings"])
14
+
15
+
16
+ @router.get("/settings")
17
+ async def get_settings():
18
+ cfg = config.load()
19
+ env_path = config.config_path()
20
+ return JSONResponse({
21
+ "config": config.mask_secrets(cfg),
22
+ "env": config.env_runtime(),
23
+ "config_path": str(env_path),
24
+ "env_path": str(env_path),
25
+ "webup_envpath_dir": str(config.webup_envpath_dir()),
26
+ })
27
+
28
+
29
+ @router.put("/settings")
30
+ async def put_settings(incoming: dict):
31
+ existing = config.load()
32
+ merged = {**existing, **config.merge_secrets(existing, incoming)}
33
+ config.save(merged)
34
+ return JSONResponse({"ok": True, "config": config.mask_secrets(merged)})
35
+
36
+
37
+ def _closest_existing(p: Path) -> Path | None:
38
+ cur: Path | None = p
39
+ while cur is not None:
40
+ if cur.exists():
41
+ return cur
42
+ parent = cur.parent
43
+ if parent == cur:
44
+ return None
45
+ cur = parent
46
+ return None
47
+
48
+
49
+ @router.get("/settings/fs-check")
50
+ async def settings_fs_check():
51
+ """Return whether MEDIA_ROOT and SEEDINGS_DIR are on the same filesystem.
52
+
53
+ Walks up missing paths so a freshly-configured seedings dir still reports
54
+ the parent filesystem it would live on.
55
+ """
56
+ media = media_root()
57
+ seed = seedings_root()
58
+ media_real = _closest_existing(media)
59
+ seed_real = _closest_existing(seed)
60
+ try:
61
+ media_dev = os.stat(media_real).st_dev if media_real else None
62
+ except OSError:
63
+ media_dev = None
64
+ try:
65
+ seed_dev = os.stat(seed_real).st_dev if seed_real else None
66
+ except OSError:
67
+ seed_dev = None
68
+ same = (media_dev is not None) and (media_dev == seed_dev)
69
+ return JSONResponse({
70
+ "media_root": str(media),
71
+ "seedings_dir": str(seed),
72
+ "media_exists": media.exists(),
73
+ "seedings_exists": seed.exists(),
74
+ "media_dev": media_dev,
75
+ "seedings_dev": seed_dev,
76
+ "same_fs": same,
77
+ })
@@ -0,0 +1,77 @@
1
+ """TMDB search + manual cache set/clear."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from fastapi.responses import JSONResponse
9
+ from pydantic import BaseModel
10
+
11
+ from ...core import tmdb_fetch_bilingual, tmdb_poster_url, tmdb_search, tmdb_year
12
+ from ...i18n import get_request_lang, t
13
+ from ..tmdb_cache import delete_cache, set_cache
14
+
15
+ TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "")
16
+
17
+ router = APIRouter(prefix="/api", tags=["tmdb"])
18
+
19
+
20
+ @router.get("/tmdb/search")
21
+ async def search(request: Request, q: str, year: str = "", kind: str = "movie"):
22
+ lang = get_request_lang(request)
23
+ if not TMDB_API_KEY:
24
+ raise HTTPException(400, t("err.tmdb_api_key_missing", lang))
25
+ loop = asyncio.get_event_loop()
26
+ try:
27
+ results = await loop.run_in_executor(
28
+ None, tmdb_search, kind, q, year, TMDB_API_KEY
29
+ )
30
+ except Exception as e:
31
+ raise HTTPException(502, str(e))
32
+ return JSONResponse({"results": results})
33
+
34
+
35
+ class SetBody(BaseModel):
36
+ source_path: str
37
+ tmdb_id: str
38
+ tmdb_kind: str = "movie"
39
+
40
+
41
+ @router.post("/tmdb/set")
42
+ async def set_manual(request: Request, body: SetBody):
43
+ if not TMDB_API_KEY:
44
+ raise HTTPException(400, t("err.tmdb_api_key_missing", get_request_lang(request)))
45
+ loop = asyncio.get_event_loop()
46
+ try:
47
+ data = await loop.run_in_executor(
48
+ None, tmdb_fetch_bilingual, body.tmdb_kind, body.tmdb_id, TMDB_API_KEY
49
+ )
50
+ except Exception as e:
51
+ raise HTTPException(502, str(e))
52
+ title = data.get("title") or ""
53
+ year = tmdb_year(data, body.tmdb_kind)
54
+ poster = tmdb_poster_url(data)
55
+ await set_cache(
56
+ body.source_path,
57
+ tmdb_id=body.tmdb_id,
58
+ tmdb_kind=body.tmdb_kind,
59
+ title=title,
60
+ title_en=data.get("title_en", ""),
61
+ original_title=data.get("original_title", ""),
62
+ year=year,
63
+ poster=poster,
64
+ overview=(data.get("overview") or "")[:300],
65
+ overview_en=(data.get("overview_en") or "")[:300],
66
+ )
67
+ return JSONResponse({"ok": True, "title": title, "year": year, "poster": poster})
68
+
69
+
70
+ class ClearBody(BaseModel):
71
+ source_path: str
72
+
73
+
74
+ @router.post("/tmdb/clear")
75
+ async def clear(body: ClearBody):
76
+ await delete_cache(body.source_path)
77
+ return JSONResponse({"ok": True})
@@ -0,0 +1,30 @@
1
+ """Tracker status (for sidebar online/offline indicators)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+
6
+ from fastapi import APIRouter
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from .. import config
10
+ from ..trackers import build_trackers
11
+
12
+ router = APIRouter(prefix="/api", tags=["trackers"])
13
+
14
+
15
+ @router.get("/trackers/status")
16
+ async def status():
17
+ trk_map = build_trackers(config.load())
18
+ names = list(trk_map.keys())
19
+ statuses = await asyncio.gather(
20
+ *(trk_map[n].status() for n in names), return_exceptions=True
21
+ )
22
+ out = []
23
+ for name, s in zip(names, statuses):
24
+ trk = trk_map[name]
25
+ out.append({
26
+ "name": name,
27
+ "online": bool(s) if not isinstance(s, Exception) else False,
28
+ "configured": bool(getattr(trk, "configured", False)),
29
+ })
30
+ return JSONResponse({"trackers": out})
@@ -0,0 +1,26 @@
1
+ """Upload history JSON endpoints."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from ...i18n import get_request_lang, t
8
+ from ..db import delete_record, list_uploads
9
+
10
+ router = APIRouter(prefix="/api", tags=["uploaded"])
11
+
12
+
13
+ @router.get("/uploaded")
14
+ async def list_all():
15
+ records = await list_uploads()
16
+ return JSONResponse({"records": records})
17
+
18
+
19
+ @router.delete("/uploaded/{record_id}")
20
+ async def delete_by_id(request: Request, record_id: int):
21
+ records = await list_uploads()
22
+ target = next((r for r in records if r.get("id") == record_id), None)
23
+ if target is None:
24
+ raise HTTPException(404, t("err.record_not_found", get_request_lang(request)))
25
+ await delete_record(target["seeding_path"])
26
+ return JSONResponse({"ok": True})