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 @@
1
+ @font-face{font-family:Space Grotesk;src:url(./SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf) format("truetype");font-weight:100 900;font-style:normal;font-display:swap}@font-face{font-family:JetBrains Mono;src:url(./JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf) format("truetype");font-weight:100 900;font-style:normal;font-display:swap}@font-face{font-family:JetBrains Mono;src:url(./JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf) format("truetype");font-weight:100 900;font-style:italic;font-display:swap}:root{--bg-base: #08090c;--bg-surface: #0f1117;--bg-card: #161b25;--bg-card-hover: #1c2232;--bg-overlay: rgba(0, 0, 0, .7);--border: #1e2536;--border-subtle: #141924;--border-focus: #3b82f6;--blue-dim: #1d3e7a;--blue: #3b82f6;--blue-bright: #60a5fa;--blue-muted: rgba(59, 130, 246, .15);--yellow-dim: #78450a;--yellow: #f5a623;--yellow-bright: #fbbf24;--yellow-muted: rgba(245, 166, 35, .15);--red-dim: #5b1a1a;--red: #f87171;--red-bright: #fca5a5;--red-muted: rgba(248, 113, 113, .15);--green-dim: #14442b;--green: #4ade80;--green-bright: #86efac;--green-muted: rgba(74, 222, 128, .15);--cyan-dim: #0c3a4a;--cyan: #22d3ee;--cyan-bright: #67e8f9;--cyan-muted: rgba(34, 211, 238, .15);--fg-1: #e2e8f0;--fg-2: #94a3b8;--fg-3: #475569;--fg-4: #2d3a4f;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-8: 32px;--space-10: 40px;--space-12: 48px;--space-16: 64px;--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-pill: 9999px;--font-display: "Space Grotesk", "Helvetica Neue", sans-serif;--font-body: "Space Grotesk", "Helvetica Neue", sans-serif;--font-mono: "JetBrains Mono", "Fira Code", "Courier New", monospace;--text-xs: 11px;--text-sm: 13px;--text-base: 14px;--text-md: 15px;--text-lg: 17px;--text-xl: 20px;--text-2xl: 24px;--text-3xl: 30px;--leading-tight: 1.2;--leading-snug: 1.35;--leading-normal: 1.5;--leading-relaxed: 1.65;--tracking-tight: -.02em;--tracking-wide: .05em;--tracking-wider: .08em}*{margin:0;padding:0;box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);color:var(--fg-1);background:var(--bg-base);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}::-webkit-scrollbar{width:5px;height:5px}::-webkit-scrollbar-track{background:var(--bg-surface)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:9999px}input,select,textarea{transition:border-color .15s;font-family:inherit}input:focus,select:focus,textarea:focus{border-color:var(--blue)!important;outline:none}button{font-family:inherit}a{color:var(--blue-bright);text-decoration:none}a:hover{color:var(--blue-bright)}code,pre,kbd{font-family:var(--font-mono);font-size:var(--text-sm)}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}@keyframes blink{50%{opacity:0}}@keyframes u3d-slide-in{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes u3d-fade-in{0%{opacity:0}to{opacity:1}}@media (max-width: 768px){.u3d-hide-mobile{display:none!important}.u3d-mobile-modal{width:100%!important;max-width:100%!important;height:100%!important;max-height:100%!important;border-radius:0!important}.u3d-mobile-fullbleed{padding-left:14px!important;padding-right:14px!important}}
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2212%22%20fill%3D%22%230a0c12%22%2F%3E%3Crect%20x%3D%223%22%20y%3D%223%22%20width%3D%2258%22%20height%3D%2258%22%20rx%3D%229%22%20fill%3D%22none%22%20stroke%3D%22%233b82f6%22%20stroke-width%3D%222%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2245%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%20Black%2Csans-serif%22%20font-size%3D%2228%22%20font-weight%3D%22900%22%20fill%3D%22%233b82f6%22%3E3D%3C%2Ftext%3E%3Ccircle%20cx%3D%2252%22%20cy%3D%2212%22%20r%3D%223%22%20fill%3D%22%2334d399%22%2F%3E%3C%2Fsvg%3E" />
7
+ <title>Unit3DPrep — Web UI</title>
8
+ <script type="module" crossorigin src="./assets/index-BizNr_oP.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-DChRHChM.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1,92 @@
1
+ """Pre-upload duplicate detection against the ITT Unit3D API.
2
+
3
+ Webup 0.0.25 does not implement duplicate detection (`DUPLICATE_ON` /
4
+ `SKIP_DUPLICATE` are commented `# Todo Not yet implemented` in its
5
+ `config/settings.py`). The legacy `unit3dup` CLI used to query the
6
+ tracker by TMDB id and refuse the upload when an existing torrent had
7
+ the *exact* same file size in bytes — irrespective of name/encode/etc.
8
+ We replicate that behaviour here as a pre-flight performed by the
9
+ bridge before invoking webup.
10
+
11
+ Triggered by the `W_DUPLICATE_CHECK` runtime setting (default ON).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ log = logging.getLogger("unit3dprep.duplicate_check")
21
+
22
+ _TIMEOUT = httpx.Timeout(15.0, connect=5.0)
23
+
24
+
25
+ async def find_duplicate(
26
+ *,
27
+ tracker_url: str,
28
+ api_token: str,
29
+ tmdb_id: int | str | None,
30
+ size_bytes: int | None,
31
+ ) -> dict[str, Any] | None:
32
+ """Query the tracker for any existing torrent that matches `size_bytes`.
33
+
34
+ Returns a dict with the matched torrent details (suitable for showing
35
+ to the user) when a duplicate is found, or ``None`` otherwise.
36
+
37
+ Returns ``None`` (no false positives) when any of the inputs is
38
+ missing, the API call fails, or the response is unexpected. The
39
+ caller can always treat ``None`` as "no duplicate".
40
+ """
41
+ if not tracker_url or not api_token or not tmdb_id or not size_bytes:
42
+ return None
43
+ try:
44
+ size_int = int(size_bytes)
45
+ tmdb_int = int(tmdb_id)
46
+ except (TypeError, ValueError):
47
+ return None
48
+ if size_int <= 0 or tmdb_int <= 0:
49
+ return None
50
+
51
+ base = tracker_url.rstrip("/")
52
+ url = f"{base}/api/torrents/filter"
53
+ params = {"tmdbId": str(tmdb_int), "api_token": api_token, "perPage": "100"}
54
+ try:
55
+ async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
56
+ r = await client.get(url, params=params)
57
+ r.raise_for_status()
58
+ payload = r.json()
59
+ except (httpx.HTTPError, ValueError) as e:
60
+ log.warning("duplicate check failed (%s): %s", url, e)
61
+ return None
62
+
63
+ items = payload.get("data") if isinstance(payload, dict) else None
64
+ if not isinstance(items, list):
65
+ return None
66
+
67
+ for entry in items:
68
+ if not isinstance(entry, dict):
69
+ continue
70
+ attrs = entry.get("attributes") or {}
71
+ existing_size = attrs.get("size")
72
+ try:
73
+ existing_size = int(existing_size) if existing_size is not None else None
74
+ except (TypeError, ValueError):
75
+ continue
76
+ if existing_size != size_int:
77
+ continue
78
+ return {
79
+ "id": entry.get("id") or attrs.get("id"),
80
+ "name": attrs.get("name"),
81
+ "size": existing_size,
82
+ "type": attrs.get("type"),
83
+ "resolution": attrs.get("resolution"),
84
+ "category": attrs.get("category"),
85
+ "uploader": attrs.get("uploader"),
86
+ "seeders": attrs.get("seeders"),
87
+ "leechers": attrs.get("leechers"),
88
+ "created_at": attrs.get("created_at"),
89
+ "details_link": attrs.get("details_link"),
90
+ "tmdb_id": tmdb_int,
91
+ }
92
+ return None
@@ -0,0 +1,118 @@
1
+ """Audio language cache — JSON file store. Thread-safe. No sqlite3.
2
+
3
+ Keys are source_path strings (unresolved for series/seasons, same convention as tmdb_cache).
4
+ Schema per entry:
5
+ {
6
+ "langs": ["ITA", "ENG", ...],
7
+ "episode_langs": { "<str(filepath)>": ["ITA", "ENG"] }, # only for series/seasons
8
+ "scanned_at": "YYYY-MM-DD HH:MM:SS"
9
+ }
10
+ """
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ _lock = threading.Lock()
19
+
20
+
21
+ def _cache_path() -> Path:
22
+ default = str(Path.home() / ".unit3dprep_lang_cache.json")
23
+ try:
24
+ from . import config
25
+ return Path(config.runtime_setting("U3DP_LANG_CACHE_PATH", default=default))
26
+ except Exception:
27
+ from ._env import env as _env
28
+ return Path(_env("U3DP_LANG_CACHE_PATH", "ITA_LANG_CACHE_PATH", default) or default)
29
+
30
+
31
+ CACHE_PATH = _cache_path()
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Sync helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _load() -> dict:
39
+ path = _cache_path()
40
+ if not path.exists():
41
+ return {}
42
+ try:
43
+ with open(path, "r", encoding="utf-8") as f:
44
+ return json.load(f)
45
+ except (json.JSONDecodeError, OSError):
46
+ return {}
47
+
48
+
49
+ def _save(data: dict):
50
+ path = _cache_path()
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ with open(path, "w", encoding="utf-8") as f:
53
+ json.dump(data, f, indent=2, ensure_ascii=False)
54
+
55
+
56
+ def _get_sync(source_path: str) -> dict | None:
57
+ with _lock:
58
+ return _load().get(source_path)
59
+
60
+
61
+ def _set_sync(source_path: str, record: dict):
62
+ with _lock:
63
+ data = _load()
64
+ data[source_path] = record
65
+ _save(data)
66
+
67
+
68
+ def _get_many_sync(source_paths: list) -> dict:
69
+ with _lock:
70
+ data = _load()
71
+ return {sp: data[sp] for sp in source_paths if sp in data}
72
+
73
+
74
+ def _delete_sync(source_path: str):
75
+ with _lock:
76
+ data = _load()
77
+ data.pop(source_path, None)
78
+ _save(data)
79
+
80
+
81
+ def _list_all_sync() -> dict:
82
+ with _lock:
83
+ return _load()
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Async wrappers
88
+ # ---------------------------------------------------------------------------
89
+
90
+ async def _run(fn, *args):
91
+ loop = asyncio.get_event_loop()
92
+ return await loop.run_in_executor(None, fn, *args)
93
+
94
+
95
+ async def get_lang(source_path: str) -> dict | None:
96
+ return await _run(_get_sync, source_path)
97
+
98
+
99
+ async def set_lang(source_path: str, langs: list, episode_langs: dict | None = None):
100
+ record: dict = {
101
+ "langs": langs,
102
+ "scanned_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
103
+ }
104
+ if episode_langs is not None:
105
+ record["episode_langs"] = episode_langs
106
+ await _run(_set_sync, source_path, record)
107
+
108
+
109
+ async def get_many_langs(source_paths: list) -> dict:
110
+ return await _run(_get_many_sync, [str(sp) for sp in source_paths])
111
+
112
+
113
+ async def delete_lang(source_path: str):
114
+ await _run(_delete_sync, source_path)
115
+
116
+
117
+ async def list_all_langs() -> dict:
118
+ return await _run(_list_all_sync)
@@ -0,0 +1,164 @@
1
+ """In-memory log ring buffer with async subscribers for SSE tail.
2
+
3
+ Captures records from the root logger (and any child loggers) and emits them
4
+ to every subscriber's asyncio.Queue. New SSE clients receive the last
5
+ `HISTORY` entries immediately then see live updates.
6
+
7
+ Each entry carries:
8
+ ts - "HH:MM:SS" (GMT)
9
+ kind - "info" | "ok" | "warn" | "error" | "debug"
10
+ name - raw logger name (e.g. "httpx", "unit3dup")
11
+ msg - formatted message
12
+ source - user-facing category: app|http|upload|client|tracker|wizard|unit3dup|system
13
+ event - optional slug for UI grouping (e.g. "upload.tmdb")
14
+ count - present when consecutive duplicates were coalesced (>=2)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import time
21
+ from collections import deque
22
+ from typing import Any, Deque
23
+
24
+ HISTORY = 500
25
+ COALESCE_WINDOW = 2.0 # seconds
26
+
27
+ _history: Deque[dict] = deque(maxlen=HISTORY)
28
+ _subscribers: set[asyncio.Queue] = set()
29
+ _main_loop: asyncio.AbstractEventLoop | None = None
30
+
31
+
32
+ def _level_kind(level: int) -> str:
33
+ if level >= logging.ERROR:
34
+ return "error"
35
+ if level >= logging.WARNING:
36
+ return "warn"
37
+ if level >= logging.INFO:
38
+ return "info"
39
+ return "debug"
40
+
41
+
42
+ def _infer_source(name: str) -> str:
43
+ n = (name or "").lower()
44
+ if n.startswith("httpx") or n.startswith("httpcore") or n.startswith("urllib3"):
45
+ return "http"
46
+ if n == "unit3dup":
47
+ return "unit3dup"
48
+ if n == "wizard":
49
+ return "wizard"
50
+ if n.startswith("uvicorn") or n.startswith("fastapi") or n.startswith("starlette"):
51
+ return "system"
52
+ if "tracker" in n:
53
+ return "tracker"
54
+ if "client" in n or "qbit" in n:
55
+ return "client"
56
+ if n.startswith("unit3dprep") or n.startswith("itatorrents"):
57
+ return "app"
58
+ return "app"
59
+
60
+
61
+ def _push(entry: dict) -> None:
62
+ """Append to history (with coalescence) and broadcast to subscribers."""
63
+ now = time.time()
64
+ last = _history[-1] if _history else None
65
+ if (
66
+ last is not None
67
+ and last.get("source") == entry.get("source")
68
+ and last.get("event") == entry.get("event")
69
+ and last.get("msg") == entry.get("msg")
70
+ and last.get("kind") == entry.get("kind")
71
+ and (now - last.get("_t", 0)) <= COALESCE_WINDOW
72
+ ):
73
+ last["count"] = int(last.get("count", 1)) + 1
74
+ last["ts"] = entry["ts"]
75
+ last["_t"] = now
76
+ out = {k: v for k, v in last.items() if k != "_t"}
77
+ _broadcast(out)
78
+ return
79
+ entry["_t"] = now
80
+ _history.append(entry)
81
+ out = {k: v for k, v in entry.items() if k != "_t"}
82
+ _broadcast(out)
83
+
84
+
85
+ def _broadcast(entry: dict) -> None:
86
+ if _main_loop is None:
87
+ return
88
+ for q in list(_subscribers):
89
+ try:
90
+ _main_loop.call_soon_threadsafe(q.put_nowait, entry)
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ class _RingHandler(logging.Handler):
96
+ def emit(self, record: logging.LogRecord) -> None:
97
+ try:
98
+ entry = {
99
+ "ts": time.strftime("%H:%M:%S", time.gmtime(record.created)),
100
+ "kind": _level_kind(record.levelno),
101
+ "name": record.name,
102
+ "msg": self.format(record),
103
+ "source": _infer_source(record.name),
104
+ }
105
+ except Exception:
106
+ return
107
+ _push(entry)
108
+
109
+
110
+ def install(loop: asyncio.AbstractEventLoop) -> None:
111
+ """Attach the ring handler to the root logger. Call once at startup."""
112
+ global _main_loop
113
+ _main_loop = loop
114
+ h = _RingHandler()
115
+ h.setLevel(logging.INFO)
116
+ h.setFormatter(logging.Formatter("%(message)s"))
117
+ root = logging.getLogger()
118
+ for existing in list(root.handlers):
119
+ if isinstance(existing, _RingHandler):
120
+ break
121
+ else:
122
+ root.addHandler(h)
123
+ if root.level > logging.INFO or root.level == logging.NOTSET:
124
+ root.setLevel(logging.INFO)
125
+
126
+ # Silence noisy HTTP-client loggers: every qBittorrent poll logs a GET/POST
127
+ # at INFO on httpx, which floods the Logs tab. Surface only real problems.
128
+ for noisy in ("httpx", "httpcore", "urllib3"):
129
+ logging.getLogger(noisy).setLevel(logging.WARNING)
130
+
131
+
132
+ def history() -> list[dict]:
133
+ return [{k: v for k, v in e.items() if k != "_t"} for e in _history]
134
+
135
+
136
+ def subscribe() -> asyncio.Queue:
137
+ q: asyncio.Queue = asyncio.Queue()
138
+ _subscribers.add(q)
139
+ return q
140
+
141
+
142
+ def unsubscribe(q: asyncio.Queue) -> None:
143
+ _subscribers.discard(q)
144
+
145
+
146
+ def emit(
147
+ kind: str,
148
+ msg: str,
149
+ name: str = "app",
150
+ *,
151
+ source: str | None = None,
152
+ event: str | None = None,
153
+ ) -> None:
154
+ """Manual emit helper for business events (upload started, etc.)."""
155
+ entry: dict[str, Any] = {
156
+ "ts": time.strftime("%H:%M:%S", time.gmtime()),
157
+ "kind": kind,
158
+ "name": name,
159
+ "msg": msg,
160
+ "source": source or _infer_source(name),
161
+ }
162
+ if event:
163
+ entry["event"] = event
164
+ _push(entry)
@@ -0,0 +1,116 @@
1
+ """TMDB metadata cache — JSON file store. Thread-safe. No sqlite3.
2
+
3
+ Keys are resolved absolute source_path strings.
4
+ Schema per entry (all fields optional for backward compat — read with .get()):
5
+ { tmdb_id, tmdb_kind,
6
+ title, # primary lang (TMDB_DEFAULT_LANG, default it-IT)
7
+ title_en, # en-US title
8
+ original_title, # TMDB original_title / original_name
9
+ year, poster,
10
+ overview, # primary lang
11
+ overview_en, # en-US
12
+ fetched_at }
13
+ """
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ _lock = threading.Lock()
22
+
23
+
24
+ def _cache_path() -> Path:
25
+ default = str(Path.home() / ".unit3dprep_tmdb_cache.json")
26
+ try:
27
+ from . import config
28
+ return Path(config.runtime_setting("U3DP_TMDB_CACHE_PATH", default=default))
29
+ except Exception:
30
+ from ._env import env as _env
31
+ return Path(_env("U3DP_TMDB_CACHE_PATH", "ITA_TMDB_CACHE_PATH", default) or default)
32
+
33
+
34
+ CACHE_PATH = _cache_path()
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Sync helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _load() -> dict:
42
+ path = _cache_path()
43
+ if not path.exists():
44
+ return {}
45
+ try:
46
+ with open(path, "r", encoding="utf-8") as f:
47
+ return json.load(f)
48
+ except (json.JSONDecodeError, OSError):
49
+ return {}
50
+
51
+
52
+ def _save(data: dict):
53
+ path = _cache_path()
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ with open(path, "w", encoding="utf-8") as f:
56
+ json.dump(data, f, indent=2, ensure_ascii=False)
57
+
58
+
59
+ def _get_sync(source_path: str) -> dict | None:
60
+ with _lock:
61
+ return _load().get(source_path)
62
+
63
+
64
+ def _set_sync(source_path: str, record: dict):
65
+ with _lock:
66
+ data = _load()
67
+ data[source_path] = record
68
+ _save(data)
69
+
70
+
71
+ def _get_many_sync(source_paths: list) -> dict:
72
+ with _lock:
73
+ data = _load()
74
+ return {sp: data[sp] for sp in source_paths if sp in data}
75
+
76
+
77
+ def _delete_sync(source_path: str):
78
+ with _lock:
79
+ data = _load()
80
+ data.pop(source_path, None)
81
+ _save(data)
82
+
83
+
84
+ def _list_all_sync() -> dict:
85
+ with _lock:
86
+ return _load()
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Async wrappers
91
+ # ---------------------------------------------------------------------------
92
+
93
+ async def _run(fn, *args):
94
+ loop = asyncio.get_event_loop()
95
+ return await loop.run_in_executor(None, fn, *args)
96
+
97
+
98
+ async def get_cache(source_path: str) -> dict | None:
99
+ return await _run(_get_sync, source_path)
100
+
101
+
102
+ async def set_cache(source_path: str, **fields):
103
+ record = {**fields, "fetched_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())}
104
+ await _run(_set_sync, source_path, record)
105
+
106
+
107
+ async def get_many(source_paths: list) -> dict:
108
+ return await _run(_get_many_sync, [str(sp) for sp in source_paths])
109
+
110
+
111
+ async def delete_cache(source_path: str):
112
+ await _run(_delete_sync, source_path)
113
+
114
+
115
+ async def list_all_cache() -> dict:
116
+ return await _run(_list_all_sync)
@@ -0,0 +1,177 @@
1
+ """Tracker (Unit3D) API wrappers.
2
+
3
+ v1 wires up ITT (Unit3D public API). PTT/SIS return 501 on search so the
4
+ UI can render the tabs without crashing when the user hasn't filled in
5
+ their credentials.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, asdict
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+
16
+ @dataclass
17
+ class SearchResult:
18
+ tracker: str
19
+ id: int
20
+ name: str
21
+ type: str # Movie|Serie|Game|Doc
22
+ resolution: str # 4K|1080p|720p|—
23
+ size: str # human-readable
24
+ seeders: int
25
+ leechers: int
26
+ uploader: str
27
+ url: str # details URL
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return asdict(self)
31
+
32
+
33
+ class Tracker(ABC):
34
+ key: str = "generic"
35
+ label: str = "Generic"
36
+
37
+ @abstractmethod
38
+ async def status(self) -> bool: ...
39
+
40
+ @abstractmethod
41
+ async def search(self, query: str) -> list[SearchResult]: ...
42
+
43
+
44
+ def _human_size(b: int) -> str:
45
+ for unit, size in [("TB", 1 << 40), ("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)]:
46
+ if b >= size:
47
+ return f"{b / size:.1f} {unit}"
48
+ return f"{b} B"
49
+
50
+
51
+ def _resolution_for(attrs: dict[str, Any]) -> str:
52
+ res_id = attrs.get("resolution_id")
53
+ res_name = (attrs.get("resolution") or "").lower()
54
+ if "2160" in res_name or res_id == 1:
55
+ return "4K"
56
+ if "1080" in res_name:
57
+ return "1080p"
58
+ if "720" in res_name:
59
+ return "720p"
60
+ if "576" in res_name or "480" in res_name:
61
+ return "SD"
62
+ return "—"
63
+
64
+
65
+ def _type_for(attrs: dict[str, Any]) -> str:
66
+ cat = (attrs.get("category") or "").lower()
67
+ if "tv" in cat or "serie" in cat:
68
+ return "Serie"
69
+ if "movie" in cat or "film" in cat:
70
+ return "Movie"
71
+ if "game" in cat:
72
+ return "Game"
73
+ if "doc" in cat or "book" in cat:
74
+ return "Doc"
75
+ return "Movie"
76
+
77
+
78
+ class Unit3DTracker(Tracker):
79
+ """Generic Unit3D tracker. Subclass with site-specific key/label/url."""
80
+
81
+ base_url: str
82
+ api_key: str
83
+
84
+ def __init__(self, key: str, label: str, url: str, api_key: str):
85
+ self.key = key
86
+ self.label = label
87
+ self.base_url = url.rstrip("/")
88
+ self.api_key = api_key
89
+
90
+ @property
91
+ def configured(self) -> bool:
92
+ return bool(self.base_url) and bool(self.api_key) and self.api_key not in {"no_key", ""}
93
+
94
+ async def status(self) -> bool:
95
+ if not self.configured:
96
+ return False
97
+ try:
98
+ async with httpx.AsyncClient(timeout=5.0) as c:
99
+ r = await c.get(self.base_url)
100
+ return r.status_code < 500
101
+ except Exception:
102
+ return False
103
+
104
+ async def search(self, query: str) -> list[SearchResult]:
105
+ if not self.api_key or self.api_key in {"no_key", ""}:
106
+ raise RuntimeError(f"{self.key} API key not configured")
107
+ params = {"api_token": self.api_key, "name": query, "perPage": 25}
108
+ async with httpx.AsyncClient(timeout=15.0) as c:
109
+ r = await c.get(f"{self.base_url}/api/torrents/filter", params=params)
110
+ r.raise_for_status()
111
+ payload = r.json()
112
+ out: list[SearchResult] = []
113
+ for entry in payload.get("data", [])[:25]:
114
+ attrs = entry.get("attributes") or entry
115
+ tid = int(entry.get("id") or attrs.get("id") or 0)
116
+ size_val = attrs.get("size")
117
+ try:
118
+ size_int = int(size_val)
119
+ size_h = _human_size(size_int)
120
+ except (TypeError, ValueError):
121
+ size_h = str(size_val or "—")
122
+ out.append(SearchResult(
123
+ tracker=self.key,
124
+ id=tid,
125
+ name=attrs.get("name") or "",
126
+ type=_type_for(attrs),
127
+ resolution=_resolution_for(attrs),
128
+ size=size_h,
129
+ seeders=int(attrs.get("seeders") or 0),
130
+ leechers=int(attrs.get("leechers") or 0),
131
+ uploader=attrs.get("uploader") or "",
132
+ url=f"{self.base_url}/torrents/{tid}",
133
+ ))
134
+ return out
135
+
136
+
137
+ class _StubTracker(Tracker):
138
+ def __init__(self, key: str, label: str, url: str, api_key: str = ""):
139
+ self.key = key
140
+ self.label = label
141
+ self.url = url.rstrip("/")
142
+ self.api_key = api_key
143
+
144
+ @property
145
+ def configured(self) -> bool:
146
+ return bool(self.url) and bool(self.api_key) and self.api_key not in {"no_key", ""}
147
+
148
+ async def status(self) -> bool:
149
+ if not self.configured:
150
+ return False
151
+ try:
152
+ async with httpx.AsyncClient(timeout=5.0) as c:
153
+ r = await c.get(self.url)
154
+ return r.status_code < 500
155
+ except Exception:
156
+ return False
157
+
158
+ async def search(self, query: str) -> list[SearchResult]:
159
+ raise NotImplementedError(f"{self.key} search not implemented yet")
160
+
161
+
162
+ def build_trackers(cfg: dict[str, Any]) -> dict[str, Tracker]:
163
+ """Instantiate all three trackers from the shared .env config."""
164
+ return {
165
+ "ITT": Unit3DTracker(
166
+ "ITT", "ITA Torrents",
167
+ cfg.get("ITT_URL", ""), cfg.get("ITT_APIKEY", ""),
168
+ ),
169
+ "PTT": _StubTracker(
170
+ "PTT", "Polish Torrent",
171
+ cfg.get("PTT_URL", ""), cfg.get("PTT_APIKEY", ""),
172
+ ),
173
+ "SIS": _StubTracker(
174
+ "SIS", "SIS",
175
+ cfg.get("SIS_URL", ""), cfg.get("SIS_APIKEY", ""),
176
+ ),
177
+ }