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.
- unit3dprep/__init__.py +0 -0
- unit3dprep/cli.py +256 -0
- unit3dprep/core.py +556 -0
- unit3dprep/i18n.py +151 -0
- unit3dprep/media.py +278 -0
- unit3dprep/upload.py +146 -0
- unit3dprep/web/__init__.py +0 -0
- unit3dprep/web/_env.py +40 -0
- unit3dprep/web/api/__init__.py +1 -0
- unit3dprep/web/api/auth.py +36 -0
- unit3dprep/web/api/fs.py +64 -0
- unit3dprep/web/api/library.py +503 -0
- unit3dprep/web/api/logs.py +42 -0
- unit3dprep/web/api/queue.py +54 -0
- unit3dprep/web/api/quickupload.py +153 -0
- unit3dprep/web/api/search.py +40 -0
- unit3dprep/web/api/settings.py +77 -0
- unit3dprep/web/api/tmdb.py +77 -0
- unit3dprep/web/api/trackers.py +30 -0
- unit3dprep/web/api/uploaded.py +26 -0
- unit3dprep/web/api/version.py +754 -0
- unit3dprep/web/api/webup.py +59 -0
- unit3dprep/web/api/wizard.py +512 -0
- unit3dprep/web/app.py +201 -0
- unit3dprep/web/auth.py +41 -0
- unit3dprep/web/clients.py +167 -0
- unit3dprep/web/config.py +932 -0
- unit3dprep/web/db.py +184 -0
- unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
- unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
- unit3dprep/web/dist/index.html +14 -0
- unit3dprep/web/duplicate_check.py +92 -0
- unit3dprep/web/lang_cache.py +118 -0
- unit3dprep/web/logbuf.py +164 -0
- unit3dprep/web/tmdb_cache.py +116 -0
- unit3dprep/web/trackers.py +177 -0
- unit3dprep/web/webup_client.py +190 -0
- unit3dprep/web/webup_job_fix.py +231 -0
- unit3dprep/web/webup_logclass.py +107 -0
- unit3dprep/web/webup_orchestrator.py +796 -0
- unit3dprep/web/webup_ws.py +141 -0
- unit3dprep-1.0.4.dist-info/METADATA +819 -0
- unit3dprep-1.0.4.dist-info/RECORD +50 -0
- unit3dprep-1.0.4.dist-info/WHEEL +5 -0
- unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
- unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
- 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)
|
unit3dprep/web/logbuf.py
ADDED
|
@@ -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
|
+
}
|