riplex 0.7.4__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.7.4/src/riplex.egg-info → riplex-0.9.0}/PKG-INFO +1 -1
- {riplex-0.7.4 → riplex-0.9.0}/docs/changelog.md +31 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/analysis.py +69 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/matcher.py +102 -38
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/normalize.py +9 -1
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/organizer.py +56 -9
- {riplex-0.7.4 → riplex-0.9.0/src/riplex.egg-info}/PKG-INFO +1 -1
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/SOURCES.txt +1 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/done.py +1 -1
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/metadata.py +1 -1
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/orchestrate_done.py +5 -5
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/welcome.py +59 -10
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_analysis.py +36 -2
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_matcher.py +144 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_organizer.py +208 -0
- riplex-0.9.0/tests/test_welcome_screen.py +42 -0
- {riplex-0.7.4 → riplex-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.github/agents/riplex.agent.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.github/copilot-instructions.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.github/workflows/publish.yml +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.github/workflows/release.yml +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.gitignore +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/.vscode/settings.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/CONTRIBUTORS.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/LICENSE +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/README.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/architecture.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/lookup.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/orchestrate.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/organize.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/workflow.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/getting-started/configuration.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/getting-started/installation.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/gui-guide/gui-walkthrough.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/index.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/naming-rules.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/reference/cli.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/docs/troubleshooting.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/issues/planned-features.md +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/mkdocs.yml +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/pyproject.toml +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/1_Welcome_Screen.png +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/2_Disc_Detection_BTTF.png +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/4_Disc_Release_BTTF.png +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/setup.cfg +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/cache.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/config.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/dedup.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/detect.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/makemkv.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/provider.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/formatter.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/lookup.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/manifest.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/planner.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/sources/tmdb.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/models.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/scanner.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/snapshot.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/splitter.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/tagger.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/title.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/ui.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex/updater.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/main.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/disc_detection.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/folder_picker.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/organize_preview.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/release.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/orchestrate.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/organize.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/rip.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/setup.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/main.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/__init__.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/fixtures/chernobyl_disc1.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_cache.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_cli_utils.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_config.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_dedup.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_detect.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_fixtures.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_provider.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_formatter.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_lookup.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_makemkv.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_normalize.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_planner.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_release_screen.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_rip_guide.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_scanner.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_snapshot.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_splitter.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_tagger.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_ui.py +0 -0
- {riplex-0.7.4 → riplex-0.9.0}/tests/test_updater.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,37 @@ 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
|
+
|
|
28
|
+
## v0.8.0 — 2026-06-12
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **GUI: editable settings after first-run setup.** The welcome screen now exposes an **Edit Settings** button after configuration is complete, so users can update the TMDb API key, media library root, MakeMKV rip output folder, and optional archive folder without re-running setup from the command line.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **GUI: less Plex-specific library wording.** User-facing destination copy now refers to a general media library while retaining **Plex-compatible naming** where the current folder/file convention is being described.
|
|
37
|
+
|
|
7
38
|
## v0.7.4 — 2026-06-10
|
|
8
39
|
|
|
9
40
|
### Fixed
|
|
@@ -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
|
|
@@ -123,6 +123,7 @@ tests/test_splitter.py
|
|
|
123
123
|
tests/test_tagger.py
|
|
124
124
|
tests/test_ui.py
|
|
125
125
|
tests/test_updater.py
|
|
126
|
+
tests/test_welcome_screen.py
|
|
126
127
|
tests/fixtures/chernobyl_disc1.json
|
|
127
128
|
tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt
|
|
128
129
|
tests/fixtures/makemkvcon_list.txt
|
|
@@ -87,7 +87,7 @@ class DoneScreen:
|
|
|
87
87
|
|
|
88
88
|
no_metadata_hint = ft.Text(
|
|
89
89
|
"Ripped without metadata — use 'Organize Rips' from the home screen "
|
|
90
|
-
"when TMDb is available to move these files into your
|
|
90
|
+
"when TMDb is available to move these files into your media library.",
|
|
91
91
|
size=12,
|
|
92
92
|
color=ft.Colors.ORANGE,
|
|
93
93
|
) if tmdb_match is None and len(successful) > 0 else ft.Container()
|
|
@@ -149,7 +149,7 @@ class MetadataScreen:
|
|
|
149
149
|
[
|
|
150
150
|
ft.Text("Metadata Lookup", size=24, weight=ft.FontWeight.BOLD),
|
|
151
151
|
ft.Text(
|
|
152
|
-
"TMDb is needed for canonical titles, years, and
|
|
152
|
+
"TMDb is needed for canonical titles, years, and media-library folder "
|
|
153
153
|
"structure. Without it, you can still rip the disc and organize later.",
|
|
154
154
|
size=13,
|
|
155
155
|
color=ft.Colors.GREY_500,
|
|
@@ -17,7 +17,7 @@ class OrchestrateDoneScreen:
|
|
|
17
17
|
"""Shown after all discs in the orchestrate queue have been ripped.
|
|
18
18
|
|
|
19
19
|
Displays a summary of all disc rips and offers to organize the
|
|
20
|
-
ripped files into Plex-compatible folder structure.
|
|
20
|
+
ripped files into a Plex-compatible folder structure.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
def __init__(self, app):
|
|
@@ -108,7 +108,7 @@ class OrchestrateDoneScreen:
|
|
|
108
108
|
on_click=self._open_folder,
|
|
109
109
|
)
|
|
110
110
|
organize_btn = ft.ElevatedButton(
|
|
111
|
-
"Organize into
|
|
111
|
+
"Organize into Library",
|
|
112
112
|
icon=ft.Icons.DRIVE_FILE_MOVE,
|
|
113
113
|
on_click=self._organize,
|
|
114
114
|
style=ft.ButtonStyle(
|
|
@@ -136,8 +136,8 @@ class OrchestrateDoneScreen:
|
|
|
136
136
|
ft.Column(disc_rows, spacing=6),
|
|
137
137
|
ft.Container(height=10),
|
|
138
138
|
ft.Text(
|
|
139
|
-
"All selected discs have been ripped. Click 'Organize into
|
|
140
|
-
"to match files to metadata and move them into your
|
|
139
|
+
"All selected discs have been ripped. Click 'Organize into Library' "
|
|
140
|
+
"to match files to metadata and move them into your media library.",
|
|
141
141
|
size=13,
|
|
142
142
|
color=ft.Colors.GREY_500,
|
|
143
143
|
),
|
|
@@ -202,7 +202,7 @@ class OrchestrateDoneScreen:
|
|
|
202
202
|
self._organize_status.value = f"Error: {exc}"
|
|
203
203
|
self._organize_status.color = ft.Colors.RED
|
|
204
204
|
e.control.disabled = False
|
|
205
|
-
e.control.text = "Organize into
|
|
205
|
+
e.control.text = "Organize into Library"
|
|
206
206
|
self.app.page.update()
|
|
207
207
|
|
|
208
208
|
threading.Thread(target=_do_organize, daemon=True).start()
|