unit3dprep 1.1.8__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.8/unit3dprep.egg-info → unit3dprep-1.2.0}/PKG-INFO +1 -1
  2. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/pyproject.toml +1 -1
  3. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/core.py +31 -0
  4. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/upload.py +38 -8
  5. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/library.py +141 -27
  6. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/wizard.py +67 -23
  7. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/config.py +7 -1
  8. unit3dprep-1.2.0/unit3dprep/web/dist/assets/index-CPw9mcxL.js +280 -0
  9. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/index.html +1 -1
  10. unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +168 -0
  11. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/reseed.py +206 -45
  12. {unit3dprep-1.1.8 → unit3dprep-1.2.0/unit3dprep.egg-info}/PKG-INFO +1 -1
  13. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/SOURCES.txt +1 -1
  14. unit3dprep-1.1.8/unit3dprep/web/dist/assets/index-EHy8CWCD.js +0 -275
  15. unit3dprep-1.1.8/unit3dprep/web/duplicate_check.py +0 -92
  16. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/LICENSE +0 -0
  17. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/MANIFEST.in +0 -0
  18. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/README.md +0 -0
  19. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/setup.cfg +0 -0
  20. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/__init__.py +0 -0
  21. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/cli.py +0 -0
  22. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/i18n.py +0 -0
  23. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/media.py +0 -0
  24. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/__init__.py +0 -0
  25. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/_env.py +0 -0
  26. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/__init__.py +0 -0
  27. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/auth.py +0 -0
  28. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/fs.py +0 -0
  29. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/logs.py +0 -0
  30. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/queue.py +0 -0
  31. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/quickupload.py +0 -0
  32. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/reseed.py +0 -0
  33. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/search.py +0 -0
  34. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/settings.py +0 -0
  35. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/tmdb.py +0 -0
  36. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/trackers.py +0 -0
  37. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/uploaded.py +0 -0
  38. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/version.py +0 -0
  39. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/webup.py +0 -0
  40. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/app.py +0 -0
  41. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/auth.py +0 -0
  42. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/clients.py +0 -0
  43. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/db.py +0 -0
  44. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  45. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  46. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  47. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
  48. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/lang_cache.py +0 -0
  49. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/logbuf.py +0 -0
  50. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/tmdb_cache.py +0 -0
  51. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/tocheck.py +0 -0
  52. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/trackers.py +0 -0
  53. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_client.py +0 -0
  54. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_job_fix.py +0 -0
  55. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_logclass.py +0 -0
  56. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_orchestrator.py +0 -0
  57. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_ws.py +0 -0
  58. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/dependency_links.txt +0 -0
  59. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/entry_points.txt +0 -0
  60. {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/requires.txt +0 -0
  61. {unit3dprep-1.1.8 → 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.8
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.8"
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:
@@ -432,15 +432,18 @@ def _ita_first(codes: list[str]) -> list[str]:
432
432
  return (["ITA"] + rest) if has_ita else rest
433
433
 
434
434
 
435
- async def _scan_langs_and_subs(video_files, loop):
436
- """Scan each video file once for audio + subtitle languages.
435
+ async def _scan_langs_and_subs_stream(video_files, loop):
436
+ """Async-generator variant of :func:`_scan_langs_and_subs`.
437
437
 
438
- Returns (merged_audio, episode_langs, merged_subs, episode_subs); audio/subs are ITA-first.
439
- Yields the event loop between files so SSE streams stay responsive."""
438
+ Yields ``("progress", n, path)`` after each file is scanned (``n`` = 1-based
439
+ count of files done so far), then a terminal
440
+ ``("result", merged_audio, episode_langs, merged_subs, episode_subs)``.
441
+ Callers that only need the aggregate use :func:`_scan_langs_and_subs`."""
440
442
  episode_langs: dict[str, list[str]] = {}
441
443
  episode_subs: dict[str, list[str]] = {}
442
444
  seen: list[str] = []
443
445
  seen_subs: list[str] = []
446
+ done = 0
444
447
  for vf in video_files:
445
448
  try:
446
449
  langs, subs = await loop.run_in_executor(None, audio_and_subtitle_languages, vf)
@@ -454,12 +457,26 @@ async def _scan_langs_and_subs(video_files, loop):
454
457
  for sub in subs:
455
458
  if sub not in seen_subs:
456
459
  seen_subs.append(sub)
460
+ done += 1
461
+ yield ("progress", done, str(vf))
457
462
  await asyncio.sleep(0.05)
458
- return _ita_first(seen), episode_langs, _ita_first(seen_subs), episode_subs
463
+ yield ("result", _ita_first(seen), episode_langs, _ita_first(seen_subs), episode_subs)
464
+
465
+
466
+ async def _scan_langs_and_subs(video_files, loop):
467
+ """Scan each video file once for audio + subtitle languages.
468
+
469
+ Returns (merged_audio, episode_langs, merged_subs, episode_subs); audio/subs are ITA-first.
470
+ Yields the event loop between files so SSE streams stay responsive."""
471
+ result = ([], {}, [], {})
472
+ async for ev in _scan_langs_and_subs_stream(video_files, loop):
473
+ if ev[0] == "result":
474
+ result = ev[1:]
475
+ return result
459
476
 
460
477
 
461
478
  @router.get("/library/{category}/scan-langs")
462
- async def library_scan_langs(request: Request, category: str):
479
+ async def library_scan_langs(request: Request, category: str, force: bool = False):
463
480
  if category not in discover_categories():
464
481
  raise HTTPException(404, _i18n_t("err.category_not_found", get_request_lang(request)))
465
482
 
@@ -474,32 +491,46 @@ async def library_scan_langs(request: Request, category: str):
474
491
  all_paths.append(str(item.path))
475
492
  lang_cache = await get_many_langs(all_paths)
476
493
  loop = asyncio.get_event_loop()
494
+
495
+ # Build the work list up-front (skipping items already scanned with sub
496
+ # support) so the client can render an accurate "done/total" counter.
497
+ tasks: list[tuple[str, object, object]] = [] # (kind, item, season|None)
498
+ # ``force`` re-scans everything, ignoring the "already scanned" skip.
477
499
  for item in items:
478
500
  if item.kind == "series":
479
501
  for season in item.seasons:
480
- sp = str(season.path)
481
- entry = lang_cache.get(sp)
482
- if entry and "subs" in entry: # already scanned with sub support
502
+ entry = lang_cache.get(str(season.path))
503
+ if not force and entry and "subs" in entry: # already scanned with sub support
483
504
  continue
484
- merged, episode_langs, merged_subs, episode_subs = \
485
- await _scan_langs_and_subs(season.video_files, loop)
486
- await set_lang(sp, merged, episode_langs, subs=merged_subs, episode_subs=episode_subs)
487
- yield {
488
- "event": "lang_scanned",
489
- "data": json.dumps({
490
- "source_path": str(item.path),
491
- "season_path": sp,
492
- "langs": merged,
493
- "subs": merged_subs,
494
- "has_ita": "ITA" in merged,
495
- "has_ita_sub": "ITA" in merged_subs,
496
- }),
497
- }
505
+ tasks.append(("series", item, season))
498
506
  else:
499
- sp = str(item.path)
500
- entry = lang_cache.get(sp)
501
- if entry and "subs" in entry: # already scanned with sub support
507
+ entry = lang_cache.get(str(item.path))
508
+ if not force and entry and "subs" in entry: # already scanned with sub support
502
509
  continue
510
+ tasks.append(("movie", item, None))
511
+
512
+ total = len(tasks)
513
+ yield {"event": "progress", "data": json.dumps({"done": 0, "total": total})}
514
+ done = 0
515
+ for kind, item, season in tasks:
516
+ if kind == "series":
517
+ sp = str(season.path)
518
+ merged, episode_langs, merged_subs, episode_subs = \
519
+ await _scan_langs_and_subs(season.video_files, loop)
520
+ await set_lang(sp, merged, episode_langs, subs=merged_subs, episode_subs=episode_subs)
521
+ yield {
522
+ "event": "lang_scanned",
523
+ "data": json.dumps({
524
+ "source_path": str(item.path),
525
+ "season_path": sp,
526
+ "langs": merged,
527
+ "subs": merged_subs,
528
+ "has_ita": "ITA" in merged,
529
+ "has_ita_sub": "ITA" in merged_subs,
530
+ }),
531
+ }
532
+ else:
533
+ sp = str(item.path)
503
534
  multi = len(item.video_files) > 1
504
535
  merged, episode_langs, merged_subs, episode_subs = \
505
536
  await _scan_langs_and_subs(item.video_files, loop)
@@ -520,7 +551,9 @@ async def library_scan_langs(request: Request, category: str):
520
551
  "has_ita_sub": "ITA" in merged_subs,
521
552
  }),
