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.
Files changed (138) hide show
  1. {riplex-0.7.4/src/riplex.egg-info → riplex-0.9.0}/PKG-INFO +1 -1
  2. {riplex-0.7.4 → riplex-0.9.0}/docs/changelog.md +31 -0
  3. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/analysis.py +69 -0
  4. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/matcher.py +102 -38
  5. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/normalize.py +9 -1
  6. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/organizer.py +56 -9
  7. {riplex-0.7.4 → riplex-0.9.0/src/riplex.egg-info}/PKG-INFO +1 -1
  8. {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/SOURCES.txt +1 -0
  9. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/done.py +1 -1
  10. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/metadata.py +1 -1
  11. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/orchestrate_done.py +5 -5
  12. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/welcome.py +59 -10
  13. {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_analysis.py +36 -2
  14. {riplex-0.7.4 → riplex-0.9.0}/tests/test_matcher.py +144 -0
  15. {riplex-0.7.4 → riplex-0.9.0}/tests/test_organizer.py +208 -0
  16. riplex-0.9.0/tests/test_welcome_screen.py +42 -0
  17. {riplex-0.7.4 → riplex-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {riplex-0.7.4 → riplex-0.9.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  19. {riplex-0.7.4 → riplex-0.9.0}/.github/agents/riplex.agent.md +0 -0
  20. {riplex-0.7.4 → riplex-0.9.0}/.github/copilot-instructions.md +0 -0
  21. {riplex-0.7.4 → riplex-0.9.0}/.github/workflows/publish.yml +0 -0
  22. {riplex-0.7.4 → riplex-0.9.0}/.github/workflows/release.yml +0 -0
  23. {riplex-0.7.4 → riplex-0.9.0}/.gitignore +0 -0
  24. {riplex-0.7.4 → riplex-0.9.0}/.vscode/settings.json +0 -0
  25. {riplex-0.7.4 → riplex-0.9.0}/CONTRIBUTORS.md +0 -0
  26. {riplex-0.7.4 → riplex-0.9.0}/LICENSE +0 -0
  27. {riplex-0.7.4 → riplex-0.9.0}/README.md +0 -0
  28. {riplex-0.7.4 → riplex-0.9.0}/docs/architecture.md +0 -0
  29. {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/lookup.md +0 -0
  30. {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/orchestrate.md +0 -0
  31. {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/organize.md +0 -0
  32. {riplex-0.7.4 → riplex-0.9.0}/docs/cli-guide/workflow.md +0 -0
  33. {riplex-0.7.4 → riplex-0.9.0}/docs/getting-started/configuration.md +0 -0
  34. {riplex-0.7.4 → riplex-0.9.0}/docs/getting-started/installation.md +0 -0
  35. {riplex-0.7.4 → riplex-0.9.0}/docs/gui-guide/gui-walkthrough.md +0 -0
  36. {riplex-0.7.4 → riplex-0.9.0}/docs/index.md +0 -0
  37. {riplex-0.7.4 → riplex-0.9.0}/docs/naming-rules.md +0 -0
  38. {riplex-0.7.4 → riplex-0.9.0}/docs/reference/cli.md +0 -0
  39. {riplex-0.7.4 → riplex-0.9.0}/docs/troubleshooting.md +0 -0
  40. {riplex-0.7.4 → riplex-0.9.0}/issues/debug-artifacts-consolidation.md +0 -0
  41. {riplex-0.7.4 → riplex-0.9.0}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
  42. {riplex-0.7.4 → riplex-0.9.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  43. {riplex-0.7.4 → riplex-0.9.0}/issues/planned-features.md +0 -0
  44. {riplex-0.7.4 → riplex-0.9.0}/mkdocs.yml +0 -0
  45. {riplex-0.7.4 → riplex-0.9.0}/pyproject.toml +0 -0
  46. {riplex-0.7.4 → riplex-0.9.0}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  47. {riplex-0.7.4 → riplex-0.9.0}/screenshots/1_Welcome_Screen.png +0 -0
  48. {riplex-0.7.4 → riplex-0.9.0}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  49. {riplex-0.7.4 → riplex-0.9.0}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  50. {riplex-0.7.4 → riplex-0.9.0}/screenshots/4_Disc_Release_BTTF.png +0 -0
  51. {riplex-0.7.4 → riplex-0.9.0}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  52. {riplex-0.7.4 → riplex-0.9.0}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  53. {riplex-0.7.4 → riplex-0.9.0}/setup.cfg +0 -0
  54. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/__init__.py +0 -0
  55. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/cache.py +0 -0
  56. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/config.py +0 -0
  57. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/dedup.py +0 -0
  58. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/detect.py +0 -0
  59. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/__init__.py +0 -0
  60. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/makemkv.py +0 -0
  61. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/disc/provider.py +0 -0
  62. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/formatter.py +0 -0
  63. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/lookup.py +0 -0
  64. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/manifest.py +0 -0
  65. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/__init__.py +0 -0
  66. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/planner.py +0 -0
  67. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/provider.py +0 -0
  68. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/sources/__init__.py +0 -0
  69. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/metadata/sources/tmdb.py +0 -0
  70. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/models.py +0 -0
  71. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/scanner.py +0 -0
  72. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/snapshot.py +0 -0
  73. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/splitter.py +0 -0
  74. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/tagger.py +0 -0
  75. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/title.py +0 -0
  76. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/ui.py +0 -0
  77. {riplex-0.7.4 → riplex-0.9.0}/src/riplex/updater.py +0 -0
  78. {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/dependency_links.txt +0 -0
  79. {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/entry_points.txt +0 -0
  80. {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/requires.txt +0 -0
  81. {riplex-0.7.4 → riplex-0.9.0}/src/riplex.egg-info/top_level.txt +0 -0
  82. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/__init__.py +0 -0
  83. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/bug_report.py +0 -0
  84. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/crash_dump.py +0 -0
  85. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/keep_awake.py +0 -0
  86. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/main.py +0 -0
  87. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/__init__.py +0 -0
  88. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/disc_detection.py +0 -0
  89. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/disc_overview.py +0 -0
  90. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/disc_swap.py +0 -0
  91. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/folder_picker.py +0 -0
  92. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/organize_done.py +0 -0
  93. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/organize_preview.py +0 -0
  94. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/progress.py +0 -0
  95. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/release.py +0 -0
  96. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/selection.py +0 -0
  97. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_app/screens/update.py +0 -0
  98. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/__init__.py +0 -0
  99. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/__init__.py +0 -0
  100. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/lookup.py +0 -0
  101. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/orchestrate.py +0 -0
  102. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/organize.py +0 -0
  103. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/rip.py +0 -0
  104. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/commands/setup.py +0 -0
  105. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/formatting.py +0 -0
  106. {riplex-0.7.4 → riplex-0.9.0}/src/riplex_cli/main.py +0 -0
  107. {riplex-0.7.4 → riplex-0.9.0}/tests/__init__.py +0 -0
  108. {riplex-0.7.4 → riplex-0.9.0}/tests/fixtures/chernobyl_disc1.json +0 -0
  109. {riplex-0.7.4 → riplex-0.9.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  110. {riplex-0.7.4 → riplex-0.9.0}/tests/fixtures/makemkvcon_list.txt +0 -0
  111. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  112. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  113. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  114. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  115. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  116. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  117. {riplex-0.7.4 → riplex-0.9.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
  118. {riplex-0.7.4 → riplex-0.9.0}/tests/test_cache.py +0 -0
  119. {riplex-0.7.4 → riplex-0.9.0}/tests/test_cli_utils.py +0 -0
  120. {riplex-0.7.4 → riplex-0.9.0}/tests/test_config.py +0 -0
  121. {riplex-0.7.4 → riplex-0.9.0}/tests/test_dedup.py +0 -0
  122. {riplex-0.7.4 → riplex-0.9.0}/tests/test_detect.py +0 -0
  123. {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_detection_screen.py +0 -0
  124. {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_fixtures.py +0 -0
  125. {riplex-0.7.4 → riplex-0.9.0}/tests/test_disc_provider.py +0 -0
  126. {riplex-0.7.4 → riplex-0.9.0}/tests/test_formatter.py +0 -0
  127. {riplex-0.7.4 → riplex-0.9.0}/tests/test_lookup.py +0 -0
  128. {riplex-0.7.4 → riplex-0.9.0}/tests/test_makemkv.py +0 -0
  129. {riplex-0.7.4 → riplex-0.9.0}/tests/test_normalize.py +0 -0
  130. {riplex-0.7.4 → riplex-0.9.0}/tests/test_planner.py +0 -0
  131. {riplex-0.7.4 → riplex-0.9.0}/tests/test_release_screen.py +0 -0
  132. {riplex-0.7.4 → riplex-0.9.0}/tests/test_rip_guide.py +0 -0
  133. {riplex-0.7.4 → riplex-0.9.0}/tests/test_scanner.py +0 -0
  134. {riplex-0.7.4 → riplex-0.9.0}/tests/test_snapshot.py +0 -0
  135. {riplex-0.7.4 → riplex-0.9.0}/tests/test_splitter.py +0 -0
  136. {riplex-0.7.4 → riplex-0.9.0}/tests/test_tagger.py +0 -0
  137. {riplex-0.7.4 → riplex-0.9.0}/tests/test_ui.py +0 -0
  138. {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.7.4
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. Runtimes may be
255
- # zero when dvdcompare only lists the title/resolution; those
256
- # are filled in during matching by pairing editions with files
257
- # by duration order (shortest = theatrical, longest = extended).
258
- suppress_movie_target = True
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
- m = _EDITION_RE.search(ex.title)
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
- zero_edition_targets.sort(key=_edition_sort_key)
535
- # Sort files by duration (shortest first = theatrical)
536
- unclaimed_files.sort(key=lambda fi: all_scanned[fi].duration_seconds)
537
-
538
- for ti, fi in zip(zero_edition_targets, unclaimed_files):
539
- sf = all_scanned[fi]
540
- label = targets[ti][0]
541
- log.debug("Edition match (no runtime): %s (%ds) -> '%s'",
542
- sf.name, sf.duration_seconds, label)
543
- matched.append(
544
- MatchCandidate(
545
- file_name=sf.name,
546
- file_duration_seconds=sf.duration_seconds,
547
- matched_label=label,
548
- matched_runtime_seconds=sf.duration_seconds,
549
- delta_seconds=0,
550
- confidence="medium",
551
- classification=sf.classification,
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
- claimed_files.add(fi)
555
- claimed_targets.add(ti)
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 not in claimed_targets
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 not in claimed_targets
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(title: str, year: int, ext: str = ".mkv", edition: str | None = None) -> str:
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
- m = _EDITION_RE.search(edition_part)
472
- if m:
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
- m = _EDITION_RE.search(candidate.classification)
477
- if m:
478
- edition = m.group(1)
479
- dest = base / movie_file_name(plan.canonical_title, plan.year, edition=edition)
480
- log.debug(" -> movie main file (edition=%s): %s", edition, dest)
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.7.4
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 Plex library.",
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 Plex folder "
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 Plex",
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 Plex' "
140
- "to match files to metadata and move them into your Plex library.",
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 Plex"
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()