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