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
unit3dprep/core.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Pure-logic functions. No print/input/sys.exit side effects."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from pymediainfo import MediaInfo
|
|
11
|
+
except ImportError:
|
|
12
|
+
MediaInfo = None # type: ignore
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from guessit import guessit
|
|
16
|
+
except ImportError:
|
|
17
|
+
guessit = None # type: ignore
|
|
18
|
+
|
|
19
|
+
def seedings_dir() -> Path:
|
|
20
|
+
"""Configured hardlink target; env → shared .env → ~/seedings."""
|
|
21
|
+
default = str(Path.home() / "seedings")
|
|
22
|
+
try:
|
|
23
|
+
from .web import config
|
|
24
|
+
return Path(config.runtime_setting("U3DP_SEEDINGS_DIR", default=default))
|
|
25
|
+
except Exception:
|
|
26
|
+
from .web._env import env as _env
|
|
27
|
+
return Path(_env("U3DP_SEEDINGS_DIR", "ITA_SEEDINGS_DIR", default) or default)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Back-compat constant (resolves at import; use seedings_dir() for live-reload).
|
|
31
|
+
SEEDINGS_DIR = seedings_dir()
|
|
32
|
+
VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".webm", ".wmv", ".flv"}
|
|
33
|
+
ITA_TAGS = {"it", "ita", "italian", "italiano"}
|
|
34
|
+
TMDB_BASE = "https://api.themoviedb.org/3"
|
|
35
|
+
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
|
|
36
|
+
def tmdb_default_lang() -> str:
|
|
37
|
+
"""Re-evaluate every call so .env edits take effect without restart."""
|
|
38
|
+
try:
|
|
39
|
+
from .web import config
|
|
40
|
+
return config.runtime_setting("U3DP_TMDB_LANG", default="it-IT")
|
|
41
|
+
except Exception:
|
|
42
|
+
from .web._env import env as _env
|
|
43
|
+
return _env("U3DP_TMDB_LANG", "ITA_TMDB_LANG", "it-IT") or "it-IT"
|
|
44
|
+
|
|
45
|
+
LANG_MAP = {
|
|
46
|
+
"it": "ITA", "ita": "ITA", "italian": "ITA", "italiano": "ITA",
|
|
47
|
+
"en": "ENG", "eng": "ENG", "english": "ENG",
|
|
48
|
+
"es": "SPA", "spa": "SPA", "spanish": "SPA",
|
|
49
|
+
"fr": "FRE", "fra": "FRE", "fre": "FRE", "french": "FRE",
|
|
50
|
+
"de": "GER", "ger": "GER", "deu": "GER", "german": "GER",
|
|
51
|
+
"ja": "JPN", "jpn": "JPN", "japanese": "JPN",
|
|
52
|
+
"ko": "KOR", "kor": "KOR", "korean": "KOR",
|
|
53
|
+
"zh": "CHI", "chi": "CHI", "zho": "CHI", "chinese": "CHI",
|
|
54
|
+
"pt": "POR", "por": "POR", "portuguese": "POR",
|
|
55
|
+
"ru": "RUS", "rus": "RUS", "russian": "RUS",
|
|
56
|
+
"nl": "DUT", "dut": "DUT", "nld": "DUT", "dutch": "DUT",
|
|
57
|
+
"pl": "POL", "pol": "POL", "polish": "POL",
|
|
58
|
+
"sv": "SWE", "swe": "SWE", "swedish": "SWE",
|
|
59
|
+
"tr": "TUR", "tur": "TUR", "turkish": "TUR",
|
|
60
|
+
"ar": "ARA", "ara": "ARA", "arabic": "ARA",
|
|
61
|
+
"hi": "HIN", "hin": "HIN", "hindi": "HIN",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
CHANNELS_MAP = {1: "1.0", 2: "2.0", 3: "2.1", 6: "5.1", 7: "6.1", 8: "7.1", 10: "9.1", 12: "11.1"}
|
|
65
|
+
|
|
66
|
+
STREAM_ABBR = {
|
|
67
|
+
"netflix": "NF", "amazon": "AMZN", "amazon prime video": "AMZN",
|
|
68
|
+
"disney+": "DSNP", "disney plus": "DSNP", "apple tv+": "ATVP",
|
|
69
|
+
"hbo max": "HMAX", "max": "HMAX", "hulu": "HULU", "paramount+": "PMTP",
|
|
70
|
+
"peacock": "PCOK", "sky": "SKY", "now": "NOW", "rai": "RAI",
|
|
71
|
+
"crunchyroll": "CR",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# MediaInfo: audio detection
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _audio_langs(track) -> list[str]:
|
|
80
|
+
cands = []
|
|
81
|
+
if track.language:
|
|
82
|
+
cands.append(track.language)
|
|
83
|
+
other = getattr(track, "other_language", None)
|
|
84
|
+
if other:
|
|
85
|
+
cands.extend(other if isinstance(other, list) else [other])
|
|
86
|
+
# Fallback: if no language tag, try track title (some muxers store "Italian", "ITA", etc. there)
|
|
87
|
+
if not cands:
|
|
88
|
+
title = getattr(track, "title", None) or ""
|
|
89
|
+
if title:
|
|
90
|
+
cands.append(title)
|
|
91
|
+
return [c for c in cands if c]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def audio_languages(path: Path) -> list[str]:
|
|
95
|
+
"""Return sorted unique normalised language codes for all audio tracks.
|
|
96
|
+
|
|
97
|
+
ITA appears first if present; remaining codes are alphabetically sorted.
|
|
98
|
+
Returns empty list if pymediainfo not available or parse fails.
|
|
99
|
+
"""
|
|
100
|
+
if MediaInfo is None:
|
|
101
|
+
return []
|
|
102
|
+
try:
|
|
103
|
+
info = MediaInfo.parse(str(path))
|
|
104
|
+
except Exception:
|
|
105
|
+
return []
|
|
106
|
+
seen: list[str] = []
|
|
107
|
+
audio_track_count = 0
|
|
108
|
+
for track in info.tracks:
|
|
109
|
+
if track.track_type != "Audio":
|
|
110
|
+
continue
|
|
111
|
+
audio_track_count += 1
|
|
112
|
+
for c in _audio_langs(track):
|
|
113
|
+
normalized = c.lower().strip()
|
|
114
|
+
# Handle IETF tags like "it-IT", "en-US" → use primary subtag
|
|
115
|
+
if "-" in normalized and "_" not in normalized:
|
|
116
|
+
normalized = normalized.split("-")[0]
|
|
117
|
+
elif "_" in normalized:
|
|
118
|
+
normalized = normalized.split("_")[0]
|
|
119
|
+
code = LANG_MAP.get(normalized)
|
|
120
|
+
if code and code not in seen:
|
|
121
|
+
seen.append(code)
|
|
122
|
+
# Audio tracks exist but none have a recognised language tag → mark as undetermined
|
|
123
|
+
if audio_track_count > 0 and not seen:
|
|
124
|
+
return ["UND"]
|
|
125
|
+
# ITA first, rest alpha
|
|
126
|
+
has_ita = "ITA" in seen
|
|
127
|
+
rest = sorted(c for c in seen if c != "ITA")
|
|
128
|
+
return (["ITA"] + rest) if has_ita else rest
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def has_italian_audio(path: Path) -> bool:
|
|
132
|
+
if MediaInfo is None:
|
|
133
|
+
raise RuntimeError("pymediainfo not installed")
|
|
134
|
+
try:
|
|
135
|
+
info = MediaInfo.parse(str(path))
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise RuntimeError(f"Cannot parse '{path}': {e}") from e
|
|
138
|
+
for track in info.tracks:
|
|
139
|
+
if track.track_type != "Audio":
|
|
140
|
+
continue
|
|
141
|
+
for c in _audio_langs(track):
|
|
142
|
+
normalized = c.lower().strip()
|
|
143
|
+
if "-" in normalized and "_" not in normalized:
|
|
144
|
+
normalized = normalized.split("-")[0]
|
|
145
|
+
elif "_" in normalized:
|
|
146
|
+
normalized = normalized.split("_")[0]
|
|
147
|
+
if normalized in ITA_TAGS:
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def iter_video_files(folder: Path):
|
|
153
|
+
for f in sorted(folder.rglob("*")):
|
|
154
|
+
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS:
|
|
155
|
+
yield f
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# MediaInfo: technical specs
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def extract_specs(path: Path) -> dict:
|
|
163
|
+
if MediaInfo is None:
|
|
164
|
+
raise RuntimeError("pymediainfo not installed")
|
|
165
|
+
info = MediaInfo.parse(str(path))
|
|
166
|
+
video_track = next((t for t in info.tracks if t.track_type == "Video"), None)
|
|
167
|
+
audio_tracks = [t for t in info.tracks if t.track_type == "Audio"]
|
|
168
|
+
|
|
169
|
+
specs: dict = {
|
|
170
|
+
"resolution": "", "hdr": "", "vcodec_format": "",
|
|
171
|
+
"bit_depth": None, "scan_type": "", "writing_library": "",
|
|
172
|
+
"acodec": "", "channels": "", "object": "",
|
|
173
|
+
"dub": [],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if video_track:
|
|
177
|
+
height = getattr(video_track, "height", None)
|
|
178
|
+
width = getattr(video_track, "width", None)
|
|
179
|
+
scan = (getattr(video_track, "scan_type", "") or "Progressive").lower()
|
|
180
|
+
suffix = "i" if scan.startswith("interlaced") else "p"
|
|
181
|
+
if width:
|
|
182
|
+
# Width-first: robust against anamorphic/letterboxed crops that reduce height
|
|
183
|
+
# Thresholds: 3840→2160p, 1920→1080p, 1280→720p; SD falls back to height
|
|
184
|
+
if width >= 3200:
|
|
185
|
+
specs["resolution"] = f"2160{suffix}"
|
|
186
|
+
elif width >= 1600:
|
|
187
|
+
specs["resolution"] = f"1080{suffix}"
|
|
188
|
+
elif width >= 1100:
|
|
189
|
+
specs["resolution"] = f"720{suffix}"
|
|
190
|
+
elif height:
|
|
191
|
+
# SD: 720-wide for both PAL/NTSC → distinguish by height
|
|
192
|
+
for h in (576, 480):
|
|
193
|
+
if height >= h:
|
|
194
|
+
specs["resolution"] = f"{h}{suffix}"
|
|
195
|
+
break
|
|
196
|
+
elif height:
|
|
197
|
+
for h in (2160, 1080, 720, 576, 480):
|
|
198
|
+
if height >= h:
|
|
199
|
+
specs["resolution"] = f"{h}{suffix}"
|
|
200
|
+
break
|
|
201
|
+
specs["vcodec_format"] = (getattr(video_track, "format", "") or "")
|
|
202
|
+
specs["bit_depth"] = getattr(video_track, "bit_depth", None)
|
|
203
|
+
specs["scan_type"] = scan
|
|
204
|
+
specs["writing_library"] = getattr(video_track, "writing_library", "") or ""
|
|
205
|
+
|
|
206
|
+
hdr_fmt = (getattr(video_track, "hdr_format_commercial", "")
|
|
207
|
+
or getattr(video_track, "hdr_format", "") or "")
|
|
208
|
+
hdr_fmt_l = hdr_fmt.lower()
|
|
209
|
+
if "dolby vision" in hdr_fmt_l and "hdr10+" in hdr_fmt_l:
|
|
210
|
+
specs["hdr"] = "DV HDR10+"
|
|
211
|
+
elif "dolby vision" in hdr_fmt_l and "hdr10" in hdr_fmt_l:
|
|
212
|
+
specs["hdr"] = "DV HDR"
|
|
213
|
+
elif "dolby vision" in hdr_fmt_l:
|
|
214
|
+
specs["hdr"] = "DV"
|
|
215
|
+
elif "hdr10+" in hdr_fmt_l:
|
|
216
|
+
specs["hdr"] = "HDR10+"
|
|
217
|
+
elif "hdr10" in hdr_fmt_l or "smpte st 2086" in hdr_fmt_l:
|
|
218
|
+
specs["hdr"] = "HDR"
|
|
219
|
+
elif "hlg" in hdr_fmt_l:
|
|
220
|
+
specs["hdr"] = "HLG"
|
|
221
|
+
|
|
222
|
+
if audio_tracks:
|
|
223
|
+
main = audio_tracks[0]
|
|
224
|
+
fmt = (getattr(main, "format", "") or "").lower()
|
|
225
|
+
comm = (getattr(main, "format_commercial_if_any", "")
|
|
226
|
+
or getattr(main, "commercial_name", "") or "").lower()
|
|
227
|
+
profile = (getattr(main, "format_profile", "") or "").lower()
|
|
228
|
+
|
|
229
|
+
if "truehd" in fmt or "truehd" in comm:
|
|
230
|
+
specs["acodec"] = "TrueHD"
|
|
231
|
+
elif "dts" in fmt or "dts" in comm:
|
|
232
|
+
if "ma" in profile:
|
|
233
|
+
specs["acodec"] = "DTS-HD MA"
|
|
234
|
+
elif "hra" in profile or "hi" in profile:
|
|
235
|
+
specs["acodec"] = "DTS-HD HRA"
|
|
236
|
+
elif "x" in profile:
|
|
237
|
+
specs["acodec"] = "DTS:X"
|
|
238
|
+
elif "es" in profile:
|
|
239
|
+
specs["acodec"] = "DTS-ES"
|
|
240
|
+
else:
|
|
241
|
+
specs["acodec"] = "DTS"
|
|
242
|
+
elif "e-ac-3" in fmt or "eac3" in fmt or "dd+" in comm or "digital plus" in comm:
|
|
243
|
+
specs["acodec"] = "DD+"
|
|
244
|
+
elif "ac-3" in fmt or "ac3" in fmt:
|
|
245
|
+
specs["acodec"] = "DD"
|
|
246
|
+
elif "flac" in fmt:
|
|
247
|
+
specs["acodec"] = "FLAC"
|
|
248
|
+
elif "alac" in fmt:
|
|
249
|
+
specs["acodec"] = "ALAC"
|
|
250
|
+
elif "opus" in fmt:
|
|
251
|
+
specs["acodec"] = "Opus"
|
|
252
|
+
elif "pcm" in fmt:
|
|
253
|
+
specs["acodec"] = "LPCM"
|
|
254
|
+
elif "aac" in fmt:
|
|
255
|
+
specs["acodec"] = "AAC"
|
|
256
|
+
else:
|
|
257
|
+
specs["acodec"] = (getattr(main, "format", "") or "").upper()
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
ch = int(getattr(main, "channel_s", 0) or 0)
|
|
261
|
+
specs["channels"] = CHANNELS_MAP.get(ch, f"{ch}.0")
|
|
262
|
+
except (ValueError, TypeError):
|
|
263
|
+
specs["channels"] = ""
|
|
264
|
+
|
|
265
|
+
if "atmos" in comm or "atmos" in fmt:
|
|
266
|
+
specs["object"] = "Atmos"
|
|
267
|
+
elif "auro" in comm or "auro" in fmt:
|
|
268
|
+
specs["object"] = "Auro3D"
|
|
269
|
+
|
|
270
|
+
dubs = []
|
|
271
|
+
for t in audio_tracks:
|
|
272
|
+
for lang in _audio_langs(t):
|
|
273
|
+
code = LANG_MAP.get(lang.lower().strip())
|
|
274
|
+
if code and code not in dubs:
|
|
275
|
+
dubs.append(code)
|
|
276
|
+
specs["dub"] = dubs
|
|
277
|
+
|
|
278
|
+
return specs
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def vcodec_for_type(specs: dict, src_type: str) -> str:
|
|
282
|
+
fmt = (specs.get("vcodec_format") or "").upper()
|
|
283
|
+
writing = (specs.get("writing_library") or "").lower()
|
|
284
|
+
t = src_type.lower()
|
|
285
|
+
|
|
286
|
+
is_avc = "AVC" in fmt
|
|
287
|
+
is_hevc = "HEVC" in fmt or "H.265" in fmt
|
|
288
|
+
is_vp9 = "VP9" in fmt
|
|
289
|
+
is_mpeg2 = "MPEG" in fmt and "2" in fmt
|
|
290
|
+
is_vc1 = "VC-1" in fmt
|
|
291
|
+
|
|
292
|
+
if t in {"remux", "disc", "bluray", "uhd bluray", "3d bluray", "hddvd"}:
|
|
293
|
+
if is_hevc: return "HEVC"
|
|
294
|
+
if is_avc: return "AVC"
|
|
295
|
+
if is_mpeg2: return "MPEG-2"
|
|
296
|
+
if is_vc1: return "VC-1"
|
|
297
|
+
if t in {"web-dl", "hdtv", "uhdtv"}:
|
|
298
|
+
if is_hevc: return "H.265"
|
|
299
|
+
if is_avc: return "H.264"
|
|
300
|
+
if is_vp9: return "VP9"
|
|
301
|
+
if is_mpeg2: return "MPEG-2"
|
|
302
|
+
if "x265" in writing: return "x265"
|
|
303
|
+
if "x264" in writing: return "x264"
|
|
304
|
+
if is_hevc: return "x265"
|
|
305
|
+
if is_avc: return "x264"
|
|
306
|
+
return fmt
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def hi10p_flag(specs: dict) -> bool:
|
|
310
|
+
fmt = (specs.get("vcodec_format") or "").upper()
|
|
311
|
+
return ("AVC" in fmt) and (specs.get("bit_depth") == 10) and (not specs.get("hdr"))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# guessit → source/type
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def map_source(guess: dict) -> tuple[str, str]:
|
|
319
|
+
src = (guess.get("source") or "").lower()
|
|
320
|
+
other = guess.get("other") or []
|
|
321
|
+
if isinstance(other, str):
|
|
322
|
+
other = [other]
|
|
323
|
+
other_l = [o.lower() for o in other]
|
|
324
|
+
stream = (guess.get("streaming_service") or "").lower()
|
|
325
|
+
|
|
326
|
+
is_remux = "remux" in other_l
|
|
327
|
+
is_webdl = "web-dl" in other_l or src == "web"
|
|
328
|
+
is_webrip = "webrip" in other_l or "web-rip" in other_l
|
|
329
|
+
is_hdtv = src == "hdtv"
|
|
330
|
+
is_uhd = "ultra hd blu-ray" in src or "uhd" in other_l
|
|
331
|
+
|
|
332
|
+
if "blu-ray" in src or src == "bluray":
|
|
333
|
+
source = "UHD BluRay" if is_uhd else "BluRay"
|
|
334
|
+
if is_remux:
|
|
335
|
+
return source, "REMUX"
|
|
336
|
+
return source, ""
|
|
337
|
+
if is_webdl:
|
|
338
|
+
abbr = STREAM_ABBR.get(stream, stream.upper().replace(" ", "") if stream else "WEB")
|
|
339
|
+
return abbr, "WEB-DL"
|
|
340
|
+
if is_webrip:
|
|
341
|
+
abbr = STREAM_ABBR.get(stream, stream.upper().replace(" ", "") if stream else "WEB")
|
|
342
|
+
return abbr, "WEBRip"
|
|
343
|
+
if is_hdtv:
|
|
344
|
+
return ("UHDTV" if is_uhd else "HDTV"), ""
|
|
345
|
+
if "dvd" in src:
|
|
346
|
+
return "DVD", ""
|
|
347
|
+
return (src.upper() if src else ""), ""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# TMDB
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def tmdb_fetch(kind: str, tmdb_id: str, api_key: str, language: str | None = None) -> dict:
|
|
355
|
+
if not api_key:
|
|
356
|
+
raise RuntimeError("TMDB_API_KEY not set")
|
|
357
|
+
lang = language or tmdb_default_lang()
|
|
358
|
+
url = f"{TMDB_BASE}/{kind}/{urllib.parse.quote(str(tmdb_id))}?api_key={api_key}&language={lang}"
|
|
359
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
360
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
361
|
+
return json.loads(r.read().decode("utf-8"))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def tmdb_fetch_bilingual(kind: str, tmdb_id: str, api_key: str) -> dict:
|
|
365
|
+
"""Fetch TMDB in primary lang (TMDB_DEFAULT_LANG) and en-US. Returns merged dict with:
|
|
366
|
+
title, original_title, year, poster, overview (primary), overview_en, title_en.
|
|
367
|
+
"""
|
|
368
|
+
data_primary = tmdb_fetch(kind, tmdb_id, api_key, language=tmdb_default_lang())
|
|
369
|
+
data_en = tmdb_fetch(kind, tmdb_id, api_key, language="en-US")
|
|
370
|
+
|
|
371
|
+
title = data_primary.get("title") or data_primary.get("name") or ""
|
|
372
|
+
title_en = data_en.get("title") or data_en.get("name") or ""
|
|
373
|
+
original_title = (
|
|
374
|
+
data_primary.get("original_title") or data_primary.get("original_name") or ""
|
|
375
|
+
)
|
|
376
|
+
overview = (data_primary.get("overview") or "")[:300]
|
|
377
|
+
overview_en = (data_en.get("overview") or "")[:300]
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
**data_primary,
|
|
381
|
+
"title": title,
|
|
382
|
+
"title_en": title_en,
|
|
383
|
+
"original_title": original_title,
|
|
384
|
+
"overview": overview,
|
|
385
|
+
"overview_en": overview_en,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def tmdb_year(data: dict, kind: str) -> str:
|
|
390
|
+
date = data.get("release_date") if kind == "movie" else data.get("first_air_date")
|
|
391
|
+
if date and len(date) >= 4:
|
|
392
|
+
return date[:4]
|
|
393
|
+
return ""
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def tmdb_poster_url(data: dict) -> str:
|
|
397
|
+
path = data.get("poster_path")
|
|
398
|
+
if path:
|
|
399
|
+
return f"{TMDB_IMAGE_BASE}{path}"
|
|
400
|
+
return ""
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def tmdb_search(kind: str, query: str, year: str, api_key: str, language: str | None = None) -> list[dict]:
|
|
404
|
+
"""Search TMDB. kind='movie'|'tv'. Returns up to 5 normalized results."""
|
|
405
|
+
if not api_key:
|
|
406
|
+
raise RuntimeError("TMDB_API_KEY not set")
|
|
407
|
+
lang = language or tmdb_default_lang()
|
|
408
|
+
params: dict = {"api_key": api_key, "query": query, "language": lang}
|
|
409
|
+
if year:
|
|
410
|
+
if kind == "movie":
|
|
411
|
+
params["year"] = year
|
|
412
|
+
else:
|
|
413
|
+
params["first_air_date_year"] = year
|
|
414
|
+
url = f"{TMDB_BASE}/search/{kind}?" + urllib.parse.urlencode(params)
|
|
415
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
416
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
417
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
418
|
+
results = data.get("results", [])[:5]
|
|
419
|
+
normalized = []
|
|
420
|
+
for item in results:
|
|
421
|
+
title = item.get("title") or item.get("name") or ""
|
|
422
|
+
original_title = item.get("original_title") or item.get("original_name") or ""
|
|
423
|
+
date = item.get("release_date") or item.get("first_air_date") or ""
|
|
424
|
+
y = date[:4] if len(date) >= 4 else ""
|
|
425
|
+
poster = f"{TMDB_IMAGE_BASE}{item['poster_path']}" if item.get("poster_path") else ""
|
|
426
|
+
normalized.append({
|
|
427
|
+
"id": item["id"],
|
|
428
|
+
"title": title,
|
|
429
|
+
"original_title": original_title,
|
|
430
|
+
"year": y,
|
|
431
|
+
"poster": poster,
|
|
432
|
+
"overview": (item.get("overview") or "")[:200],
|
|
433
|
+
})
|
|
434
|
+
return normalized
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# Name builder
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
def sanitize(s: str) -> str:
|
|
442
|
+
bad = '<>:"/\\|?*'
|
|
443
|
+
return "".join(c for c in s if c not in bad).strip()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def build_name(
|
|
447
|
+
title: str,
|
|
448
|
+
year: str,
|
|
449
|
+
se: str,
|
|
450
|
+
specs: dict,
|
|
451
|
+
source: str,
|
|
452
|
+
src_type: str,
|
|
453
|
+
tag: str,
|
|
454
|
+
cut: str = "",
|
|
455
|
+
repack: str = "",
|
|
456
|
+
edition_flag: str = "",
|
|
457
|
+
dub_override: list[str] | None = None,
|
|
458
|
+
) -> str:
|
|
459
|
+
parts = [title]
|
|
460
|
+
if year:
|
|
461
|
+
parts.append(year)
|
|
462
|
+
if se:
|
|
463
|
+
parts.append(se)
|
|
464
|
+
if cut:
|
|
465
|
+
parts.append(cut)
|
|
466
|
+
if repack:
|
|
467
|
+
parts.append(repack)
|
|
468
|
+
if specs.get("resolution"):
|
|
469
|
+
parts.append(specs["resolution"])
|
|
470
|
+
if "3d" in (edition_flag or "").lower():
|
|
471
|
+
parts.append("3D")
|
|
472
|
+
if source:
|
|
473
|
+
parts.append(source)
|
|
474
|
+
if src_type:
|
|
475
|
+
parts.append(src_type)
|
|
476
|
+
|
|
477
|
+
is_disc_or_remux = src_type.upper() in {"REMUX", ""} and source in {
|
|
478
|
+
"BluRay", "UHD BluRay", "3D BluRay", "HDDVD"
|
|
479
|
+
}
|
|
480
|
+
dubs = dub_override if dub_override is not None else specs.get("dub", [])
|
|
481
|
+
dub_str = " ".join(dubs) if dubs else ""
|
|
482
|
+
|
|
483
|
+
if is_disc_or_remux:
|
|
484
|
+
if hi10p_flag(specs): parts.append("Hi10P")
|
|
485
|
+
if specs.get("hdr"): parts.append(specs["hdr"])
|
|
486
|
+
vc = vcodec_for_type(specs, src_type or source)
|
|
487
|
+
if vc: parts.append(vc)
|
|
488
|
+
if dub_str: parts.append(dub_str)
|
|
489
|
+
if specs.get("acodec"): parts.append(specs["acodec"])
|
|
490
|
+
if specs.get("channels"): parts.append(specs["channels"])
|
|
491
|
+
if specs.get("object"): parts.append(specs["object"])
|
|
492
|
+
else:
|
|
493
|
+
if dub_str: parts.append(dub_str)
|
|
494
|
+
if specs.get("acodec"): parts.append(specs["acodec"])
|
|
495
|
+
if specs.get("channels"): parts.append(specs["channels"])
|
|
496
|
+
if specs.get("object"): parts.append(specs["object"])
|
|
497
|
+
if hi10p_flag(specs): parts.append("Hi10P")
|
|
498
|
+
if specs.get("hdr"): parts.append(specs["hdr"])
|
|
499
|
+
vc = vcodec_for_type(specs, src_type or source)
|
|
500
|
+
if vc: parts.append(vc)
|
|
501
|
+
|
|
502
|
+
base = " ".join(p for p in parts if p)
|
|
503
|
+
if tag:
|
|
504
|
+
base = f"{base}-{tag}"
|
|
505
|
+
return sanitize(base)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def format_se(season: int | None, episode) -> str:
|
|
509
|
+
if season is None or episode is None:
|
|
510
|
+
return ""
|
|
511
|
+
if isinstance(episode, list) and episode:
|
|
512
|
+
if len(episode) == 1:
|
|
513
|
+
return f"S{season:02d}E{episode[0]:02d}"
|
|
514
|
+
ep_sorted = sorted(episode)
|
|
515
|
+
if ep_sorted == list(range(ep_sorted[0], ep_sorted[-1] + 1)):
|
|
516
|
+
return f"S{season:02d}E{ep_sorted[0]:02d}-{ep_sorted[-1]:02d}"
|
|
517
|
+
return f"S{season:02d}E{ep_sorted[0]:02d}E{ep_sorted[-1]:02d}"
|
|
518
|
+
if isinstance(episode, int):
|
|
519
|
+
return f"S{season:02d}E{episode:02d}"
|
|
520
|
+
return ""
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# Hardlink
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
def hardlink_file(src: Path, dst: Path, overwrite: bool = True):
|
|
528
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
if dst.exists():
|
|
530
|
+
if not overwrite:
|
|
531
|
+
return
|
|
532
|
+
if dst.is_dir():
|
|
533
|
+
shutil.rmtree(dst)
|
|
534
|
+
else:
|
|
535
|
+
dst.unlink()
|
|
536
|
+
os.link(src, dst)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def hardlink_tree(src_dir: Path, dst_dir: Path, episode_rename: dict[Path, str]):
|
|
540
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
for src_file in sorted(src_dir.rglob("*")):
|
|
542
|
+
if not src_file.is_file():
|
|
543
|
+
continue
|
|
544
|
+
if src_file.suffix.lower() not in VIDEO_EXTENSIONS:
|
|
545
|
+
continue
|
|
546
|
+
rel_parent = src_file.parent.relative_to(src_dir)
|
|
547
|
+
target_parent = dst_dir / rel_parent
|
|
548
|
+
target_parent.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
if src_file in episode_rename:
|
|
550
|
+
new_name = episode_rename[src_file] + src_file.suffix.lower()
|
|
551
|
+
else:
|
|
552
|
+
new_name = src_file.name
|
|
553
|
+
target = target_parent / new_name
|
|
554
|
+
if target.exists():
|
|
555
|
+
target.unlink()
|
|
556
|
+
os.link(src_file, target)
|