522
553
  }
523
- yield {"event": "done", "data": "{}"}
554
+ done += 1
555
+ yield {"event": "progress", "data": json.dumps({"done": done, "total": total})}
556
+ yield {"event": "done", "data": json.dumps({"total": total})}
524
557
 
525
558
  return EventSourceResponse(generate())
526
559
 
@@ -642,3 +675,84 @@ async def library_rescan_langs(request: Request, category: str, item_name: str):
642
675
  "ok": True, "langs": merged, "subs": merged_subs,
643
676
  "episode_langs": episode_langs, "episode_subs": episode_subs,
644
677
  })
678
+
679
+
680
+ @router.get("/library/{category}/{item_name:path}/rescan-langs/stream")
681
+ async def library_rescan_langs_stream(request: Request, category: str, item_name: str):
682
+ """SSE variant of ``rescan-langs`` for a single item. Emits ``progress``
683
+ events ``{done, total}`` per video file (so a series streams a per-episode
684
+ counter) and a terminal ``done`` carrying the same payload as the POST."""
685
+ lang = get_request_lang(request)
686
+ if category not in discover_categories():
687
+ raise HTTPException(404, _i18n_t("err.category_not_found", lang))
688
+ item = get_item(category, item_name)
689
+ if item is None:
690
+ raise HTTPException(404, _i18n_t("err.item_not_found_in_category", lang, name=item_name, category=category))
691
+
692
+ async def generate() -> AsyncGenerator[dict, None]:
693
+ loop = asyncio.get_event_loop()
694
+ if item.kind == "series":
695
+ total = sum(len(s.video_files) for s in item.seasons)
696
+ yield {"event": "progress", "data": json.dumps({"done": 0, "total": total})}
697
+ done = 0
698
+ seasons_result: dict[str, dict] = {}
699
+ all_series_langs: list[str] = []
700
+ all_series_subs: list[str] = []
701
+ for season in item.seasons:
702
+ sp = str(season.path)
703
+ merged, episode_langs, merged_subs, episode_subs = [], {}, [], {}
704
+ async for ev in _scan_langs_and_subs_stream(season.video_files, loop):
705
+ if ev[0] == "progress":
706
+ done += 1
707
+ yield {"event": "progress", "data": json.dumps({"done": done, "total": total})}
708
+ else:
709
+ _, merged, episode_langs, merged_subs, episode_subs = ev
710
+ await set_lang(sp, merged, episode_langs, subs=merged_subs, episode_subs=episode_subs)
711
+ seasons_result[sp] = {
712
+ "langs": merged, "subs": merged_subs,
713
+ "episode_langs": episode_langs, "episode_subs": episode_subs,
714
+ }
715
+ for lg in merged:
716
+ if lg not in all_series_langs:
717
+ all_series_langs.append(lg)
718
+ for sub in merged_subs:
719
+ if sub not in all_series_subs:
720
+ all_series_subs.append(sub)
721
+ yield {
722
+ "event": "done",
723
+ "data": json.dumps({
724
+ "ok": True,
725
+ "langs": _ita_first(all_series_langs),
726
+ "subs": _ita_first(all_series_subs),
727
+ "seasons": seasons_result,
728
+ }),
729
+ }
730
+ return
731
+
732
+ sp = str(item.path)
733
+ multi = len(item.video_files) > 1
734
+ total = len(item.video_files)
735
+ yield {"event": "progress", "data": json.dumps({"done": 0, "total": total})}
736
+ done = 0
737
+ merged, episode_langs, merged_subs, episode_subs = [], {}, [], {}
738
+ async for ev in _scan_langs_and_subs_stream(item.video_files, loop):
739
+ if ev[0] == "progress":
740
+ done += 1
741
+ yield {"event": "progress", "data": json.dumps({"done": done, "total": total})}
742
+ else:
743
+ _, merged, episode_langs, merged_subs, episode_subs = ev
744
+ await set_lang(
745
+ sp, merged,
746
+ episode_langs if multi else None,
747
+ subs=merged_subs,
748
+ episode_subs=episode_subs if multi else None,
749
+ )
750
+ yield {
751
+ "event": "done",
752
+ "data": json.dumps({
753
+ "ok": True, "langs": merged, "subs": merged_subs,
754
+ "episode_langs": episode_langs, "episode_subs": episode_subs,
755
+ }),
756
+ }
757
+
758
+ return EventSourceResponse(generate())
@@ -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("["):