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
unit3dprep/web/app.py ADDED
@@ -0,0 +1,201 @@
1
+ """FastAPI application: JSON API under /{ROOT_PATH}/api + SPA served from /dist."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from starlette.middleware.sessions import SessionMiddleware
12
+
13
+ from ._env import env as _env, migrate_dotfiles
14
+ from .auth import SECRET_KEY
15
+ from .db import init_db
16
+ from . import logbuf
17
+ from .webup_client import get_client as get_webup_client, shutdown_client as shutdown_webup_client
18
+ from .webup_ws import WebupWSManager
19
+ from .api import (
20
+ auth as auth_api,
21
+ fs as fs_api,
22
+ library as library_api,
23
+ logs as logs_api,
24
+ quickupload as quickupload_api,
25
+ queue as queue_api,
26
+ search as search_api,
27
+ settings as settings_api,
28
+ tmdb as tmdb_api,
29
+ trackers as trackers_api,
30
+ uploaded as uploaded_api,
31
+ version as version_api,
32
+ webup as webup_api,
33
+ wizard as wizard_api,
34
+ )
35
+
36
+ migrate_dotfiles(Path.home())
37
+
38
+ # When the reverse proxy does NOT strip the prefix (typical Ultra.cc nginx
39
+ # user-proxy without trailing slash on proxy_pass), routes must be registered
40
+ # under the prefix — FastAPI's `root_path=` only helps when the proxy strips.
41
+ ROOT_PATH = (_env("U3DP_ROOT_PATH", "ITA_ROOT_PATH", "") or "").rstrip("/")
42
+ DIST_DIR = Path(__file__).parent / "dist"
43
+
44
+ API_PREFIX = f"{ROOT_PATH}/api"
45
+ _OPENAPI_URL = f"{ROOT_PATH}/openapi.json"
46
+ AUTH_EXEMPT = {
47
+ f"{API_PREFIX}/auth/login",
48
+ f"{API_PREFIX}/me",
49
+ f"{API_PREFIX}/docs",
50
+ f"{API_PREFIX}/redoc",
51
+ _OPENAPI_URL,
52
+ }
53
+
54
+ app = FastAPI(
55
+ title="Unit3DPrep Web",
56
+ docs_url=f"{API_PREFIX}/docs",
57
+ redoc_url=f"{API_PREFIX}/redoc",
58
+ openapi_url=_OPENAPI_URL,
59
+ )
60
+
61
+
62
+ @app.middleware("http")
63
+ async def _auth_guard(request: Request, call_next):
64
+ path = request.url.path.rstrip("/")
65
+ protected = path.startswith(API_PREFIX) or path == _OPENAPI_URL
66
+ if protected and path not in AUTH_EXEMPT:
67
+ if not request.session.get("authenticated"):
68
+ return JSONResponse({"detail": "Not authenticated"}, status_code=401)
69
+ return await call_next(request)
70
+
71
+
72
+ app.add_middleware(
73
+ SessionMiddleware,
74
+ secret_key=SECRET_KEY,
75
+ https_only=(_env("U3DP_HTTPS_ONLY", "ITA_HTTPS_ONLY", "0") or "0") == "1",
76
+ same_site="lax",
77
+ max_age=86400 * 7,
78
+ )
79
+
80
+
81
+ @app.on_event("startup")
82
+ async def _startup():
83
+ await init_db()
84
+ logbuf.install(asyncio.get_event_loop())
85
+ logging.getLogger("unit3dprep").info("unit3dprep-web started")
86
+
87
+ # Webup orchestration state.
88
+ app.state.webup = get_webup_client()
89
+ app.state.webup_ws = WebupWSManager()
90
+ app.state.webup_ws.start()
91
+ app.state.webup_scan_lock = asyncio.Lock()
92
+
93
+ # Best-effort runtime bridge: push the shared .env content to webup's
94
+ # in-memory settings via /setenv on startup, so config changes saved while
95
+ # webup was offline take effect without restarting it.
96
+ # Non-blocking; ignore failures (webup may not be running yet).
97
+ try:
98
+ from .config import bootstrap_webup_env
99
+ asyncio.create_task(bootstrap_webup_env(app.state.webup), name="webup-bootstrap-env")
100
+ except Exception:
101
+ logging.getLogger("unit3dprep").exception("webup bootstrap scheduling failed")
102
+
103
+
104
+ @app.on_event("shutdown")
105
+ async def _shutdown():
106
+ ws_mgr = getattr(app.state, "webup_ws", None)
107
+ if ws_mgr is not None:
108
+ try:
109
+ await ws_mgr.stop()
110
+ except Exception:
111
+ pass
112
+ try:
113
+ await shutdown_webup_client()
114
+ except Exception:
115
+ pass
116
+
117
+
118
+ # Mount all JSON routers under ROOT_PATH (routers themselves already declare
119
+ # /api/... so the final path is /{ROOT_PATH}/api/...).
120
+ for r in (
121
+ auth_api.router,
122
+ fs_api.router,
123
+ library_api.router,
124
+ logs_api.router,
125
+ queue_api.router,
126
+ quickupload_api.router,
127
+ search_api.router,
128
+ settings_api.router,
129
+ tmdb_api.router,
130
+ trackers_api.router,
131
+ uploaded_api.router,
132
+ version_api.router,
133
+ webup_api.router,
134
+ wizard_api.router,
135
+ ):
136
+ app.include_router(r, prefix=ROOT_PATH)
137
+
138
+
139
+ if (DIST_DIR / "assets").exists():
140
+ app.mount(
141
+ f"{ROOT_PATH}/assets",
142
+ StaticFiles(directory=str(DIST_DIR / "assets")),
143
+ name="assets",
144
+ )
145
+
146
+
147
+ def _render_index() -> str:
148
+ idx = DIST_DIR / "index.html"
149
+ if not idx.is_file():
150
+ return ""
151
+ html = idx.read_text(encoding="utf-8")
152
+ inject = f'<script>window.__ROOT_PATH__={ROOT_PATH!r};</script>'
153
+ html = html.replace("</head>", f"{inject}</head>", 1)
154
+ # Vite builds with base='./' producing relative asset paths. When the
155
+ # page is served at /unit3dprep (no trailing slash) the browser resolves
156
+ # './' to '/' instead of '/unit3dprep/' — fix to absolute paths at serve time.
157
+ if ROOT_PATH:
158
+ html = html.replace('./assets/', f'{ROOT_PATH}/assets/')
159
+ return html
160
+
161
+
162
+ # SPA catch-all. Serves index.html for any non-API path so a deep link to a
163
+ # client-side route still boots the app.
164
+ _CATCHALL = f"{ROOT_PATH}/{{full_path:path}}" if ROOT_PATH else "/{full_path:path}"
165
+
166
+
167
+ @app.get(_CATCHALL)
168
+ async def spa(full_path: str, request: Request):
169
+ if full_path.startswith("api/") or full_path.startswith("assets/"):
170
+ raise HTTPException(status_code=404)
171
+ if full_path and not full_path.endswith("/"):
172
+ candidate = DIST_DIR / full_path
173
+ if candidate.is_file():
174
+ return FileResponse(candidate)
175
+ rendered = _render_index()
176
+ if rendered:
177
+ return HTMLResponse(rendered)
178
+ return JSONResponse(
179
+ {"detail": "Frontend not built. Run `cd frontend && npm install && npm run build`."},
180
+ status_code=503,
181
+ )
182
+
183
+
184
+ if ROOT_PATH:
185
+ @app.get(ROOT_PATH)
186
+ async def _root_alias():
187
+ rendered = _render_index()
188
+ return HTMLResponse(rendered) if rendered else JSONResponse(
189
+ {"detail": "Frontend not built."}, status_code=503,
190
+ )
191
+
192
+
193
+ def run():
194
+ import uvicorn
195
+ host = _env("U3DP_HOST", "ITA_HOST", "127.0.0.1") or "127.0.0.1"
196
+ port = int(_env("U3DP_PORT", "ITA_PORT", "8765") or "8765")
197
+ uvicorn.run("unit3dprep.web.app:app", host=host, port=port, reload=False)
198
+
199
+
200
+ if __name__ == "__main__":
201
+ run()
unit3dprep/web/auth.py ADDED
@@ -0,0 +1,41 @@
1
+ """Auth helpers: bcrypt password check, session management."""
2
+ import bcrypt
3
+ from fastapi import Request
4
+ from starlette.responses import RedirectResponse
5
+
6
+ from ._env import env as _env
7
+
8
+ PASSWORD_HASH = _env("U3DP_PASSWORD_HASH", "ITA_PASSWORD_HASH", "") or ""
9
+ SECRET_KEY = _env("U3DP_SECRET", "ITA_SECRET", "changeme-set-U3DP_SECRET") or "changeme-set-U3DP_SECRET"
10
+ SESSION_ID_KEY = "session_id"
11
+ AUTH_KEY = "authenticated"
12
+
13
+
14
+ def verify_password(plain: str) -> bool:
15
+ if not PASSWORD_HASH:
16
+ return False
17
+ try:
18
+ return bcrypt.checkpw(plain.encode(), PASSWORD_HASH.encode())
19
+ except Exception:
20
+ return False
21
+
22
+
23
+ def login_session(request: Request, session_id: str):
24
+ request.session[AUTH_KEY] = True
25
+ request.session[SESSION_ID_KEY] = session_id
26
+
27
+
28
+ def logout_session(request: Request):
29
+ request.session.clear()
30
+
31
+
32
+ def is_authenticated(request: Request) -> bool:
33
+ return bool(request.session.get(AUTH_KEY))
34
+
35
+
36
+ def get_session_id(request: Request) -> str:
37
+ return request.session.get(SESSION_ID_KEY, "")
38
+
39
+
40
+ def redirect_to_login(next_url: str = "/") -> RedirectResponse:
41
+ return RedirectResponse(f"/login?next={next_url}", status_code=303)
@@ -0,0 +1,167 @@
1
+ """Torrent-client abstraction.
2
+
3
+ v1 wires up qBittorrent (Web API). Transmission/rTorrent surface as "not
4
+ implemented" so the Settings page can advertise them without crashing the
5
+ Queue view.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import time
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+
18
+ @dataclass
19
+ class Torrent:
20
+ hash: str
21
+ name: str
22
+ size: int
23
+ progress: float
24
+ state: str # seeding|uploading|queued|error|pending|paused|other
25
+ ratio: float
26
+ category: str
27
+ tracker: str
28
+ save_path: str
29
+
30
+
31
+ class TorrentClient(ABC):
32
+ name: str = "generic"
33
+
34
+ @abstractmethod
35
+ async def list(self) -> list[Torrent]: ...
36
+
37
+ @abstractmethod
38
+ async def reseed(self, torrent_hash: str) -> None: ...
39
+
40
+ @abstractmethod
41
+ async def remove(self, torrent_hash: str, delete_files: bool = False) -> None: ...
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # qBittorrent Web API (v2)
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ _QBIT_STATE_MAP = {
50
+ "uploading": "seeding",
51
+ "stalledUP": "seeding",
52
+ "forcedUP": "seeding",
53
+ "queuedUP": "queued",
54
+ "checkingUP": "seeding",
55
+ "downloading": "uploading",
56
+ "forcedDL": "uploading",
57
+ "stalledDL": "uploading",
58
+ "queuedDL": "queued",
59
+ "checkingDL": "uploading",
60
+ "metaDL": "uploading",
61
+ "pausedUP": "paused",
62
+ "pausedDL": "paused",
63
+ "error": "error",
64
+ "missingFiles": "error",
65
+ "unknown": "pending",
66
+ "allocating": "pending",
67
+ "moving": "pending",
68
+ }
69
+
70
+
71
+ class QBittorrentClient(TorrentClient):
72
+ name = "qbittorrent"
73
+
74
+ def __init__(self, host: str, port: str | int, user: str, password: str):
75
+ self.base = f"http://{host}:{port}"
76
+ self.user = user
77
+ self.password = password
78
+ self._client: httpx.AsyncClient | None = None
79
+ self._logged_in_at: float = 0.0
80
+
81
+ async def _http(self) -> httpx.AsyncClient:
82
+ if self._client is None:
83
+ self._client = httpx.AsyncClient(base_url=self.base, timeout=15.0)
84
+ # Re-login every 30 min to keep cookie fresh
85
+ if time.time() - self._logged_in_at > 1800:
86
+ r = await self._client.post(
87
+ "/api/v2/auth/login",
88
+ data={"username": self.user, "password": self.password},
89
+ headers={"Referer": self.base},
90
+ )
91
+ if r.status_code != 200 or r.text.strip() != "Ok.":
92
+ raise RuntimeError(f"qBittorrent login failed: {r.status_code} {r.text!r}")
93
+ self._logged_in_at = time.time()
94
+ return self._client
95
+
96
+ async def list(self) -> list[Torrent]:
97
+ cli = await self._http()
98
+ r = await cli.get("/api/v2/torrents/info")
99
+ r.raise_for_status()
100
+ out: list[Torrent] = []
101
+ for t in r.json():
102
+ state = _QBIT_STATE_MAP.get(t.get("state", "unknown"), "pending")
103
+ out.append(Torrent(
104
+ hash=t.get("hash", ""),
105
+ name=t.get("name", ""),
106
+ size=int(t.get("size", 0) or 0),
107
+ progress=float(t.get("progress", 0) or 0),
108
+ state=state,
109
+ ratio=float(t.get("ratio", 0) or 0),
110
+ category=t.get("category", ""),
111
+ tracker=t.get("tracker", ""),
112
+ save_path=t.get("save_path", ""),
113
+ ))
114
+ return out
115
+
116
+ async def reseed(self, torrent_hash: str) -> None:
117
+ cli = await self._http()
118
+ r = await cli.post("/api/v2/torrents/recheck", data={"hashes": torrent_hash})
119
+ r.raise_for_status()
120
+ r2 = await cli.post("/api/v2/torrents/resume", data={"hashes": torrent_hash})
121
+ r2.raise_for_status()
122
+
123
+ async def remove(self, torrent_hash: str, delete_files: bool = False) -> None:
124
+ cli = await self._http()
125
+ r = await cli.post(
126
+ "/api/v2/torrents/delete",
127
+ data={"hashes": torrent_hash, "deleteFiles": "true" if delete_files else "false"},
128
+ )
129
+ r.raise_for_status()
130
+
131
+
132
+ class _NotImplementedClient(TorrentClient):
133
+ def __init__(self, name: str):
134
+ self.name = name
135
+
136
+ async def list(self) -> list[Torrent]:
137
+ raise NotImplementedError(f"{self.name} client not implemented yet")
138
+
139
+ async def reseed(self, torrent_hash: str) -> None:
140
+ raise NotImplementedError(f"{self.name} client not implemented yet")
141
+
142
+ async def remove(self, torrent_hash: str, delete_files: bool = False) -> None:
143
+ raise NotImplementedError(f"{self.name} client not implemented yet")
144
+
145
+
146
+ _client_cache: tuple[tuple, TorrentClient] | None = None
147
+
148
+
149
+ def get_client(cfg: dict[str, Any]) -> TorrentClient:
150
+ global _client_cache
151
+ which = (cfg.get("TORRENT_CLIENT") or "qbittorrent").lower()
152
+ if which == "qbittorrent":
153
+ host = cfg.get("QBIT_HOST", "127.0.0.1")
154
+ port = cfg.get("QBIT_PORT", "15491")
155
+ user = cfg.get("QBIT_USER", "admin")
156
+ password = cfg.get("QBIT_PASS", "")
157
+ key = ("qbittorrent", host, str(port), user, password)
158
+ if _client_cache is not None and _client_cache[0] == key:
159
+ return _client_cache[1]
160
+ cli = QBittorrentClient(host=host, port=port, user=user, password=password)
161
+ _client_cache = (key, cli)
162
+ return cli
163
+ if which == "transmission":
164
+ return _NotImplementedClient("transmission")
165
+ if which == "rtorrent":
166
+ return _NotImplementedClient("rtorrent")
167
+ return _NotImplementedClient(which)