unit3dprep 1.1.9__tar.gz → 1.2.0__tar.gz

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 (61) hide show
  1. {unit3dprep-1.1.9/unit3dprep.egg-info → unit3dprep-1.2.0}/PKG-INFO +1 -1
  2. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/pyproject.toml +1 -1
  3. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/core.py +31 -0
  4. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/upload.py +38 -8
  5. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/wizard.py +67 -23
  6. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/config.py +7 -1
  7. unit3dprep-1.2.0/unit3dprep/web/dist/assets/index-CPw9mcxL.js +280 -0
  8. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/index.html +1 -1
  9. unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +168 -0
  10. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/reseed.py +206 -45
  11. {unit3dprep-1.1.9 → unit3dprep-1.2.0/unit3dprep.egg-info}/PKG-INFO +1 -1
  12. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/SOURCES.txt +2 -2
  13. unit3dprep-1.1.9/unit3dprep/web/dist/assets/index-hDgVU3lh.js +0 -275
  14. unit3dprep-1.1.9/unit3dprep/web/duplicate_check.py +0 -92
  15. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/LICENSE +0 -0
  16. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/MANIFEST.in +0 -0
  17. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/README.md +0 -0
  18. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/setup.cfg +0 -0
  19. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/__init__.py +0 -0
  20. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/cli.py +0 -0
  21. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/i18n.py +0 -0
  22. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/media.py +0 -0
  23. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/__init__.py +0 -0
  24. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/_env.py +0 -0
  25. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/__init__.py +0 -0
  26. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/auth.py +0 -0
  27. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/fs.py +0 -0
  28. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/library.py +0 -0
  29. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/logs.py +0 -0
  30. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/queue.py +0 -0
  31. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/quickupload.py +0 -0
  32. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/reseed.py +0 -0
  33. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/search.py +0 -0
  34. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/settings.py +0 -0
  35. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/tmdb.py +0 -0
  36. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/trackers.py +0 -0
  37. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/uploaded.py +0 -0
  38. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/version.py +0 -0
  39. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/webup.py +0 -0
  40. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/app.py +0 -0
  41. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/auth.py +0 -0
  42. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/clients.py +0 -0
  43. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/db.py +0 -0
  44. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  45. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  46. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  47. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
  48. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/lang_cache.py +0 -0
  49. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/logbuf.py +0 -0
  50. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/tmdb_cache.py +0 -0
  51. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/tocheck.py +0 -0
  52. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/trackers.py +0 -0
  53. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_client.py +0 -0
  54. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_job_fix.py +0 -0
  55. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_logclass.py +0 -0
  56. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_orchestrator.py +0 -0
  57. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_ws.py +0 -0
  58. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/dependency_links.txt +0 -0
  59. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/entry_points.txt +0 -0
  60. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/requires.txt +0 -0
  61. {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unit3dprep
3
- Version: 1.1.9
3
+ Version: 1.2.0
4
4
  Summary: Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)
5
5
  Author: Davide Sidoti
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unit3dprep"
7
- version = "1.1.9"
7
+ version = "1.2.0"
8
8
  description = "Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -381,6 +381,37 @@ def hi10p_flag(specs: dict) -> bool:
381
381
  return ("AVC" in fmt) and (specs.get("bit_depth") == 10) and (not specs.get("hdr"))
382
382
 
383
383
 
384
+ # Attribute keys of `media_profile`, in display order.
385
+ PROFILE_KEYS = ("resolution", "codec", "source", "hdr", "audio", "dub", "group")
386
+
387
+
388
+ def media_profile(specs: dict, source: str, src_type: str, tag: str = "") -> dict[str, str]:
389
+ """Compact, comparable summary of one file's technical make-up.
390
+
391
+ Uses the very same labels `build_name` puts in the final name, so a season
392
+ pack whose episodes were encoded differently (e.g. one H.264 among H.265)
393
+ can be spotted by comparing profiles across files. Empty string = unknown.
394
+ """
395
+ codec = vcodec_for_type(specs, src_type or source)
396
+ if hi10p_flag(specs):
397
+ codec = f"{codec} Hi10P".strip()
398
+ return {
399
+ "resolution": specs.get("resolution") or "",
400
+ "codec": codec,
401
+ "source": " ".join(p for p in (source, src_type) if p),
402
+ "hdr": specs.get("hdr") or "",
403
+ "audio": " ".join(
404
+ p for p in (
405
+ specs.get("acodec") or "",
406
+ specs.get("channels") or "",
407
+ specs.get("object") or "",
408
+ ) if p
409
+ ),
410
+ "dub": " ".join(specs.get("dub") or []),
411
+ "group": tag or "",
412
+ }
413
+
414
+
384
415
  # ---------------------------------------------------------------------------
