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,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