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.
- {unit3dprep-1.1.9/unit3dprep.egg-info → unit3dprep-1.2.0}/PKG-INFO +1 -1
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/pyproject.toml +1 -1
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/core.py +31 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/upload.py +38 -8
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/wizard.py +67 -23
- {unit3dprep-1.1.9 → 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.9 → 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.9 → unit3dprep-1.2.0}/unit3dprep/web/reseed.py +206 -45
- {unit3dprep-1.1.9 → unit3dprep-1.2.0/unit3dprep.egg-info}/PKG-INFO +1 -1
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/SOURCES.txt +2 -2
- unit3dprep-1.1.9/unit3dprep/web/dist/assets/index-hDgVU3lh.js +0 -275
- unit3dprep-1.1.9/unit3dprep/web/duplicate_check.py +0 -92
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/LICENSE +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/MANIFEST.in +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/README.md +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/setup.cfg +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/__init__.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/cli.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/i18n.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/media.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/__init__.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/_env.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/__init__.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/auth.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/fs.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/library.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/logs.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/queue.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/quickupload.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/reseed.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/search.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/settings.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/tmdb.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/trackers.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/uploaded.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/version.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/api/webup.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/app.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/auth.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/clients.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/db.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/lang_cache.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/logbuf.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/tmdb_cache.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/tocheck.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/trackers.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_client.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_job_fix.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_logclass.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_orchestrator.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep/web/webup_ws.py +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/dependency_links.txt +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/entry_points.txt +0 -0
- {unit3dprep-1.1.9 → unit3dprep-1.2.0}/unit3dprep.egg-info/requires.txt +0 -0
- {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.
|
|
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:
|
|
@@ -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("["):
|