385
416
  # guessit → source/type
386
417
  # ---------------------------------------------------------------------------
@@ -29,6 +29,7 @@ from .core import (
29
29
  hardlink_tree,
30
30
  has_italian_audio,
31
31
  map_source,
32
+ media_profile,
32
33
  )
33
34
 
34
35
 
@@ -67,15 +68,20 @@ def check_audio_files(paths: list[Path]) -> list[AudioResult]:
67
68
  return results
68
69
 
69
70
 
70
- def build_episode_names(
71
+ def build_episode_names_detailed(
71
72
  series_folder: Path,
72
73
  video_files: list[Path],
73
74
  series_title: str,
74
75
  year: str,
75
76
  folder_guess: dict,
76
- ) -> dict[Path, str]:
77
- """Returns mapping file_path → new_base_name (no extension)."""
78
- episode_rename: dict[Path, str] = {}
77
+ ) -> dict[Path, tuple[str, dict[str, str]]]:
78
+ """Like `build_episode_names`, but also returns each file's media profile.
79
+
80
+ The profile comes from the same mediainfo parse used to build the name, so
81
+ callers that want to compare episodes (odd-one-out detection) pay nothing
82
+ extra for it.
83
+ """
84
+ episode_rename: dict[Path, tuple[str, dict[str, str]]] = {}
79
85
  for f in video_files:
80
86
  g = dict(guessit(f.name))
81
87
  season = g.get("season")
@@ -92,24 +98,48 @@ def build_episode_names(
92
98
  title=series_title, year="", se=se,
93
99
  specs=specs, source=source, src_type=src_type, tag=tag,
94
100
  )
95
- episode_rename[f] = new_name
101
+ episode_rename[f] = (new_name, media_profile(specs, source, src_type, tag))
96
102
  return episode_rename
97
103
 
98
104
 
99
- def build_movie_name_from_file(
105
+ def build_episode_names(
106
+ series_folder: Path,
107
+ video_files: list[Path],
108
+ series_title: str,
109
+ year: str,
110
+ folder_guess: dict,
111
+ ) -> dict[Path, str]:
112
+ """Returns mapping file_path → new_base_name (no extension)."""
113
+ detailed = build_episode_names_detailed(
114
+ series_folder, video_files, series_title, year, folder_guess
115
+ )
116
+ return {f: name for f, (name, _) in detailed.items()}
117
+
118
+
119
+ def build_movie_name_from_file_detailed(
100
120
  video_file: Path,
101
121
  movie_title: str,
102
122
  year: str,
103
- ) -> str:
123
+ ) -> tuple[str, dict[str, str]]:
124
+ """Returns `(new_base_name, media_profile)` for a single video file."""
104
125
  g = dict(guessit(video_file.name))
105
126
  specs = extract_specs(video_file)
106
127
  source, src_type = map_source(g)
107
128
  tag = g.get("release_group", "") or ""
108
129
  repack = "REPACK" if g.get("proper_count") else ""
109
- return build_name(
130
+ name = build_name(
110
131
  title=movie_title, year=year, se="",
111
132
  specs=specs, source=source, src_type=src_type, tag=tag, repack=repack,
112
133
  )
134
+ return name, media_profile(specs, source, src_type, tag)
135
+
136
+
137
+ def build_movie_name_from_file(
138
+ video_file: Path,
139
+ movie_title: str,
140
+ year: str,
141
+ ) -> str:
142
+ return build_movie_name_from_file_detailed(video_file, movie_title, year)[0]
113
143
 
114
144
 
115
145
  def do_hardlink_movie(src: Path, final_name: str) -> Path:
@@ -25,8 +25,8 @@ from ...core import (
25
25
  build_name,
26
26
  )
27
27
  from ...upload import (
28
- build_episode_names,
29
- build_movie_name_from_file,
28
+ build_episode_names_detailed,
29
+ build_movie_name_from_file_detailed,
30
30
  do_hardlink_movie,
31
31
  do_hardlink_series,
32
32
  )
@@ -132,6 +132,7 @@ async def wizard_start(request: Request, body: StartBody):
132
132
  "tmdb_poster": "",
133
133
  "tmdb_overview": "",
134
134
  "final_names": {},
135
+ "file_specs": {},
135
136
  "folder_name": "",
136
137
  "seeding_path": "",
137
138
  "upload_done": False,
@@ -263,25 +264,34 @@ async def wizard_tmdb(request: Request, tok: str, body: TmdbBody):
263
264
  "poster": state["tmdb_poster"],
264
265
  },
