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.
- {unit3dprep-1.1.8/unit3dprep.egg-info → unit3dprep-1.2.0}/PKG-INFO +1 -1
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/pyproject.toml +1 -1
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/core.py +31 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/upload.py +38 -8
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/library.py +141 -27
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/wizard.py +67 -23
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/config.py +7 -1
- unit3dprep-1.2.0/unit3dprep/web/dist/assets/index-CPw9mcxL.js +280 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/index.html +1 -1
- unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +168 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/reseed.py +206 -45
- {unit3dprep-1.1.8 → unit3dprep-1.2.0/unit3dprep.egg-info}/PKG-INFO +1 -1
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/SOURCES.txt +1 -1
- unit3dprep-1.1.8/unit3dprep/web/dist/assets/index-EHy8CWCD.js +0 -275
- unit3dprep-1.1.8/unit3dprep/web/duplicate_check.py +0 -92
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/LICENSE +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/MANIFEST.in +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/README.md +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/setup.cfg +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/__init__.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/cli.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/i18n.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/media.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/__init__.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/_env.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/__init__.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/auth.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/fs.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/logs.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/queue.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/quickupload.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/reseed.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/search.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/settings.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/tmdb.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/trackers.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/uploaded.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/version.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/api/webup.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/app.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/auth.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/clients.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/db.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/lang_cache.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/logbuf.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/tmdb_cache.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/tocheck.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/trackers.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_client.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_job_fix.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_logclass.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_orchestrator.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep/web/webup_ws.py +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/dependency_links.txt +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/entry_points.txt +0 -0
- {unit3dprep-1.1.8 → unit3dprep-1.2.0}/unit3dprep.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
"""
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
436
|
-
"""
|
|
435
|
+
async def _scan_langs_and_subs_stream(video_files, loop):
|
|
436
|
+
"""Async-generator variant of :func:`_scan_langs_and_subs`.
|
|
437
437
|
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
entry
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
entry
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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,
|
|
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
|
-
|
|
303
|
+
detailed = await loop.run_in_executor(
|
|
294
304
|
None,
|
|
295
|
-
lambda: {str(k): v for k, v in
|
|
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,
|
|
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
|
-
|
|
321
|
+
detailed = await loop.run_in_executor(
|
|
309
322
|
None,
|
|
310
|
-
lambda: {str(k): v for k, v in
|
|
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
|
|
346
|
-
"""
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
except
|
|
356
|
-
|
|
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
|
|
406
|
+
if not enabled:
|
|
366
407
|
state["step"] = "hardlink"
|
|
367
408
|
return JSONResponse({"enabled": enabled, "duplicate": None})
|
|
368
|
-
|
|
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
|
-
|
|
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("["):
|