riplex 0.8.0__tar.gz → 0.9.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.
- {riplex-0.8.0/src/riplex.egg-info → riplex-0.9.0}/PKG-INFO +1 -1
- {riplex-0.8.0 → riplex-0.9.0}/docs/changelog.md +21 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/disc/analysis.py +69 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/matcher.py +102 -38
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/normalize.py +9 -1
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/organizer.py +56 -9
- {riplex-0.8.0 → riplex-0.9.0/src/riplex.egg-info}/PKG-INFO +1 -1
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_disc_analysis.py +36 -2
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_matcher.py +144 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_organizer.py +208 -0
- {riplex-0.8.0 → riplex-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.github/agents/riplex.agent.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.github/copilot-instructions.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.github/workflows/publish.yml +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.github/workflows/release.yml +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.gitignore +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/.vscode/settings.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/CONTRIBUTORS.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/LICENSE +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/README.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/architecture.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/cli-guide/lookup.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/cli-guide/orchestrate.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/cli-guide/organize.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/cli-guide/workflow.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/getting-started/configuration.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/getting-started/installation.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/gui-guide/gui-walkthrough.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/index.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/naming-rules.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/reference/cli.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/docs/troubleshooting.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/issues/planned-features.md +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/mkdocs.yml +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/pyproject.toml +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/1_Welcome_Screen.png +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/2_Disc_Detection_BTTF.png +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/4_Disc_Release_BTTF.png +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/setup.cfg +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/cache.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/config.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/dedup.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/detect.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/disc/makemkv.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/disc/provider.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/formatter.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/lookup.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/manifest.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/metadata/planner.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/metadata/sources/tmdb.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/models.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/scanner.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/snapshot.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/splitter.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/tagger.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/title.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/ui.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex/updater.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex.egg-info/SOURCES.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/main.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/disc_detection.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/done.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/folder_picker.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/metadata.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/orchestrate_done.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/organize_preview.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/release.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_app/screens/welcome.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/commands/orchestrate.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/commands/organize.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/commands/rip.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/commands/setup.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/src/riplex_cli/main.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/__init__.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/fixtures/chernobyl_disc1.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_cache.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_cli_utils.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_config.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_dedup.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_detect.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_disc_fixtures.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_disc_provider.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_formatter.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_lookup.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_makemkv.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_normalize.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_planner.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_release_screen.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_rip_guide.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_scanner.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_snapshot.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_splitter.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_tagger.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_ui.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_updater.py +0 -0
- {riplex-0.8.0 → riplex-0.9.0}/tests/test_welcome_screen.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -4,6 +4,27 @@ All notable changes to the riplex documentation are recorded here.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## v0.9.0 — 2026-06-13
|
|
8
|
+
|
|
9
|
+
Summary: Plex-aligned movie version and edition support for combo-disc releases, with better organize preview matching for 4K, Blu-ray, and 3D movie rips.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Plex movie versions and editions.** Movie organization now distinguishes Plex versions, such as `4k` and `1080p`, from Plex editions, such as `{edition-3D}`. Multiple 2D resolutions are organized together in the base movie folder, while 3D rips are organized as a separate Plex edition folder.
|
|
14
|
+
- **3D movie edition output.** 3D movie rips now use Plex's edition naming convention in both folder and file names, for example `Movie Title (Year) {edition-3D}/Movie Title (Year) - 1080p {edition-3D}.mkv`.
|
|
15
|
+
- **Combo-pack movie matching.** Multi-disc releases with separate 4K, Blu-ray, and 3D film discs now match each main feature independently so the 4K movie, standard 1080p movie, and 3D edition can all be organized from the same release.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **2D is treated as the base movie, not an edition.** dvdcompare labels such as `2D` are still useful for matching disc targets, but the organized Plex output keeps 2D files in the normal `Movie Title (Year)` folder instead of creating `{edition-2D}` folders.
|
|
20
|
+
- **Resolution suffixes are inferred from scanned video dimensions.** Standard Blu-ray movie rips now receive a `- 1080p` suffix when ffprobe reports 1920x1080 video, matching the existing `- 4k` behavior for 2160p content.
|
|
21
|
+
- **Duplicate bonus features no longer clutter the missing list.** If the same bonus feature appears on multiple discs and one copy is matched, equivalent duplicate targets from other present discs are suppressed from the organize preview's missing section.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **4K main feature skipped in 4K + 3D combo releases.** Multi-edition film entries on a separate 3D/2D Blu-ray disc no longer suppress the generic movie target needed to match a separate 4K film disc.
|
|
26
|
+
- **Matched extras still shown as missing.** Duplicate extras such as `Behind The Scenes` and `Humpback Whales` no longer appear under missing after one copy has already been matched and planned for organization.
|
|
27
|
+
|
|
7
28
|
## v0.8.0 — 2026-06-12
|
|
8
29
|
|
|
9
30
|
### Added
|
|
@@ -16,6 +16,7 @@ _EDITION_RE = re.compile(
|
|
|
16
16
|
r"((?:Extended|Director'?s|Unrated|Ultimate|Special|Theatrical)\s+(?:Cut|Edition|Version))",
|
|
17
17
|
re.IGNORECASE,
|
|
18
18
|
)
|
|
19
|
+
_FORMAT_EDITION_RE = re.compile(r"\b(3D|2D|IMAX|Open Matte)\b", re.IGNORECASE)
|
|
19
20
|
|
|
20
21
|
# Feature types that indicate featurette/bonus content (not episodes)
|
|
21
22
|
_FEATURETTE_PLAY_ALL_TYPES = frozenset({
|
|
@@ -72,6 +73,70 @@ def _detect_edition_name(
|
|
|
72
73
|
return candidates[0]
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
def _extract_movie_edition(text: str) -> str | None:
|
|
77
|
+
"""Extract a movie edition label from dvdcompare film-entry text."""
|
|
78
|
+
m = _EDITION_RE.search(text)
|
|
79
|
+
if m:
|
|
80
|
+
return m.group(1)
|
|
81
|
+
m = _FORMAT_EDITION_RE.search(text)
|
|
82
|
+
if not m:
|
|
83
|
+
return None
|
|
84
|
+
value = m.group(1)
|
|
85
|
+
if value.lower() == "open matte":
|
|
86
|
+
return "Open Matte"
|
|
87
|
+
return value.upper()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _apply_movie_variant_classifications(
|
|
91
|
+
classifications: dict[int, str],
|
|
92
|
+
titles: list,
|
|
93
|
+
current_disc_entries: list,
|
|
94
|
+
movie_runtime: int | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Label same-runtime movie variants such as 3D/2D using dvdcompare hints."""
|
|
97
|
+
if not movie_runtime:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
film_entries = []
|
|
101
|
+
for disc in current_disc_entries:
|
|
102
|
+
if not getattr(disc, "is_film", False):
|
|
103
|
+
continue
|
|
104
|
+
for extra in getattr(disc, "extras", []):
|
|
105
|
+
title = getattr(extra, "title", "")
|
|
106
|
+
if not title.lower().startswith("the film"):
|
|
107
|
+
continue
|
|
108
|
+
edition = _extract_movie_edition(title)
|
|
109
|
+
if edition:
|
|
110
|
+
film_entries.append((edition, getattr(extra, "runtime_seconds", 0) or 0))
|
|
111
|
+
|
|
112
|
+
if len(film_entries) < 2:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
candidate_titles = [
|
|
116
|
+
title for title in titles
|
|
117
|
+
if abs(title.duration_seconds - movie_runtime) < 60
|
|
118
|
+
and classifications.get(title.index, "").startswith("MAIN FILM")
|
|
119
|
+
]
|
|
120
|
+
if len(candidate_titles) < 2:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
edition_names = [edition for edition, _ in film_entries]
|
|
124
|
+
has_3d_2d = "3D" in edition_names and "2D" in edition_names
|
|
125
|
+
if has_3d_2d:
|
|
126
|
+
sorted_entries = sorted(film_entries, key=lambda entry: 0 if entry[0] == "3D" else 1)
|
|
127
|
+
sorted_titles = sorted(candidate_titles, key=lambda title: title.size_bytes, reverse=True)
|
|
128
|
+
else:
|
|
129
|
+
sorted_entries = sorted(
|
|
130
|
+
film_entries,
|
|
131
|
+
key=lambda entry: 0 if "theatrical" in entry[0].lower() else 1,
|
|
132
|
+
)
|
|
133
|
+
sorted_titles = sorted(candidate_titles, key=lambda title: title.duration_seconds)
|
|
134
|
+
|
|
135
|
+
for title, (edition, _) in zip(sorted_titles, sorted_entries):
|
|
136
|
+
res_label = "4K" if "3840" in (title.resolution or "") else "1080p"
|
|
137
|
+
classifications[title.index] = f"{edition} Edition ({res_label})"
|
|
138
|
+
|
|
139
|
+
|
|
75
140
|
def format_seconds(seconds: int) -> str:
|
|
76
141
|
"""Format seconds as MM:SS or H:MM:SS."""
|
|
77
142
|
m, s = divmod(seconds, 60)
|
|
@@ -593,6 +658,10 @@ def analyze_disc(
|
|
|
593
658
|
t, titles, dvd_entries, is_movie, effective_movie_runtime,
|
|
594
659
|
total_episode_runtime, episode_count,
|
|
595
660
|
)
|
|
661
|
+
if is_movie:
|
|
662
|
+
_apply_movie_variant_classifications(
|
|
663
|
+
classifications, titles, current_disc_entries, effective_movie_runtime,
|
|
664
|
+
)
|
|
596
665
|
|
|
597
666
|
return DiscAnalysis(
|
|
598
667
|
disc_number=disc_number,
|
|
@@ -188,6 +188,24 @@ def _is_unidentified(classification: str) -> bool:
|
|
|
188
188
|
return False
|
|
189
189
|
return classification.startswith(_UNIDENTIFIED_CLASSIFICATION_PREFIXES)
|
|
190
190
|
|
|
191
|
+
|
|
192
|
+
_DISC_LABEL_PREFIX_RE = re.compile(r"^Disc\s+\d+:\s*", re.IGNORECASE)
|
|
193
|
+
_TRAILING_TYPE_RE = re.compile(
|
|
194
|
+
r"\s+\((?:featurette|documentary|interview|interviews|deleted scenes?|trailer|trailers|short|shorts)\)$",
|
|
195
|
+
re.IGNORECASE,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _duplicate_content_key(label: str) -> str | None:
|
|
200
|
+
"""Return a cross-disc comparison key for duplicate non-movie targets."""
|
|
201
|
+
if _is_movie_target(label):
|
|
202
|
+
return None
|
|
203
|
+
key = _DISC_LABEL_PREFIX_RE.sub("", label).strip()
|
|
204
|
+
key = _TRAILING_TYPE_RE.sub("", key).strip()
|
|
205
|
+
if not key:
|
|
206
|
+
return None
|
|
207
|
+
return re.sub(r"\s+", " ", key).casefold()
|
|
208
|
+
|
|
191
209
|
_PLAY_ALL_RE = re.compile(r"\bplay\s*all\b", re.IGNORECASE)
|
|
192
210
|
|
|
193
211
|
# Edition patterns in dvdcompare feature titles
|
|
@@ -195,6 +213,21 @@ _EDITION_RE = re.compile(
|
|
|
195
213
|
r"((?:Extended|Director'?s|Unrated|Ultimate|Special|Theatrical)\s+(?:Cut|Edition|Version))",
|
|
196
214
|
re.IGNORECASE,
|
|
197
215
|
)
|
|
216
|
+
_FORMAT_EDITION_RE = re.compile(r"\b(3D|2D|IMAX|Open Matte)\b", re.IGNORECASE)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _extract_movie_edition(text: str) -> str:
|
|
220
|
+
"""Extract a Plex movie edition label from dvdcompare film-entry text."""
|
|
221
|
+
m = _EDITION_RE.search(text)
|
|
222
|
+
if m:
|
|
223
|
+
return m.group(1)
|
|
224
|
+
m = _FORMAT_EDITION_RE.search(text)
|
|
225
|
+
if not m:
|
|
226
|
+
return ""
|
|
227
|
+
value = m.group(1)
|
|
228
|
+
if value.lower() == "open matte":
|
|
229
|
+
return "Open Matte"
|
|
230
|
+
return value.upper()
|
|
198
231
|
|
|
199
232
|
|
|
200
233
|
def collect_disc_targets(
|
|
@@ -251,14 +284,15 @@ def collect_disc_targets(
|
|
|
251
284
|
# Handle "The Film ..." entries on film discs
|
|
252
285
|
if len(film_entries) > 1:
|
|
253
286
|
# Multiple editions: create edition-aware targets and suppress
|
|
254
|
-
# the single TMDb movie target added above
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
|
|
287
|
+
# the single TMDb movie target added above only when it would
|
|
288
|
+
# point at this same disc. In combo packs, a separate 4K disc
|
|
289
|
+
# can be the base movie while a Blu-ray disc carries 3D/2D
|
|
290
|
+
# variants; those need both the generic movie target and the
|
|
291
|
+
# edition-aware targets.
|
|
292
|
+
if film_disc_num is None or disc.number == film_disc_num:
|
|
293
|
+
suppress_movie_target = True
|
|
259
294
|
for ex in film_entries:
|
|
260
|
-
|
|
261
|
-
edition = m.group(1) if m else ex.title
|
|
295
|
+
edition = _extract_movie_edition(ex.title) or ex.title
|
|
262
296
|
label = f"{prefix}: {edition} (movie)"
|
|
263
297
|
runtime = ex.runtime_seconds or 0
|
|
264
298
|
log.debug("Disc %d: multi-edition film entry '%s' -> target '%s' (%ds)",
|
|
@@ -515,14 +549,6 @@ def match_discs(
|
|
|
515
549
|
if ti not in claimed_targets and runtime_s == 0 and "(movie)" in label
|
|
516
550
|
]
|
|
517
551
|
if zero_edition_targets:
|
|
518
|
-
# Find unclaimed files on the same disc(s)
|
|
519
|
-
edition_disc = targets[zero_edition_targets[0]][2]
|
|
520
|
-
unclaimed_files = [
|
|
521
|
-
fi for fi in range(len(all_scanned))
|
|
522
|
-
if fi not in claimed_files
|
|
523
|
-
and all_scanned[fi].duration_seconds > 0
|
|
524
|
-
and (edition_disc is None or file_disc[fi] is None or file_disc[fi] == edition_disc)
|
|
525
|
-
]
|
|
526
552
|
# Sort targets: theatrical first, then extended
|
|
527
553
|
def _edition_sort_key(ti: int) -> int:
|
|
528
554
|
label_lower = targets[ti][0].lower()
|
|
@@ -531,28 +557,55 @@ def match_discs(
|
|
|
531
557
|
if any(w in label_lower for w in _EXTENDED_WORDS):
|
|
532
558
|
return 1
|
|
533
559
|
return 2
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
)
|
|
560
|
+
|
|
561
|
+
edition_discs = sorted(
|
|
562
|
+
{targets[ti][2] for ti in zero_edition_targets},
|
|
563
|
+
key=lambda d: -1 if d is None else d,
|
|
564
|
+
)
|
|
565
|
+
for edition_disc in edition_discs:
|
|
566
|
+
disc_targets = [ti for ti in zero_edition_targets if targets[ti][2] == edition_disc]
|
|
567
|
+
unclaimed_files = [
|
|
568
|
+
fi for fi in range(len(all_scanned))
|
|
569
|
+
if fi not in claimed_files
|
|
570
|
+
and all_scanned[fi].duration_seconds > 0
|
|
571
|
+
and (edition_disc is None or file_disc[fi] is None or file_disc[fi] == edition_disc)
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
disc_targets.sort(key=_edition_sort_key)
|
|
575
|
+
target_labels = [targets[ti][0].lower() for ti in disc_targets]
|
|
576
|
+
has_3d_2d_targets = (
|
|
577
|
+
any("3d" in label for label in target_labels)
|
|
578
|
+
and any("2d" in label for label in target_labels)
|
|
553
579
|
)
|
|
554
|
-
|
|
555
|
-
|
|
580
|
+
if has_3d_2d_targets:
|
|
581
|
+
# 3D MVC titles carry an extra eye view and are typically larger
|
|
582
|
+
# than their same-runtime 2D companion on the same disc.
|
|
583
|
+
disc_targets.sort(
|
|
584
|
+
key=lambda ti: 0 if "3d" in targets[ti][0].lower() else 1
|
|
585
|
+
)
|
|
586
|
+
unclaimed_files.sort(key=lambda fi: all_scanned[fi].size_bytes, reverse=True)
|
|
587
|
+
else:
|
|
588
|
+
# Sort files by duration (shortest first = theatrical)
|
|
589
|
+
unclaimed_files.sort(key=lambda fi: all_scanned[fi].duration_seconds)
|
|
590
|
+
|
|
591
|
+
for ti, fi in zip(disc_targets, unclaimed_files):
|
|
592
|
+
sf = all_scanned[fi]
|
|
593
|
+
label = targets[ti][0]
|
|
594
|
+
log.debug("Edition match (no runtime): %s (%ds) -> '%s'",
|
|
595
|
+
sf.name, sf.duration_seconds, label)
|
|
596
|
+
matched.append(
|
|
597
|
+
MatchCandidate(
|
|
598
|
+
file_name=sf.name,
|
|
599
|
+
file_duration_seconds=sf.duration_seconds,
|
|
600
|
+
matched_label=label,
|
|
601
|
+
matched_runtime_seconds=sf.duration_seconds,
|
|
602
|
+
delta_seconds=0,
|
|
603
|
+
confidence="medium",
|
|
604
|
+
classification=sf.classification,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
claimed_files.add(fi)
|
|
608
|
+
claimed_targets.add(ti)
|
|
556
609
|
|
|
557
610
|
unmatched = [
|
|
558
611
|
all_scanned[i]
|
|
@@ -560,6 +613,17 @@ def match_discs(
|
|
|
560
613
|
if i not in claimed_files
|
|
561
614
|
]
|
|
562
615
|
|
|
616
|
+
matched_content_keys = {
|
|
617
|
+
key for key in (_duplicate_content_key(targets[i][0]) for i in claimed_targets)
|
|
618
|
+
if key is not None
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
def _is_missing_target(target_index: int) -> bool:
|
|
622
|
+
if target_index in claimed_targets:
|
|
623
|
+
return False
|
|
624
|
+
key = _duplicate_content_key(targets[target_index][0])
|
|
625
|
+
return key is None or key not in matched_content_keys
|
|
626
|
+
|
|
563
627
|
# Only report missing targets from discs the user actually has
|
|
564
628
|
# folders for (or targets with no disc constraint). If no folder
|
|
565
629
|
# mapped to any disc (e.g. single folder with no disc number),
|
|
@@ -569,14 +633,14 @@ def match_discs(
|
|
|
569
633
|
missing = [
|
|
570
634
|
targets[i][0]
|
|
571
635
|
for i in range(len(targets))
|
|
572
|
-
if i
|
|
636
|
+
if _is_missing_target(i)
|
|
573
637
|
and (targets[i][2] is None or targets[i][2] in present_discs)
|
|
574
638
|
]
|
|
575
639
|
else:
|
|
576
640
|
missing = [
|
|
577
641
|
targets[i][0]
|
|
578
642
|
for i in range(len(targets))
|
|
579
|
-
if i
|
|
643
|
+
if _is_missing_target(i)
|
|
580
644
|
]
|
|
581
645
|
|
|
582
646
|
for sf in unmatched:
|
|
@@ -86,10 +86,18 @@ def movie_folder_name(title: str, year: int, edition: str | None = None) -> str:
|
|
|
86
86
|
return name
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def movie_file_name(
|
|
89
|
+
def movie_file_name(
|
|
90
|
+
title: str,
|
|
91
|
+
year: int,
|
|
92
|
+
ext: str = ".mkv",
|
|
93
|
+
edition: str | None = None,
|
|
94
|
+
version_suffix: str = "",
|
|
95
|
+
) -> str:
|
|
90
96
|
"""Build the canonical Plex movie filename."""
|
|
91
97
|
safe = sanitize_filename(title)
|
|
92
98
|
name = f"{safe} ({year})"
|
|
99
|
+
if version_suffix:
|
|
100
|
+
name += f" - {sanitize_filename(version_suffix)}"
|
|
93
101
|
if edition:
|
|
94
102
|
name += f" {{edition-{edition}}}"
|
|
95
103
|
return f"{name}{ext}"
|
|
@@ -35,6 +35,43 @@ _EDITION_RE = re.compile(
|
|
|
35
35
|
r"((?:Extended|Director'?s|Unrated|Ultimate|Special|Theatrical)\s+(?:Cut|Edition|Version))",
|
|
36
36
|
re.IGNORECASE,
|
|
37
37
|
)
|
|
38
|
+
_FORMAT_EDITION_RE = re.compile(r"\b(3D|IMAX|Open Matte)\b", re.IGNORECASE)
|
|
39
|
+
_VERSION_4K_RE = re.compile(r"\b(?:4K|2160p)\b", re.IGNORECASE)
|
|
40
|
+
_VERSION_1080P_RE = re.compile(r"\b1080p\b", re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_movie_edition(text: str) -> str:
|
|
44
|
+
"""Extract a Plex movie edition label from classifier/match text."""
|
|
45
|
+
m = _EDITION_RE.search(text)
|
|
46
|
+
if m:
|
|
47
|
+
return m.group(1)
|
|
48
|
+
m = _FORMAT_EDITION_RE.search(text)
|
|
49
|
+
if not m:
|
|
50
|
+
return ""
|
|
51
|
+
value = m.group(1)
|
|
52
|
+
if value.lower() == "open matte":
|
|
53
|
+
return "Open Matte"
|
|
54
|
+
return value.upper()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_movie_version_suffix(text: str) -> str:
|
|
58
|
+
"""Extract a Plex multi-version suffix from classifier/match text."""
|
|
59
|
+
if _VERSION_4K_RE.search(text):
|
|
60
|
+
return "4k"
|
|
61
|
+
if _VERSION_1080P_RE.search(text):
|
|
62
|
+
return "1080p"
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _infer_movie_version_suffix(scanned_file: ScannedFile | None, edition: str | None) -> str:
|
|
67
|
+
"""Infer a Plex multi-version suffix from scanned video dimensions."""
|
|
68
|
+
if not scanned_file:
|
|
69
|
+
return ""
|
|
70
|
+
if scanned_file.max_width >= 3840 or scanned_file.max_height >= 2160:
|
|
71
|
+
return "4k"
|
|
72
|
+
if scanned_file.max_width >= 1920 or scanned_file.max_height >= 1080:
|
|
73
|
+
return "1080p"
|
|
74
|
+
return ""
|
|
38
75
|
|
|
39
76
|
# Map dvdcompare feature_type strings to Plex extras folder names
|
|
40
77
|
_EXTRAS_FOLDER_MAP: dict[str, str] = {
|
|
@@ -380,7 +417,7 @@ def build_organize_plan(
|
|
|
380
417
|
)
|
|
381
418
|
continue
|
|
382
419
|
|
|
383
|
-
dest = _compute_destination(candidate, plan, base)
|
|
420
|
+
dest = _compute_destination(candidate, plan, base, scanned_files.get(candidate.file_name))
|
|
384
421
|
if dest is None:
|
|
385
422
|
# Could not resolve to a valid Plex path; treat as unmatched.
|
|
386
423
|
log.debug("No valid destination for '%s' (label='%s'), treating as unmatched",
|
|
@@ -452,6 +489,7 @@ def _compute_destination(
|
|
|
452
489
|
candidate: MatchCandidate,
|
|
453
490
|
plan: PlannedMovie | PlannedShow,
|
|
454
491
|
base: Path,
|
|
492
|
+
scanned_file: ScannedFile | None = None,
|
|
455
493
|
) -> Path | None:
|
|
456
494
|
"""Compute the Plex-canonical destination path for a matched file.
|
|
457
495
|
|
|
@@ -464,20 +502,29 @@ def _compute_destination(
|
|
|
464
502
|
# Movie main file
|
|
465
503
|
if "(movie)" in label:
|
|
466
504
|
edition = None
|
|
505
|
+
version_suffix = ""
|
|
467
506
|
# Check for edition in label (from multi-edition disc targets)
|
|
468
507
|
# e.g. "Disc 1: Theatrical Cut (movie)"
|
|
469
508
|
if label.startswith("Disc ") and ": " in label:
|
|
470
509
|
edition_part = label.split(": ", 1)[1].replace(" (movie)", "")
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
edition = m.group(1)
|
|
510
|
+
edition = _extract_movie_edition(edition_part) or None
|
|
511
|
+
version_suffix = _extract_movie_version_suffix(edition_part)
|
|
474
512
|
# Fallback: check rip-time classification
|
|
475
513
|
if not edition and candidate.classification:
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
514
|
+
edition = _extract_movie_edition(candidate.classification) or None
|
|
515
|
+
if not version_suffix and candidate.classification:
|
|
516
|
+
version_suffix = _extract_movie_version_suffix(candidate.classification)
|
|
517
|
+
if not version_suffix:
|
|
518
|
+
version_suffix = _infer_movie_version_suffix(scanned_file, edition)
|
|
519
|
+
movie_base = base.parent / movie_folder_name(plan.canonical_title, plan.year, edition=edition) if edition else base
|
|
520
|
+
file_name = movie_file_name(
|
|
521
|
+
plan.canonical_title,
|
|
522
|
+
plan.year,
|
|
523
|
+
edition=edition,
|
|
524
|
+
version_suffix=version_suffix,
|
|
525
|
+
)
|
|
526
|
+
dest = movie_base / file_name
|
|
527
|
+
log.debug(" -> movie main file (edition=%s, version=%s): %s", edition, version_suffix, dest)
|
|
481
528
|
return dest
|
|
482
529
|
|
|
483
530
|
# Episode: label like "Disc 1: Coasts" or "s01e01 - Title"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -416,7 +416,7 @@ class TestDetectDiscNumber:
|
|
|
416
416
|
class TestAnalyzeDisc:
|
|
417
417
|
"""Tests for the analyze_disc() shared entry point."""
|
|
418
418
|
|
|
419
|
-
def _make_fake_disc(self, number, ep_runtimes, extra_runtimes=None):
|
|
419
|
+
def _make_fake_disc(self, number, ep_runtimes, extra_runtimes=None, extra_titles=None, is_film=False):
|
|
420
420
|
class FakeEp:
|
|
421
421
|
def __init__(self, title, runtime):
|
|
422
422
|
self.title = title
|
|
@@ -434,9 +434,16 @@ class TestAnalyzeDisc:
|
|
|
434
434
|
self.episodes = eps
|
|
435
435
|
self.extras = extras
|
|
436
436
|
self.disc_format = "Blu-ray"
|
|
437
|
+
self.is_film = is_film
|
|
437
438
|
|
|
438
439
|
eps = [FakeEp(f"Ep {i+1}", rt) for i, rt in enumerate(ep_runtimes)]
|
|
439
|
-
|
|
440
|
+
if extra_titles is None:
|
|
441
|
+
extras = [FakeExtra(f"Extra {i+1}", rt) for i, rt in enumerate(extra_runtimes or [])]
|
|
442
|
+
else:
|
|
443
|
+
extras = [
|
|
444
|
+
FakeExtra(title, runtime)
|
|
445
|
+
for title, runtime in zip(extra_titles, extra_runtimes or [])
|
|
446
|
+
]
|
|
440
447
|
return FakeDisc(number, eps, extras)
|
|
441
448
|
|
|
442
449
|
def test_filters_to_detected_disc(self):
|
|
@@ -507,3 +514,30 @@ class TestAnalyzeDisc:
|
|
|
507
514
|
# Should still classify titles (main film detected by runtime)
|
|
508
515
|
assert len(analysis.rippable_titles) >= 1
|
|
509
516
|
assert len(analysis.classifications) == 2
|
|
517
|
+
|
|
518
|
+
def test_3d_2d_movie_variants_are_labeled_by_size(self):
|
|
519
|
+
disc = self._make_fake_disc(
|
|
520
|
+
2,
|
|
521
|
+
[],
|
|
522
|
+
extra_runtimes=[0, 0],
|
|
523
|
+
extra_titles=["The Film (3D) (1080p)", "The Film (2D) (1080p)"],
|
|
524
|
+
is_film=True,
|
|
525
|
+
)
|
|
526
|
+
titles = [
|
|
527
|
+
_make_title(
|
|
528
|
+
0, 2653, resolution="1920x1080",
|
|
529
|
+
size=14_494_550_016,
|
|
530
|
+
),
|
|
531
|
+
_make_title(
|
|
532
|
+
3, 2653, resolution="1920x1080",
|
|
533
|
+
size=13_900_886_016,
|
|
534
|
+
),
|
|
535
|
+
]
|
|
536
|
+
info = DiscInfo(disc_name="MYSTERY", disc_type="Blu-ray disc", titles=titles)
|
|
537
|
+
|
|
538
|
+
analysis = analyze_disc(
|
|
539
|
+
info, [disc], disc_number=2, is_movie=True, movie_runtime=2700,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
assert analysis.classifications[0] == "3D Edition (1080p)"
|
|
543
|
+
assert analysis.classifications[3] == "2D Edition (1080p)"
|
|
@@ -753,6 +753,31 @@ class TestMissingFilteredToPresent:
|
|
|
753
753
|
assert any("Feature A" in l for l in labels)
|
|
754
754
|
assert any("Feature B" in l for l in labels)
|
|
755
755
|
|
|
756
|
+
def test_duplicate_extra_matched_on_one_disc_not_reported_missing_on_another(self):
|
|
757
|
+
discs = [
|
|
758
|
+
PlannedDisc(
|
|
759
|
+
number=1, disc_format="Blu-ray 4K",
|
|
760
|
+
extras=[PlannedExtra(title="Humpback Whales", runtime_seconds=138)],
|
|
761
|
+
),
|
|
762
|
+
PlannedDisc(
|
|
763
|
+
number=2, disc_format="Blu-ray",
|
|
764
|
+
extras=[PlannedExtra(title="Humpback Whales", runtime_seconds=138)],
|
|
765
|
+
),
|
|
766
|
+
]
|
|
767
|
+
scanned = [
|
|
768
|
+
ScannedDisc(folder_name="Disc 1", files=[]),
|
|
769
|
+
ScannedDisc(
|
|
770
|
+
folder_name="Disc 2",
|
|
771
|
+
files=[ScannedFile(name="humpback.mkv", path="x", duration_seconds=138)],
|
|
772
|
+
),
|
|
773
|
+
]
|
|
774
|
+
|
|
775
|
+
result = match_discs(scanned, discs)
|
|
776
|
+
|
|
777
|
+
assert len(result.matched) == 1
|
|
778
|
+
assert result.matched[0].matched_label == "Disc 2: Humpback Whales"
|
|
779
|
+
assert result.missing == []
|
|
780
|
+
|
|
756
781
|
|
|
757
782
|
class TestMultiEditionFilm:
|
|
758
783
|
"""Multiple editions (Theatrical + Extended) on the same film disc."""
|
|
@@ -808,6 +833,29 @@ class TestMultiEditionFilm:
|
|
|
808
833
|
assert any("Extended Cut" in l and "(movie)" in l for l in labels)
|
|
809
834
|
assert not any(l == "King Kong (movie)" for l in labels)
|
|
810
835
|
|
|
836
|
+
def test_3d_2d_film_entries_create_edition_targets(self):
|
|
837
|
+
movie = PlannedMovie(
|
|
838
|
+
canonical_title="Flight of the Butterflies",
|
|
839
|
+
year=2012,
|
|
840
|
+
runtime="45m",
|
|
841
|
+
runtime_seconds=2700,
|
|
842
|
+
)
|
|
843
|
+
discs = [
|
|
844
|
+
PlannedDisc(
|
|
845
|
+
number=2, disc_format="Blu-ray", is_film=True,
|
|
846
|
+
extras=[
|
|
847
|
+
PlannedExtra(title="The Film (3D) (1080p)", runtime_seconds=0),
|
|
848
|
+
PlannedExtra(title="The Film (2D) (1080p)", runtime_seconds=0),
|
|
849
|
+
],
|
|
850
|
+
),
|
|
851
|
+
]
|
|
852
|
+
|
|
853
|
+
targets = collect_disc_targets(discs, movie)
|
|
854
|
+
labels = [t[0] for t in targets]
|
|
855
|
+
assert "Disc 2: 3D (movie)" in labels
|
|
856
|
+
assert "Disc 2: 2D (movie)" in labels
|
|
857
|
+
assert "Flight of the Butterflies (movie)" not in labels
|
|
858
|
+
|
|
811
859
|
def test_two_editions_match_files(self):
|
|
812
860
|
"""Two MKV files match the two editions by duration order when no runtimes."""
|
|
813
861
|
movie = PlannedMovie(
|
|
@@ -846,6 +894,102 @@ class TestMultiEditionFilm:
|
|
|
846
894
|
assert "Theatrical Cut" in t00.matched_label
|
|
847
895
|
assert "Extended Cut" in t02.matched_label
|
|
848
896
|
|
|
897
|
+
def test_3d_2d_editions_match_by_size_when_runtime_is_equal(self):
|
|
898
|
+
movie = PlannedMovie(
|
|
899
|
+
canonical_title="Flight of the Butterflies",
|
|
900
|
+
year=2012,
|
|
901
|
+
runtime="45m",
|
|
902
|
+
runtime_seconds=2700,
|
|
903
|
+
)
|
|
904
|
+
discs = [
|
|
905
|
+
PlannedDisc(
|
|
906
|
+
number=2, disc_format="Blu-ray", is_film=True,
|
|
907
|
+
extras=[
|
|
908
|
+
PlannedExtra(title="The Film (3D) (1080p)", runtime_seconds=0),
|
|
909
|
+
PlannedExtra(title="The Film (2D) (1080p)", runtime_seconds=0),
|
|
910
|
+
],
|
|
911
|
+
),
|
|
912
|
+
]
|
|
913
|
+
scanned = [
|
|
914
|
+
ScannedDisc(
|
|
915
|
+
folder_name="Disc 2",
|
|
916
|
+
files=[
|
|
917
|
+
ScannedFile(
|
|
918
|
+
name="Butterflies_t03.mkv", path="x",
|
|
919
|
+
duration_seconds=2653, size_bytes=13_900_886_016,
|
|
920
|
+
),
|
|
921
|
+
ScannedFile(
|
|
922
|
+
name="Butterflies_t00.mkv", path="x",
|
|
923
|
+
duration_seconds=2653, size_bytes=14_494_550_016,
|
|
924
|
+
),
|
|
925
|
+
],
|
|
926
|
+
),
|
|
927
|
+
]
|
|
928
|
+
|
|
929
|
+
result = match_discs(scanned, discs, movie)
|
|
930
|
+
|
|
931
|
+
assert len(result.matched) == 2
|
|
932
|
+
large = next(c for c in result.matched if c.file_name == "Butterflies_t00.mkv")
|
|
933
|
+
small = next(c for c in result.matched if c.file_name == "Butterflies_t03.mkv")
|
|
934
|
+
assert large.matched_label == "Disc 2: 3D (movie)"
|
|
935
|
+
assert small.matched_label == "Disc 2: 2D (movie)"
|
|
936
|
+
|
|
937
|
+
def test_4k_movie_target_kept_when_3d_2d_entries_are_on_second_disc(self):
|
|
938
|
+
movie = PlannedMovie(
|
|
939
|
+
canonical_title="Flight of the Butterflies",
|
|
940
|
+
year=2012,
|
|
941
|
+
runtime="45m",
|
|
942
|
+
runtime_seconds=2700,
|
|
943
|
+
)
|
|
944
|
+
discs = [
|
|
945
|
+
PlannedDisc(
|
|
946
|
+
number=1, disc_format="Blu-ray 4K", is_film=True,
|
|
947
|
+
extras=[PlannedExtra(title="The Film (2160p)", runtime_seconds=0)],
|
|
948
|
+
),
|
|
949
|
+
PlannedDisc(
|
|
950
|
+
number=2, disc_format="Blu-ray", is_film=True,
|
|
951
|
+
extras=[
|
|
952
|
+
PlannedExtra(title="The Film (3D) (1080p)", runtime_seconds=0),
|
|
953
|
+
PlannedExtra(title="The Film (2D) (1080p)", runtime_seconds=0),
|
|
954
|
+
],
|
|
955
|
+
),
|
|
956
|
+
]
|
|
957
|
+
scanned = [
|
|
958
|
+
ScannedDisc(
|
|
959
|
+
folder_name="Disc 1",
|
|
960
|
+
files=[
|
|
961
|
+
ScannedFile(
|
|
962
|
+
name="title_t00.mkv", path="x",
|
|
963
|
+
duration_seconds=2654, size_bytes=18_890_000_000,
|
|
964
|
+
),
|
|
965
|
+
],
|
|
966
|
+
),
|
|
967
|
+
ScannedDisc(
|
|
968
|
+
folder_name="Disc 2",
|
|
969
|
+
files=[
|
|
970
|
+
ScannedFile(
|
|
971
|
+
name="Butterflies_t00.mkv", path="x",
|
|
972
|
+
duration_seconds=2654, size_bytes=13_421_000_000,
|
|
973
|
+
),
|
|
974
|
+
ScannedFile(
|
|
975
|
+
name="Butterflies_t03.mkv", path="x",
|
|
976
|
+
duration_seconds=2654, size_bytes=9_846_000_000,
|
|
977
|
+
),
|
|
978
|
+
],
|
|
979
|
+
),
|
|
980
|
+
]
|
|
981
|
+
|
|
982
|
+
result = match_discs(scanned, discs, movie)
|
|
983
|
+
|
|
984
|
+
assert len(result.matched) == 3
|
|
985
|
+
disc1 = next(c for c in result.matched if c.file_name == "title_t00.mkv")
|
|
986
|
+
disc2_large = next(c for c in result.matched if c.file_name == "Butterflies_t00.mkv")
|
|
987
|
+
disc2_small = next(c for c in result.matched if c.file_name == "Butterflies_t03.mkv")
|
|
988
|
+
assert disc1.matched_label == "Flight of the Butterflies (movie)"
|
|
989
|
+
assert disc2_large.matched_label == "Disc 2: 3D (movie)"
|
|
990
|
+
assert disc2_small.matched_label == "Disc 2: 2D (movie)"
|
|
991
|
+
assert result.unmatched == []
|
|
992
|
+
|
|
849
993
|
def test_two_editions_with_runtimes_match(self):
|
|
850
994
|
"""Two editions with known runtimes match via normal greedy pairing."""
|
|
851
995
|
movie = PlannedMovie(
|