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,190 @@
|
|
|
1
|
+
"""HTTP client for Unit3DWebUp FastAPI bot.
|
|
2
|
+
|
|
3
|
+
Wraps the endpoints exposed by https://github.com/31December99/Unit3DWebUp.
|
|
4
|
+
Singleton pattern (one client per app lifespan) — held in `app.state.webup`.
|
|
5
|
+
Base URL resolved at instantiation time from `runtime_setting("WEBUP_URL")`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .config import runtime_setting
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
|
|
20
|
+
_HEALTH_TTL = 5.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def base_url() -> str:
|
|
24
|
+
return runtime_setting("WEBUP_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_job_id(file_or_folder_path: str) -> str:
|
|
28
|
+
"""Replicate Unit3DWebUp's job_id derivation: sha256(str(folder/subfolder)).
|
|
29
|
+
|
|
30
|
+
Webup normalizes its scan_path with `os.path.normpath` before deriving
|
|
31
|
+
job_ids, so we match that here.
|
|
32
|
+
"""
|
|
33
|
+
import os as _os
|
|
34
|
+
return hashlib.sha256(_os.path.normpath(str(file_or_folder_path)).encode()).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compute_job_list_id(scan_path: str) -> str:
|
|
38
|
+
return hashlib.sha256(str(scan_path).encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WebupError(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WebupClient:
|
|
46
|
+
def __init__(self, base: str | None = None) -> None:
|
|
47
|
+
self._base = (base or base_url()).rstrip("/")
|
|
48
|
+
self._client = httpx.AsyncClient(
|
|
49
|
+
base_url=self._base,
|
|
50
|
+
timeout=httpx.Timeout(connect=5.0, read=180.0, write=30.0, pool=5.0),
|
|
51
|
+
)
|
|
52
|
+
self._health_at: float = 0.0
|
|
53
|
+
self._health_ok: bool = False
|
|
54
|
+
self._health_payload: dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def base(self) -> str:
|
|
58
|
+
return self._base
|
|
59
|
+
|
|
60
|
+
async def aclose(self) -> None:
|
|
61
|
+
await self._client.aclose()
|
|
62
|
+
|
|
63
|
+
async def _post(self, path: str, payload: dict | None = None) -> httpx.Response:
|
|
64
|
+
try:
|
|
65
|
+
return await self._client.post(path, json=payload or {})
|
|
66
|
+
except httpx.HTTPError as e:
|
|
67
|
+
raise WebupError(f"webup {path} request failed: {e}") from e
|
|
68
|
+
|
|
69
|
+
async def _get(self, path: str) -> httpx.Response:
|
|
70
|
+
try:
|
|
71
|
+
return await self._client.get(path)
|
|
72
|
+
except httpx.HTTPError as e:
|
|
73
|
+
raise WebupError(f"webup {path} request failed: {e}") from e
|
|
74
|
+
|
|
75
|
+
async def health(self, force: bool = False) -> dict[str, Any]:
|
|
76
|
+
"""Cheap reachability check + version readout. Cached for 5 s."""
|
|
77
|
+
now = time.time()
|
|
78
|
+
if not force and (now - self._health_at) < _HEALTH_TTL:
|
|
79
|
+
return {"ok": self._health_ok, **self._health_payload}
|
|
80
|
+
t0 = time.perf_counter()
|
|
81
|
+
try:
|
|
82
|
+
r = await self._client.post("/setting", timeout=5.0)
|
|
83
|
+
r.raise_for_status()
|
|
84
|
+
data = r.json()
|
|
85
|
+
prefs = data.get("userPreferences") or {}
|
|
86
|
+
payload = {
|
|
87
|
+
"version": prefs.get("UNIT3DWEBUP__VERSION") or prefs.get("VERSION") or "",
|
|
88
|
+
"scan_path": prefs.get("SCAN_PATH"),
|
|
89
|
+
"torrent_archive_path": prefs.get("TORRENT_ARCHIVE_PATH"),
|
|
90
|
+
"torrent_client": prefs.get("TORRENT_CLIENT"),
|
|
91
|
+
"latency_ms": int((time.perf_counter() - t0) * 1000),
|
|
92
|
+
}
|
|
93
|
+
self._health_ok = True
|
|
94
|
+
self._health_payload = payload
|
|
95
|
+
except Exception as e:
|
|
96
|
+
self._health_ok = False
|
|
97
|
+
self._health_payload = {"error": str(e), "latency_ms": int((time.perf_counter() - t0) * 1000)}
|
|
98
|
+
self._health_at = now
|
|
99
|
+
return {"ok": self._health_ok, **self._health_payload}
|
|
100
|
+
|
|
101
|
+
async def setting(self) -> dict[str, Any]:
|
|
102
|
+
r = await self._post("/setting")
|
|
103
|
+
r.raise_for_status()
|
|
104
|
+
return r.json()
|
|
105
|
+
|
|
106
|
+
async def setenv(self, key: str, value: str) -> dict[str, Any]:
|
|
107
|
+
r = await self._post("/setenv", {"key": key, "value": value})
|
|
108
|
+
r.raise_for_status()
|
|
109
|
+
return r.json()
|
|
110
|
+
|
|
111
|
+
async def scan(self) -> dict[str, Any]:
|
|
112
|
+
# `path` field is required by the Pydantic model but ignored by the
|
|
113
|
+
# endpoint — it reads `app.state.scan_path` (set via PREFS__SCAN_PATH).
|
|
114
|
+
r = await self._post("/scan", {"path": "ignored"})
|
|
115
|
+
r.raise_for_status()
|
|
116
|
+
return r.json()
|
|
117
|
+
|
|
118
|
+
async def maketorrent(self, job_id: str) -> dict[str, Any] | None:
|
|
119
|
+
r = await self._post("/maketorrent", {"job_id": job_id})
|
|
120
|
+
r.raise_for_status()
|
|
121
|
+
return r.json() if r.text else None
|
|
122
|
+
|
|
123
|
+
async def upload(self, job_id: str) -> dict[str, Any] | None:
|
|
124
|
+
r = await self._post("/upload", {"job_id": job_id})
|
|
125
|
+
r.raise_for_status()
|
|
126
|
+
return r.json() if r.text else None
|
|
127
|
+
|
|
128
|
+
async def seed(self, job_id: str) -> tuple[int, dict[str, Any] | None]:
|
|
129
|
+
r = await self._post("/seed", {"job_id": job_id})
|
|
130
|
+
body: dict[str, Any] | None = None
|
|
131
|
+
if r.text:
|
|
132
|
+
try:
|
|
133
|
+
body = r.json()
|
|
134
|
+
except ValueError:
|
|
135
|
+
body = None
|
|
136
|
+
return r.status_code, body
|
|
137
|
+
|
|
138
|
+
async def processall(self, job_list_id: str) -> dict[str, Any] | None:
|
|
139
|
+
r = await self._post("/processall", {"job_list_id": job_list_id})
|
|
140
|
+
r.raise_for_status()
|
|
141
|
+
return r.json() if r.text else None
|
|
142
|
+
|
|
143
|
+
async def set_tmdbid(self, job_id: str, new_id: str) -> None:
|
|
144
|
+
r = await self._post("/settmdbid", {"job_id": job_id, "field_id": "tmdb_id", "new_id": str(new_id)})
|
|
145
|
+
r.raise_for_status()
|
|
146
|
+
|
|
147
|
+
async def set_tvdbid(self, job_id: str, new_id: str) -> None:
|
|
148
|
+
r = await self._post("/settvdbid", {"job_id": job_id, "field_id": "tvdb_id", "new_id": str(new_id)})
|
|
149
|
+
r.raise_for_status()
|
|
150
|
+
|
|
151
|
+
async def set_imdbid(self, job_id: str, new_id: str) -> None:
|
|
152
|
+
r = await self._post("/setimdbid", {"job_id": job_id, "field_id": "imdb_id", "new_id": str(new_id)})
|
|
153
|
+
r.raise_for_status()
|
|
154
|
+
|
|
155
|
+
async def set_poster_url(self, job_id: str, new_url: str) -> None:
|
|
156
|
+
r = await self._post("/setposterurl", {"job_id": job_id, "field_id": "backdrop_path", "new_id": new_url})
|
|
157
|
+
r.raise_for_status()
|
|
158
|
+
|
|
159
|
+
async def set_poster_dname(self, job_id: str, new_name: str) -> None:
|
|
160
|
+
r = await self._post("/setposterdname", {"job_id": job_id, "field_id": "display_name", "new_id": new_name})
|
|
161
|
+
r.raise_for_status()
|
|
162
|
+
|
|
163
|
+
async def filter_search(self, title: str) -> dict[str, Any]:
|
|
164
|
+
r = await self._post("/filter", {"title": title})
|
|
165
|
+
r.raise_for_status()
|
|
166
|
+
return r.json()
|
|
167
|
+
|
|
168
|
+
async def cjoblist(self, job_list_id: str) -> dict[str, Any] | None:
|
|
169
|
+
r = await self._post("/cjoblist", {"job_list_id": job_list_id})
|
|
170
|
+
r.raise_for_status()
|
|
171
|
+
return r.json() if r.text else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_singleton: WebupClient | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_client() -> WebupClient:
|
|
178
|
+
"""Lazy singleton. Recreated when WEBUP_URL changes."""
|
|
179
|
+
global _singleton
|
|
180
|
+
target = base_url()
|
|
181
|
+
if _singleton is None or _singleton.base != target:
|
|
182
|
+
_singleton = WebupClient(target)
|
|
183
|
+
return _singleton
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def shutdown_client() -> None:
|
|
187
|
+
global _singleton
|
|
188
|
+
if _singleton is not None:
|
|
189
|
+
await _singleton.aclose()
|
|
190
|
+
_singleton = None
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Workarounds for Unit3DwebUp 0.0.25 job-state bugs.
|
|
2
|
+
|
|
3
|
+
This module patches the Redis-backed Media record that webup builds in
|
|
4
|
+
its `/scan` step, BEFORE we invoke `/maketorrent` and `/upload`. We do
|
|
5
|
+
this because some webup logic mis-flags otherwise-uploadable media and
|
|
6
|
+
the upstream fix lives on the wrong branch / unreleased.
|
|
7
|
+
|
|
8
|
+
Currently it patches a single bug:
|
|
9
|
+
|
|
10
|
+
Audio-language gate (`tags_service.py:281`) leaves `media.can_upload`
|
|
11
|
+
permanently False if the FIRST audio track is not in the preferred
|
|
12
|
+
language. The check runs inside the per-track loop and only ever
|
|
13
|
+
flips the flag to False, never back to True — so a movie with [eng,
|
|
14
|
+
ita] tracks fails the gate even when `PREFERRED_LANG=it`. We detect
|
|
15
|
+
that case here (preferred language IS present in some track) and
|
|
16
|
+
force `can_upload = True` back.
|
|
17
|
+
|
|
18
|
+
If/when webup ships a fix, this whole module can be removed.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import redis.asyncio as aioredis
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("unit3dprep.webup_job_fix")
|
|
30
|
+
|
|
31
|
+
# Webup hardcodes Redis on localhost:6379 — see CLAUDE.md.
|
|
32
|
+
_REDIS_URL = "redis://127.0.0.1:6379/0"
|
|
33
|
+
|
|
34
|
+
# Season/episode token as written by the bridge's `build_name`: S01, S01E01,
|
|
35
|
+
# S01E01-E12, S01E01-12, S01E01E12.
|
|
36
|
+
_SEASON_TOKEN = re.compile(r"S\d{2}(?:E\d{2}(?:-?E?\d{2})?)?", re.IGNORECASE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _season_label(*sources: str | None) -> str:
|
|
40
|
+
"""First S##(E##) token from any bridge-built name, upper-cased."""
|
|
41
|
+
for src in sources:
|
|
42
|
+
if not src:
|
|
43
|
+
continue
|
|
44
|
+
m = _SEASON_TOKEN.search(src)
|
|
45
|
+
if m:
|
|
46
|
+
return m.group(0).upper()
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _languages_for_track(track: dict[str, Any]) -> set[str]:
|
|
51
|
+
"""All language codes mentioned by mediainfo for a single audio track."""
|
|
52
|
+
out: set[str] = set()
|
|
53
|
+
lang = track.get("language")
|
|
54
|
+
if isinstance(lang, str) and lang:
|
|
55
|
+
out.add(lang.strip().lower())
|
|
56
|
+
others = track.get("other_language")
|
|
57
|
+
if isinstance(others, (list, tuple)):
|
|
58
|
+
for v in others:
|
|
59
|
+
if isinstance(v, str) and v.strip():
|
|
60
|
+
out.add(v.strip().lower())
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _all_audio_languages(media: dict[str, Any]) -> set[str]:
|
|
65
|
+
out: set[str] = set()
|
|
66
|
+
mediafile = media.get("mediafile") or {}
|
|
67
|
+
tracks = mediafile.get("audio_tracks") or []
|
|
68
|
+
if not isinstance(tracks, list):
|
|
69
|
+
return out
|
|
70
|
+
for t in tracks:
|
|
71
|
+
if isinstance(t, dict):
|
|
72
|
+
out |= _languages_for_track(t)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _preferred_present(preferred: str, langs: set[str]) -> bool:
|
|
77
|
+
"""Is `preferred` (e.g. 'it') matched by any code in `langs`?
|
|
78
|
+
|
|
79
|
+
Mediainfo emits 2-letter ('it') and 3-letter ('ita') codes plus
|
|
80
|
+
plain-text names ('Italian'). We do a case-insensitive substring
|
|
81
|
+
match in either direction so 'it' matches 'ita', 'italian',
|
|
82
|
+
'italiano' — without false positives across unrelated codes
|
|
83
|
+
(we require an exact start-with).
|
|
84
|
+
"""
|
|
85
|
+
p = (preferred or "").strip().lower()
|
|
86
|
+
if not p:
|
|
87
|
+
return False
|
|
88
|
+
for code in langs:
|
|
89
|
+
if code == p:
|
|
90
|
+
return True
|
|
91
|
+
if code.startswith(p) or p.startswith(code):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def maybe_force_can_upload(job_id: str, preferred_lang: str) -> dict[str, Any]:
|
|
97
|
+
"""If webup wrongly set `can_upload=False`, force it back to True.
|
|
98
|
+
|
|
99
|
+
Returns a small dict describing what happened — useful for logs:
|
|
100
|
+
``{"patched": bool, "reason": str, "preferred": str,
|
|
101
|
+
"languages": [list]}``.
|
|
102
|
+
|
|
103
|
+
Never raises: on any failure returns ``{"patched": False,
|
|
104
|
+
"reason": "<error>"}`` so the caller can keep the upload flow
|
|
105
|
+
going.
|
|
106
|
+
"""
|
|
107
|
+
pref = (preferred_lang or "").strip().lower()
|
|
108
|
+
result: dict[str, Any] = {
|
|
109
|
+
"patched": False,
|
|
110
|
+
"preferred": pref,
|
|
111
|
+
"languages": [],
|
|
112
|
+
"reason": "",
|
|
113
|
+
}
|
|
114
|
+
if not pref:
|
|
115
|
+
result["reason"] = "no preferred lang set"
|
|
116
|
+
return result
|
|
117
|
+
try:
|
|
118
|
+
client = aioredis.from_url(_REDIS_URL, decode_responses=True)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
result["reason"] = f"redis connect failed: {e!r}"
|
|
121
|
+
return result
|
|
122
|
+
try:
|
|
123
|
+
try:
|
|
124
|
+
raw = await client.hget(job_id, "data")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
result["reason"] = f"redis hget failed: {e!r}"
|
|
127
|
+
return result
|
|
128
|
+
if not raw:
|
|
129
|
+
result["reason"] = "no job data in redis"
|
|
130
|
+
return result
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(raw)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
result["reason"] = f"json parse failed: {e!r}"
|
|
135
|
+
return result
|
|
136
|
+
if data.get("can_upload"):
|
|
137
|
+
result["reason"] = "can_upload already true"
|
|
138
|
+
return result
|
|
139
|
+
langs = sorted(_all_audio_languages(data))
|
|
140
|
+
result["languages"] = langs
|
|
141
|
+
if not _preferred_present(pref, set(langs)):
|
|
142
|
+
result["reason"] = f"preferred '{pref}' not in audio tracks"
|
|
143
|
+
return result
|
|
144
|
+
data["can_upload"] = True
|
|
145
|
+
try:
|
|
146
|
+
await client.hset(job_id, mapping={"data": json.dumps(data)})
|
|
147
|
+
except Exception as e:
|
|
148
|
+
result["reason"] = f"redis hset failed: {e!r}"
|
|
149
|
+
return result
|
|
150
|
+
result["patched"] = True
|
|
151
|
+
result["reason"] = (
|
|
152
|
+
f"forced can_upload=true (preferred '{pref}' present in audio tracks {langs})"
|
|
153
|
+
)
|
|
154
|
+
return result
|
|
155
|
+
finally:
|
|
156
|
+
try:
|
|
157
|
+
await client.aclose()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def maybe_inject_season(job_id: str) -> dict[str, Any]:
|
|
163
|
+
"""Ensure webup's tracker ``display_name`` carries the season label.
|
|
164
|
+
|
|
165
|
+
Webup builds ``display_name`` from ``PREFS__TAG_POSITION_SERIE`` but reads
|
|
166
|
+
that setting from a module-global ``settings`` captured at import time
|
|
167
|
+
(``media.py`` / ``tags_service.py``), so a corrected tag order in the
|
|
168
|
+
``.env`` only takes effect after a webup *restart*. To make the season
|
|
169
|
+
appear without restarting webup, patch the Redis job's ``display_name``
|
|
170
|
+
directly: take the ``S##(E##)`` token from the bridge-built folder/torrent
|
|
171
|
+
name and insert it right after the title.
|
|
172
|
+
|
|
173
|
+
Series only. Idempotent: skips if the label is already present. Never
|
|
174
|
+
raises — returns ``{"patched": bool, "reason": str, "display_name": str}``.
|
|
175
|
+
"""
|
|
176
|
+
result: dict[str, Any] = {"patched": False, "reason": "", "display_name": ""}
|
|
177
|
+
try:
|
|
178
|
+
client = aioredis.from_url(_REDIS_URL, decode_responses=True)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
result["reason"] = f"redis connect failed: {e!r}"
|
|
181
|
+
return result
|
|
182
|
+
try:
|
|
183
|
+
try:
|
|
184
|
+
raw = await client.hget(job_id, "data")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
result["reason"] = f"redis hget failed: {e!r}"
|
|
187
|
+
return result
|
|
188
|
+
if not raw:
|
|
189
|
+
result["reason"] = "no job data in redis"
|
|
190
|
+
return result
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(raw)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
result["reason"] = f"json parse failed: {e!r}"
|
|
195
|
+
return result
|
|
196
|
+
if data.get("category") != "series":
|
|
197
|
+
result["reason"] = "not a series"
|
|
198
|
+
return result
|
|
199
|
+
display = (data.get("display_name") or "").strip()
|
|
200
|
+
if not display:
|
|
201
|
+
result["reason"] = "no display_name"
|
|
202
|
+
return result
|
|
203
|
+
label = _season_label(
|
|
204
|
+
data.get("torrent_name"), data.get("title"), data.get("title_sanitized")
|
|
205
|
+
)
|
|
206
|
+
if not label:
|
|
207
|
+
result["reason"] = "no season token in source name"
|
|
208
|
+
return result
|
|
209
|
+
if re.search(rf"(?<![A-Za-z0-9]){re.escape(label)}(?![A-Za-z0-9])", display, re.IGNORECASE):
|
|
210
|
+
result["reason"] = f"season '{label}' already present"
|
|
211
|
+
return result
|
|
212
|
+
guess_title = (data.get("guess_title") or "").strip()
|
|
213
|
+
if guess_title and guess_title in display:
|
|
214
|
+
new_display = display.replace(guess_title, f"{guess_title} {label}", 1)
|
|
215
|
+
else:
|
|
216
|
+
new_display = f"{label} {display}"
|
|
217
|
+
data["display_name"] = new_display
|
|
218
|
+
try:
|
|
219
|
+
await client.hset(job_id, mapping={"data": json.dumps(data)})
|
|
220
|
+
except Exception as e:
|
|
221
|
+
result["reason"] = f"redis hset failed: {e!r}"
|
|
222
|
+
return result
|
|
223
|
+
result["patched"] = True
|
|
224
|
+
result["display_name"] = new_display
|
|
225
|
+
result["reason"] = f"inserted season '{label}' into display_name"
|
|
226
|
+
return result
|
|
227
|
+
finally:
|
|
228
|
+
try:
|
|
229
|
+
await client.aclose()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Classify WebSocket messages from Unit3DWebUp /ws into (kind, event) pairs.
|
|
2
|
+
|
|
3
|
+
Replaces the regex-on-stdout classifier (`logclass.py`) used for the unit3dup
|
|
4
|
+
CLI. Webup messages are already structured: `{type, level, message, job_id?}`.
|
|
5
|
+
We map level → log kind and the message text to a stable event slug used by
|
|
6
|
+
log filters/UI.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_LEVEL_TO_KIND = {
|
|
15
|
+
"success": "ok",
|
|
16
|
+
"ok": "ok",
|
|
17
|
+
"info": "info",
|
|
18
|
+
"warn": "warn",
|
|
19
|
+
"warning": "warn",
|
|
20
|
+
"error": "error",
|
|
21
|
+
"debug": "debug",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_RX_TORRENT_CREATED = re.compile(r"torrent.*(created|file exists)", re.I)
|
|
25
|
+
_RX_UPLOAD_OK = re.compile(r"\b(uploaded|successful|success)\b", re.I)
|
|
26
|
+
# Match: explicit fail words OR Laravel-style validation error dicts that
|
|
27
|
+
# webup forwards verbatim (e.g. "{'episode_number': ['The episode number
|
|
28
|
+
# field is required.']}").
|
|
29
|
+
_RX_UPLOAD_FAIL = re.compile(
|
|
30
|
+
r"\b(fail|failure|error|exception|denied|invalid|required|rejected)\b",
|
|
31
|
+
re.I,
|
|
32
|
+
)
|
|
33
|
+
_RX_SEED_OK = re.compile(r"added to (qbittorrent|transmission|rtorrent)", re.I)
|
|
34
|
+
_RX_SEED_FAIL = re.compile(r"(login failed|file not found)", re.I)
|
|
35
|
+
_RX_SCAN_DONE = re.compile(r"scan completato|scan complete|scan done", re.I)
|
|
36
|
+
_RX_TRACKER_ONLINE = re.compile(r"tracker.*online", re.I)
|
|
37
|
+
_RX_QBIT = re.compile(r"qbittorrent|qbit\b", re.I)
|
|
38
|
+
_RX_IMAGES = re.compile(r"(screenshot|image host|imgbb|passima|ptscreens)", re.I)
|
|
39
|
+
_RX_TMDB = re.compile(r"\b(tmdb|tvdb|imdb)\b", re.I)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def kind_for_level(level: str | None) -> str:
|
|
43
|
+
return _LEVEL_TO_KIND.get((level or "info").lower(), "info")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def classify_msg(msg: dict[str, Any]) -> tuple[str, str | None, str]:
|
|
47
|
+
"""
|
|
48
|
+
Returns (kind, event, text) for a webup WS message.
|
|
49
|
+
|
|
50
|
+
- kind: 'info'|'ok'|'warn'|'error'|'debug' for the log buffer.
|
|
51
|
+
- event: stable slug like 'upload.done', 'upload.maketorrent', 'scan.done', or None.
|
|
52
|
+
- text: the message string (already concatenated; use as the log line).
|
|
53
|
+
"""
|
|
54
|
+
mtype = (msg.get("type") or "").lower()
|
|
55
|
+
level = msg.get("level")
|
|
56
|
+
text = str(msg.get("message") or "")
|
|
57
|
+
kind = kind_for_level(level)
|
|
58
|
+
|
|
59
|
+
if mtype == "posterlogmessage":
|
|
60
|
+
if _RX_UPLOAD_OK.search(text):
|
|
61
|
+
return ("ok", "upload.done", text)
|
|
62
|
+
if _RX_UPLOAD_FAIL.search(text):
|
|
63
|
+
return ("error", "upload.done", text)
|
|
64
|
+
if _RX_TORRENT_CREATED.search(text):
|
|
65
|
+
return (kind, "upload.maketorrent", text)
|
|
66
|
+
if _RX_SEED_OK.search(text):
|
|
67
|
+
return ("ok", "upload.qbit", text)
|
|
68
|
+
if _RX_SEED_FAIL.search(text):
|
|
69
|
+
return ("error", "upload.qbit", text)
|
|
70
|
+
return (kind, "upload.target", text)
|
|
71
|
+
|
|
72
|
+
if mtype == "log":
|
|
73
|
+
if _RX_SCAN_DONE.search(text):
|
|
74
|
+
return ("ok", "scan.done", text)
|
|
75
|
+
if _RX_TRACKER_ONLINE.search(text):
|
|
76
|
+
return ("ok", "tracker.online", text)
|
|
77
|
+
if _RX_QBIT.search(text):
|
|
78
|
+
return (kind, "upload.qbit", text)
|
|
79
|
+
if _RX_IMAGES.search(text):
|
|
80
|
+
return (kind, "upload.images", text)
|
|
81
|
+
if _RX_TMDB.search(text):
|
|
82
|
+
return (kind, "upload.tmdb", text)
|
|
83
|
+
return (kind, None, text)
|
|
84
|
+
|
|
85
|
+
return (kind, None, text)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_terminal_success(msg: dict[str, Any]) -> bool:
|
|
89
|
+
"""True if this message indicates the upload succeeded for its job_id."""
|
|
90
|
+
if (msg.get("type") or "").lower() != "posterlogmessage":
|
|
91
|
+
return False
|
|
92
|
+
text = str(msg.get("message") or "")
|
|
93
|
+
return bool(_RX_UPLOAD_OK.search(text))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_terminal_failure(msg: dict[str, Any]) -> bool:
|
|
97
|
+
if (msg.get("level") or "").lower() == "error":
|
|
98
|
+
return True
|
|
99
|
+
if (msg.get("type") or "").lower() == "posterlogmessage":
|
|
100
|
+
text = str(msg.get("message") or "")
|
|
101
|
+
# "None" (string) means tracker rejected with no 'data' field returned
|
|
102
|
+
# by itt_tracker_service (response.get('data', None) when Unit3D API
|
|
103
|
+
# returns {'message':..., 'errors':{...}} without a 'data' key).
|
|
104
|
+
if not text or text == "None":
|
|
105
|
+
return True
|
|
106
|
+
return bool(_RX_UPLOAD_FAIL.search(text))
|
|
107
|
+
return False
|