265
266
  "proposed": proposed,
267
+ "file_specs": state["file_specs"],
266
268
  "folder_name": state["folder_name"],
267
269
  })
268
270
 
269
271
 
270
272
  async def _build_proposed_names(state: dict[str, Any]) -> dict[str, str]:
273
+ """Propose a final name per video file and record each file's media profile.
274
+
275
+ `state["file_specs"]` maps file path → technical profile (resolution, codec,
276
+ source, …) so the names step can flag the odd file out inside a pack.
277
+ """
271
278
  from guessit import guessit as _guessit
272
279
  loop = asyncio.get_event_loop()
273
280
  path = Path(state["path"])
274
281
  kind = state["kind"]
275
282
  title = state["tmdb_title"]
276
283
  year = state["tmdb_year"]
284
+ specs_map: dict[str, dict[str, str]] = {}
285
+ state["file_specs"] = specs_map
277
286
  if kind == "movie":
278
287
  files = [path] if path.is_file() else list(iter_video_files(path))
279
288
  proposed: dict[str, str] = {}
280
289
  for vf in files:
281
- name = await loop.run_in_executor(
282
- None, build_movie_name_from_file, vf, title, year
290
+ name, profile = await loop.run_in_executor(
291
+ None, build_movie_name_from_file_detailed, vf, title, year
283
292
  )
284
293
  proposed[str(vf)] = name
294
+ specs_map[str(vf)] = profile
285
295
  return proposed
286
296
  if kind == "episode":
287
297
  files = [path] if path.is_file() else list(iter_video_files(path))
@@ -290,27 +300,32 @@ async def _build_proposed_names(state: dict[str, Any]) -> dict[str, str]:
290
300
  episode_file = files[0]
291
301
  season_folder = episode_file.parent
292
302
  folder_guess = dict(_guessit(season_folder.name))
293
- result = await loop.run_in_executor(
303
+ detailed = await loop.run_in_executor(
294
304
  None,
295
- lambda: {str(k): v for k, v in build_episode_names(
305
+ lambda: {str(k): v for k, v in build_episode_names_detailed(
296
306
  season_folder, [episode_file], title, year, folder_guess
297
307
  ).items()},
298
308
  )
309
+ result = {k: name for k, (name, _) in detailed.items()}
310
+ specs_map.update({k: profile for k, (_, profile) in detailed.items()})
299
311
  if not result:
300
- fallback = await loop.run_in_executor(
301
- None, build_movie_name_from_file, episode_file, title, ""
312
+ fallback, profile = await loop.run_in_executor(
313
+ None, build_movie_name_from_file_detailed, episode_file, title, ""
302
314
  )
303
315
  result = {str(episode_file): fallback}
316
+ specs_map[str(episode_file)] = profile
304
317
  return result
305
318
  # series
306
319
  folder_guess = dict(_guessit(path.name))
307
320
  files = list(iter_video_files(path))
308
- result = await loop.run_in_executor(
321
+ detailed = await loop.run_in_executor(
309
322
  None,
310
- lambda: {str(k): v for k, v in build_episode_names(
323
+ lambda: {str(k): v for k, v in build_episode_names_detailed(
311
324
  path, files, title, year, folder_guess
312
325
  ).items()},
313
326
  )
327
+ result = {k: name for k, (name, _) in detailed.items()}
328
+ specs_map.update({k: profile for k, (_, profile) in detailed.items()})
314
329
  if files:
315
330
  first = files[0]
316
331
  g = dict(_guessit(first.name))
@@ -342,18 +357,44 @@ async def wizard_names(tok: str, body: NamesBody):
342
357
  return JSONResponse({"ok": True})
343
358
 
344
359
 
345
- def _primary_source_size(state: dict[str, Any]) -> int | None:
346
- """Bytes of the file we're about to upload (None for season packs)."""
347
- if state["kind"] not in {"movie", "episode"}:
348
- return None
360
+ def _source_fingerprint(state: dict[str, Any]) -> tuple[int | None, int | None, list[int]]:
361
+ """(file_count, total_bytes, sorted per-file sizes) of what we'll upload.
362
+
363
+ Movie/episode → the single video file. Series (season pack / full series)
364
+ → every video file that ``hardlink_tree`` will place in the torrent, so the
365
+ fingerprint mirrors the packaged content. ``(None, None, [])`` when nothing
366
+ usable is found.
367
+ """
349
368
  path = Path(state["path"])
350
- src = path if path.is_file() else next(iter(iter_video_files(path)), None)
351
- if src is None:
352
- return None
369
+ if state["kind"] in {"movie", "episode"}:
370
+ src = path if path.is_file() else next(iter(iter_video_files(path)), None)
371
+ if src is None:
372
+ return (None, None, [])
373
+ try:
374
+ sz = src.stat().st_size
375
+ except OSError:
376
+ return (None, None, [])
377
+ return (1, sz, [sz])
378
+ if state["kind"] == "series":
379
+ sizes: list[int] = []
380
+ for f in iter_video_files(path):
381
+ try:
382
+ sizes.append(f.stat().st_size)
383
+ except OSError:
384
+ continue
385
+ if not sizes:
386
+ return (None, None, [])
387
+ return (len(sizes), sum(sizes), sorted(sizes))
388
+ return (None, None, [])
389
+
390
+
391
+ def _duplicate_tolerance_pct(cfg: dict[str, Any]) -> float:
392
+ """Configured size-match tolerance (%), clamped to a sane range."""
353
393
  try:
354
- return src.stat().st_size
355
- except OSError:
356
- return None
394
+ v = float(cfg.get("W_DUPLICATE_SIZE_TOLERANCE_PCT", 2.0))
395
+ except (TypeError, ValueError):
396
+ v = 2.0
397
+ return max(0.0, min(v, 50.0))
357
398
 
358
399
 
359
400
  @router.post("/wizard/{tok}/duplicate-check")
@@ -362,10 +403,10 @@ async def wizard_duplicate_check(tok: str):
362
403
  cfg = web_config.load()
363
404
  enabled = bool(cfg.get("W_DUPLICATE_CHECK", True))
364
405
  state["duplicate"] = None
365
- if not enabled or state["kind"] not in {"movie", "episode"}:
406
+ if not enabled:
366
407
  state["step"] = "hardlink"
367
408
  return JSONResponse({"enabled": enabled, "duplicate": None})
368
- size = _primary_source_size(state)
409
+ num_files, total_size, file_sizes = _source_fingerprint(state)
369
410
  tmdb_id = state.get("tmdb_id", "")
370
411
  tracker_url = (cfg.get("ITT_URL") or "").strip()
371
412
  api_token = (cfg.get("ITT_APIKEY") or "").strip()
@@ -373,7 +414,10 @@ async def wizard_duplicate_check(tok: str):
373
414
  tracker_url=tracker_url,
374
415
  api_token=api_token,
375
416
  tmdb_id=tmdb_id,
376
- size_bytes=size,
417
+ num_files=num_files,
418
+ total_size=total_size,
419
+ file_sizes=file_sizes,
420
+ tolerance_pct=_duplicate_tolerance_pct(cfg),
377
421
  )
378
422
  if match is None:
379
423
  state["step"] = "hardlink"
@@ -257,6 +257,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
257
257
  "W_HARDLINK_ONLY": False,
258
258
  "W_CONFIRM_NAMES": True,
259
259
  "W_DUPLICATE_CHECK": True,
260
+ "W_DUPLICATE_SIZE_TOLERANCE_PCT": 2.0,
260
261
  }
261
262
 
262
263
  MASKED_KEYS = {
@@ -555,7 +556,7 @@ _GROUPS: list[tuple[str, list[str]]] = [
555
556
  ("Wizard defaults (unit3dprep)", [
556
557
  "W_AUDIO_CHECK", "W_AUTO_TMDB", "W_HIDE_UPLOADED",
557
558
  "W_HIDE_NO_ITALIAN", "W_HARDLINK_ONLY", "W_CONFIRM_NAMES",
558
- "W_DUPLICATE_CHECK",
559
+ "W_DUPLICATE_CHECK", "W_DUPLICATE_SIZE_TOLERANCE_PCT",
559
560
  ]),
560
561
  ]
561
562
 
@@ -595,6 +596,11 @@ def _coerce_value(default_val: Any, raw: str) -> Any:
595
596
  return int(raw)
596
597
  except ValueError:
597
598
  return default_val
599
+ if isinstance(default_val, float):
600
+ try:
601
+ return float(raw)
602
+ except ValueError:
603
+ return default_val
598
604
  if isinstance(default_val, list):
599
605
  s = raw.strip()
600
606
  if s.startswith("["):