riplex 0.6.4__tar.gz → 0.7.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 (126) hide show
  1. {riplex-0.6.4 → riplex-0.7.0}/.gitignore +3 -0
  2. {riplex-0.6.4 → riplex-0.7.0}/PKG-INFO +1 -1
  3. {riplex-0.6.4 → riplex-0.7.0}/docs/changelog.md +24 -7
  4. {riplex-0.6.4 → riplex-0.7.0}/docs/reference/cli.md +1 -0
  5. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/analysis.py +12 -11
  6. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/matcher.py +49 -7
  7. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/organizer.py +3 -0
  8. {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/PKG-INFO +1 -1
  9. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/folder_picker.py +69 -2
  10. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/organize_preview.py +16 -1
  11. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/release.py +33 -0
  12. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/organize.py +35 -4
  13. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/main.py +10 -0
  14. {riplex-0.6.4 → riplex-0.7.0}/tests/fixtures/chernobyl_disc1.json +2 -2
  15. {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_analysis.py +37 -0
  16. {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_fixtures.py +13 -6
  17. {riplex-0.6.4 → riplex-0.7.0}/tests/test_matcher.py +57 -3
  18. {riplex-0.6.4 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {riplex-0.6.4 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  20. {riplex-0.6.4 → riplex-0.7.0}/.github/agents/riplex.agent.md +0 -0
  21. {riplex-0.6.4 → riplex-0.7.0}/.github/copilot-instructions.md +0 -0
  22. {riplex-0.6.4 → riplex-0.7.0}/.github/workflows/publish.yml +0 -0
  23. {riplex-0.6.4 → riplex-0.7.0}/.github/workflows/release.yml +0 -0
  24. {riplex-0.6.4 → riplex-0.7.0}/.vscode/settings.json +0 -0
  25. {riplex-0.6.4 → riplex-0.7.0}/CONTRIBUTORS.md +0 -0
  26. {riplex-0.6.4 → riplex-0.7.0}/LICENSE +0 -0
  27. {riplex-0.6.4 → riplex-0.7.0}/README.md +0 -0
  28. {riplex-0.6.4 → riplex-0.7.0}/docs/architecture.md +0 -0
  29. {riplex-0.6.4 → riplex-0.7.0}/docs/getting-started/configuration.md +0 -0
  30. {riplex-0.6.4 → riplex-0.7.0}/docs/getting-started/installation.md +0 -0
  31. {riplex-0.6.4 → riplex-0.7.0}/docs/guide/lookup.md +0 -0
  32. {riplex-0.6.4 → riplex-0.7.0}/docs/guide/orchestrate.md +0 -0
  33. {riplex-0.6.4 → riplex-0.7.0}/docs/guide/organize.md +0 -0
  34. {riplex-0.6.4 → riplex-0.7.0}/docs/guide/workflow.md +0 -0
  35. {riplex-0.6.4 → riplex-0.7.0}/docs/index.md +0 -0
  36. {riplex-0.6.4 → riplex-0.7.0}/docs/naming-rules.md +0 -0
  37. {riplex-0.6.4 → riplex-0.7.0}/docs/troubleshooting.md +0 -0
  38. {riplex-0.6.4 → riplex-0.7.0}/issues/debug-artifacts-consolidation.md +0 -0
  39. {riplex-0.6.4 → riplex-0.7.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  40. {riplex-0.6.4 → riplex-0.7.0}/issues/planned-features.md +0 -0
  41. {riplex-0.6.4 → riplex-0.7.0}/mkdocs.yml +0 -0
  42. {riplex-0.6.4 → riplex-0.7.0}/pyproject.toml +0 -0
  43. {riplex-0.6.4 → riplex-0.7.0}/setup.cfg +0 -0
  44. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/__init__.py +0 -0
  45. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/cache.py +0 -0
  46. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/config.py +0 -0
  47. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/dedup.py +0 -0
  48. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/detect.py +0 -0
  49. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/__init__.py +0 -0
  50. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/makemkv.py +0 -0
  51. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/provider.py +0 -0
  52. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/formatter.py +0 -0
  53. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/lookup.py +0 -0
  54. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/manifest.py +0 -0
  55. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/__init__.py +0 -0
  56. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/planner.py +0 -0
  57. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/provider.py +0 -0
  58. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/sources/__init__.py +0 -0
  59. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/sources/tmdb.py +0 -0
  60. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/models.py +0 -0
  61. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/normalize.py +0 -0
  62. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/scanner.py +0 -0
  63. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/snapshot.py +0 -0
  64. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/splitter.py +0 -0
  65. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/tagger.py +0 -0
  66. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/title.py +0 -0
  67. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/ui.py +0 -0
  68. {riplex-0.6.4 → riplex-0.7.0}/src/riplex/updater.py +0 -0
  69. {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/SOURCES.txt +0 -0
  70. {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/dependency_links.txt +0 -0
  71. {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/entry_points.txt +0 -0
  72. {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/requires.txt +0 -0
  73. {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/top_level.txt +0 -0
  74. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/__init__.py +0 -0
  75. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/bug_report.py +0 -0
  76. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/crash_dump.py +0 -0
  77. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/keep_awake.py +0 -0
  78. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/main.py +0 -0
  79. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/__init__.py +0 -0
  80. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/disc_detection.py +0 -0
  81. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/disc_overview.py +0 -0
  82. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/disc_swap.py +0 -0
  83. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/done.py +0 -0
  84. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/metadata.py +0 -0
  85. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/orchestrate_done.py +0 -0
  86. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/organize_done.py +0 -0
  87. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/progress.py +0 -0
  88. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/selection.py +0 -0
  89. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/update.py +0 -0
  90. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/welcome.py +0 -0
  91. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/__init__.py +0 -0
  92. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/__init__.py +0 -0
  93. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/lookup.py +0 -0
  94. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/orchestrate.py +0 -0
  95. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/rip.py +0 -0
  96. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/setup.py +0 -0
  97. {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/formatting.py +0 -0
  98. {riplex-0.6.4 → riplex-0.7.0}/tests/__init__.py +0 -0
  99. {riplex-0.6.4 → riplex-0.7.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  100. {riplex-0.6.4 → riplex-0.7.0}/tests/fixtures/makemkvcon_list.txt +0 -0
  101. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  102. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  103. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  104. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  105. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  106. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  107. {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
  108. {riplex-0.6.4 → riplex-0.7.0}/tests/test_cache.py +0 -0
  109. {riplex-0.6.4 → riplex-0.7.0}/tests/test_cli_utils.py +0 -0
  110. {riplex-0.6.4 → riplex-0.7.0}/tests/test_config.py +0 -0
  111. {riplex-0.6.4 → riplex-0.7.0}/tests/test_dedup.py +0 -0
  112. {riplex-0.6.4 → riplex-0.7.0}/tests/test_detect.py +0 -0
  113. {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_detection_screen.py +0 -0
  114. {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_provider.py +0 -0
  115. {riplex-0.6.4 → riplex-0.7.0}/tests/test_formatter.py +0 -0
  116. {riplex-0.6.4 → riplex-0.7.0}/tests/test_makemkv.py +0 -0
  117. {riplex-0.6.4 → riplex-0.7.0}/tests/test_normalize.py +0 -0
  118. {riplex-0.6.4 → riplex-0.7.0}/tests/test_organizer.py +0 -0
  119. {riplex-0.6.4 → riplex-0.7.0}/tests/test_planner.py +0 -0
  120. {riplex-0.6.4 → riplex-0.7.0}/tests/test_rip_guide.py +0 -0
  121. {riplex-0.6.4 → riplex-0.7.0}/tests/test_scanner.py +0 -0
  122. {riplex-0.6.4 → riplex-0.7.0}/tests/test_snapshot.py +0 -0
  123. {riplex-0.6.4 → riplex-0.7.0}/tests/test_splitter.py +0 -0
  124. {riplex-0.6.4 → riplex-0.7.0}/tests/test_tagger.py +0 -0
  125. {riplex-0.6.4 → riplex-0.7.0}/tests/test_ui.py +0 -0
  126. {riplex-0.6.4 → riplex-0.7.0}/tests/test_updater.py +0 -0
@@ -10,3 +10,6 @@ build/
10
10
  riplex.toml
11
11
  plex-planner.toml
12
12
  riplex_app.log
13
+
14
+ # Local dev fixture captures (scripts/capture_fixture.py output)
15
+ _captures/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.6.4
3
+ Version: 0.7.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
@@ -1,19 +1,36 @@
1
- # Documentation Changelog
1
+ # Documentation Changelog
2
2
 
3
3
  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
- ## 2026-05-13
7
+ ## v0.7.0 — 2026-05-16
8
+
9
+ Summary: organize-time match quality fixes, faster post-rip organize, and a release-picker affordance for verifying the dvdcompare film page.
10
+
11
+ ### Added
12
+
13
+ - **GUI: "View on dvdcompare.net" link** on the disc-release screen. Shows the auto-selected film page so users can verify region/edition before committing to a long rip.
14
+ - **GUI: manual film-id override** on the disc-release screen. Paste either a bare fid (e.g. `55540`) or a full URL (`https://www.dvdcompare.net/comparisons/film.php?fid=55540`) and riplex fetches and uses that film page instead. The chosen fid is persisted per `(title, disc_format)` so swapping discs in the same box set keeps the override, with a "Clear saved override" affordance.
15
+ - **`riplex organize --rescan`** flag. By default `organize` now reads `_rip_manifest.json` from each disc subfolder when present (instant load, preserves rip-time classification). Pass `--rescan` to force a fresh ffprobe scan instead.
16
+ - **GUI organize folder picker**: when every disc subfolder has a `_rip_manifest.json`, the folder loads instantly without running ffprobe. A green banner indicates the manifest load with a "Rescan with ffprobe" button to force a fresh probe.
17
+ - **GUI organize preview**: every matched row now shows a confidence chip with the actual delta in seconds (e.g. `HIGH ±18s`, `MEDIUM ±104s`) so weak matches are easy to spot before executing.
8
18
 
9
19
  ### Changed
10
20
 
11
- - Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing. Boxsets such as *Back to the Future 40th Anniversary Trilogy* that glue six physical discs into a single dvdcompare disc entry are now split correctly into one PlannedDisc per physical disc.
21
+ - **Tighter match tolerance for extras and episodes.** The global `_MAX_MATCH_DELTA` of 300 s is now reserved for the main-movie target; episodes and extras use a 120 s cap. This prevents short featurettes from being claimed by unrelated short clips when no good candidate exists.
22
+ - **Classification-aware matching.** Files whose rip-time classification is `Unmatched content`, `Unknown content`, or `Very short` are no longer paired with a named extra target unless the duration delta is within ±30 s. Ambiguous shorts stay unmatched (and visible) instead of being silently assigned to the closest dvdcompare entry within the loose 300 s window.
23
+ - **Release workflow**: GitHub Releases now include both the manually composed release notes and the auto-generated commit list, so tag annotations authored ahead of the tag push aren't lost.
12
24
 
13
- ### Added
25
+ ### Fixed
26
+
27
+ - **4K disc extras classification**: 1080p extras on a 4K disc are now only skipped when a 4K counterpart actually exists on the same disc. Previously, the duplicate-detection pass could flag legitimate standalone 1080p extras as duplicates of unrelated 4K titles.
28
+
29
+ ## 2026-05-13
30
+
31
+ ### Changed
14
32
 
15
- - Disc Release screen: "View on dvdcompare.net" link showing the auto-selected film page so users can verify region/edition before committing to a long rip.
16
- - Disc Release screen: manual film-id override input. Paste either a bare fid (e.g. `55540`) or a full URL (`https://www.dvdcompare.net/comparisons/film.php?fid=55540`) and riplex will fetch and use that film page instead. The chosen fid is persisted per `(title, disc_format)` so swapping discs in the same box set keeps the override, with a "Clear saved override" affordance.
33
+ - Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing.
17
34
 
18
35
  ## 2026-05-12
19
36
 
@@ -48,7 +65,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
48
65
  ### Added
49
66
 
50
67
  - Troubleshooting guide: macOS-specific sections for tkinter/browse button, SSL certificate errors, Gatekeeper blocking, and tools not found despite being installed.
51
- - Installation guide: new "Install with pipx" section as the recommended install method for end users provides globally available `riplex` and `riplex-ui` commands without venv activation.
68
+ - Installation guide: new "Install with pipx" section as the recommended install method for end users — provides globally available `riplex` and `riplex-ui` commands without venv activation.
52
69
 
53
70
  ### Changed
54
71
 
@@ -59,6 +59,7 @@ riplex organize <folder> [options]
59
59
  | `--verbose`, `-v` | Print debug logging to stderr (log file is always written) |
60
60
  | `--no-cache` | Bypass cached dvdcompare and TMDb responses |
61
61
  | `--force` | Re-organize files even if already tagged as organized |
62
+ | `--rescan` | Ignore `_rip_manifest.json` files and re-probe every MKV with ffprobe. By default, riplex reuses rip-time metadata from the manifest when present (faster, preserves classification). |
62
63
  | `--json` | Output as JSON |
63
64
  | `--api-key` | TMDb API key |
64
65
  | `--snapshot` | Replay from a snapshot JSON file instead of scanning live files |
@@ -289,22 +289,23 @@ def is_skip_title(
289
289
  return True
290
290
 
291
291
  # On 4K discs, skip 1080p titles that match non-episode dvdcompare entries
292
- # (featurettes, behind-the-scenes, etc. at lower resolution)
293
- # Exception: keep 1080p featurette play-alls when no 4K version exists
292
+ # ONLY when a 4K physical title at the same duration also exists on the
293
+ # disc (i.e. the 1080p title is a true duplicate). Some studios (e.g.
294
+ # Universal) ship the 4K main film on a 4K disc but include most extras
295
+ # at 1080p only — in that case the 1080p extras are the *only* copy and
296
+ # must be ripped, not skipped.
294
297
  if not is_4k and dvd_entries:
295
298
  has_4k = any("3840" in (t.resolution or "") for t in all_titles if t.duration_seconds > 600)
296
299
  if has_4k:
297
300
  match = find_duration_match(dur, dvd_entries)
298
301
  if match and match[2] != "episode":
299
- # Keep featurette play-alls if no 4K counterpart at same duration
300
- if "play all" in match[0].lower() and _is_featurette_play_all(match[2]):
301
- has_4k_version = any(
302
- "3840" in (t.resolution or "") and abs(t.duration_seconds - dur) < 30
303
- for t in all_titles if t is not title
304
- )
305
- if not has_4k_version:
306
- return False
307
- return True
302
+ has_4k_version = any(
303
+ "3840" in (t.resolution or "")
304
+ and abs(t.duration_seconds - dur) < 30
305
+ for t in all_titles if t is not title
306
+ )
307
+ if has_4k_version:
308
+ return True
308
309
 
309
310
  # Skip dvdcompare-based play-all if individual episodes exist at same resolution
310
311
  if total_episode_runtime > 0 and abs(dur - total_episode_runtime) < 120:
@@ -161,8 +161,32 @@ def format_match_report(candidates: list[MatchCandidate]) -> str:
161
161
  _FILM_DISC_MARKER = -1
162
162
 
163
163
  # Absolute maximum delta (seconds) beyond which a pairing is rejected.
164
- # Prevents garbage matches when no good candidate exists.
165
- _MAX_MATCH_DELTA = 300
164
+ # The movie target gets a generous tolerance because main-feature
165
+ # runtimes from TMDb are often rounded to the nearest minute and may
166
+ # omit credits / disc-specific intros. Extras tend to have precise
167
+ # dvdcompare runtimes, so a much tighter cap prevents short featurettes
168
+ # from being matched to unrelated short clips.
169
+ _MAX_MATCH_DELTA = 300 # movie target
170
+ _MAX_MATCH_DELTA_EXTRA = 120 # episodes + extras
171
+
172
+ # Classification prefixes that signal "rip-time classifier could not
173
+ # identify this title". When set, the file must not be claimed by a
174
+ # named target unless the delta is very small (< _HIGH_THRESHOLD).
175
+ _UNIDENTIFIED_CLASSIFICATION_PREFIXES = (
176
+ "Unmatched content",
177
+ "Unknown content",
178
+ "Very short",
179
+ )
180
+
181
+
182
+ def _is_movie_target(label: str) -> bool:
183
+ return label.endswith("(movie)")
184
+
185
+
186
+ def _is_unidentified(classification: str) -> bool:
187
+ if not classification:
188
+ return False
189
+ return classification.startswith(_UNIDENTIFIED_CLASSIFICATION_PREFIXES)
166
190
 
167
191
  _PLAY_ALL_RE = re.compile(r"\bplay\s*all\b", re.IGNORECASE)
168
192
 
@@ -437,12 +461,30 @@ def match_discs(
437
461
  for delta, fi, ti in pairings:
438
462
  if fi in claimed_files or ti in claimed_targets:
439
463
  continue
440
- if delta > _MAX_MATCH_DELTA:
441
- log.debug("Stopping greedy claims: delta %ds exceeds max %ds",
442
- delta, _MAX_MATCH_DELTA)
443
- break
444
- sf = all_scanned[fi]
445
464
  label, runtime_s, _t_disc = targets[ti]
465
+ is_movie = _is_movie_target(label)
466
+ max_delta = _MAX_MATCH_DELTA if is_movie else _MAX_MATCH_DELTA_EXTRA
467
+ if delta > max_delta:
468
+ # Skip — but don't break, since later pairings may involve a
469
+ # movie target with a looser cap. Pairings are still sorted
470
+ # by delta, so any remaining pair this large is also too large.
471
+ log.debug("Skip pairing: %s -> '%s' delta=%ds exceeds cap %ds",
472
+ all_scanned[fi].name, label, delta, max_delta)
473
+ continue
474
+ sf = all_scanned[fi]
475
+ # Honor rip-time classification: if the classifier explicitly
476
+ # flagged this file as unidentified/short, require a tight delta
477
+ # before pairing it with a named target.
478
+ if (
479
+ not is_movie
480
+ and _is_unidentified(sf.classification)
481
+ and delta > _HIGH_THRESHOLD
482
+ ):
483
+ log.debug(
484
+ "Reject pairing on classification: %s [%s] -> '%s' delta=%ds",
485
+ sf.name, sf.classification, label, delta,
486
+ )
487
+ continue
446
488
  conf = _confidence(delta)
447
489
  log.debug("Claim: %s (%ds) -> '%s' (%ds) delta=%ds [%s]",
448
490
  sf.name, sf.duration_seconds, label, runtime_s, delta, conf)
@@ -75,6 +75,7 @@ class FileMove:
75
75
  destination: str
76
76
  label: str # what this file was matched to
77
77
  confidence: str
78
+ delta_seconds: int = 0 # |file_runtime - target_runtime|, for diagnostics
78
79
 
79
80
 
80
81
  @dataclass
@@ -86,6 +87,7 @@ class SplitMove:
86
87
  chapter_labels: list[str] # one label per chapter
87
88
  confidence: str
88
89
  original_label: str = "" # the dvdcompare match label
90
+ delta_seconds: int = 0
89
91
 
90
92
 
91
93
  @dataclass
@@ -396,6 +398,7 @@ def build_organize_plan(
396
398
  destination=str(dest),
397
399
  label=candidate.matched_label,
398
400
  confidence=candidate.confidence,
401
+ delta_seconds=candidate.delta_seconds,
399
402
  )
400
403
  )
401
404
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.6.4
3
+ Version: 0.7.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
@@ -11,6 +11,7 @@ log = logging.getLogger(__name__)
11
11
 
12
12
  from riplex.config import get_rip_output
13
13
  from riplex.detect import detect_format
14
+ from riplex.manifest import build_scanned_from_manifests
14
15
  from riplex.scanner import scan_folder
15
16
  from riplex.snapshot import load_organized_marker
16
17
  from riplex.title import infer_title_from_scanned
@@ -124,8 +125,48 @@ class FolderPickerScreen:
124
125
  return
125
126
  self.folder_field.error_text = None
126
127
 
127
- self.app.state["source_folder"] = Path(path)
128
+ folder = Path(path)
129
+ self.app.state["source_folder"] = folder
128
130
 
131
+ # Fast path: if every disc subfolder has a _rip_manifest.json,
132
+ # load metadata from the manifest instead of running ffprobe.
133
+ if self._has_complete_manifests(folder):
134
+ log.info("loading scan from rip manifests in %s", folder)
135
+ try:
136
+ scanned = build_scanned_from_manifests(folder)
137
+ except Exception as exc:
138
+ log.exception("manifest load failed; falling back to ffprobe")
139
+ scanned = None
140
+ if scanned:
141
+ self.app.state["_scan_from_manifest"] = True
142
+ self.app.state["_scan_result"] = scanned
143
+ self.app.navigate("folder_picker")
144
+ return
145
+
146
+ self.app.state["_scan_from_manifest"] = False
147
+ self._start_ffprobe_scan(folder)
148
+
149
+ def _has_complete_manifests(self, folder: Path) -> bool:
150
+ subfolders_with_mkvs = [
151
+ c for c in folder.iterdir()
152
+ if c.is_dir() and any(c.glob("*.mkv"))
153
+ ]
154
+ if not subfolders_with_mkvs:
155
+ return False
156
+ return all((c / "_rip_manifest.json").exists() for c in subfolders_with_mkvs)
157
+
158
+ def _rescan_with_ffprobe(self, e):
159
+ """User clicked the 'Rescan with ffprobe' banner button."""
160
+ folder = self.app.state.get("source_folder")
161
+ if not folder:
162
+ return
163
+ # Clear any cached scan and force a fresh ffprobe scan.
164
+ self.app.state.pop("scanned", None)
165
+ self.app.state.pop("_scan_result", None)
166
+ self.app.state["_scan_from_manifest"] = False
167
+ self._start_ffprobe_scan(folder)
168
+
169
+ def _start_ffprobe_scan(self, folder: Path):
129
170
  # Show scanning state with progress
130
171
  self._progress_text = ft.Text("Discovering files...", size=14)
131
172
  self._progress_bar = ft.ProgressBar(width=400)
@@ -149,7 +190,7 @@ class FolderPickerScreen:
149
190
  )
150
191
  self.app.page.update()
151
192
 
152
- threading.Thread(target=self._do_scan, args=(Path(path),), daemon=True).start()
193
+ threading.Thread(target=self._do_scan, args=(folder,), daemon=True).start()
153
194
 
154
195
  def _do_scan(self, folder: Path):
155
196
  """Run ffprobe scan in background."""
@@ -219,6 +260,31 @@ class FolderPickerScreen:
219
260
  ),
220
261
  ]
221
262
 
263
+ # Banner shown when the scan came from rip manifests (instant load).
264
+ manifest_banner = []
265
+ if self.app.state.get("_scan_from_manifest"):
266
+ manifest_banner = [
267
+ ft.Container(
268
+ content=ft.Row([
269
+ ft.Icon(ft.Icons.BOLT, color=ft.Colors.GREEN_400, size=18),
270
+ ft.Text(
271
+ "Loaded instantly from rip manifests (no ffprobe needed).",
272
+ size=13,
273
+ color=ft.Colors.GREEN_400,
274
+ expand=True,
275
+ ),
276
+ ft.TextButton(
277
+ "Rescan with ffprobe",
278
+ icon=ft.Icons.REFRESH,
279
+ on_click=self._rescan_with_ffprobe,
280
+ ),
281
+ ], spacing=8),
282
+ bgcolor=ft.Colors.with_opacity(0.08, ft.Colors.GREEN),
283
+ padding=12,
284
+ border_radius=8,
285
+ ),
286
+ ]
287
+
222
288
  # Build disc summary rows
223
289
  disc_rows = []
224
290
  for d in scanned:
@@ -252,6 +318,7 @@ class FolderPickerScreen:
252
318
  color=ft.Colors.GREY_500,
253
319
  ),
254
320
  ft.Divider(height=20),
321
+ *manifest_banner,
255
322
  *marker_banner,
256
323
  ft.Text(
257
324
  f"Scanned {len(scanned)} disc{'s' if len(scanned) != 1 else ''}, "
@@ -175,10 +175,25 @@ class OrganizePreviewScreen:
175
175
  "medium": ft.Colors.YELLOW,
176
176
  "low": ft.Colors.ORANGE,
177
177
  }.get(move.confidence, ft.Colors.GREY)
178
+ # Confidence chip: shows label + delta so the user can spot
179
+ # weak (medium/low) matches before executing.
180
+ delta = getattr(move, "delta_seconds", 0)
181
+ conf_label = (move.confidence or "?").upper()
182
+ if delta:
183
+ conf_text = f"{conf_label} \u00B1{delta}s"
184
+ else:
185
+ conf_text = conf_label
186
+ conf_chip = ft.Container(
187
+ content=ft.Text(conf_text, size=10, color=conf_color, weight=ft.FontWeight.BOLD),
188
+ bgcolor=ft.Colors.with_opacity(0.12, conf_color),
189
+ padding=ft.Padding(left=6, right=6, top=2, bottom=2),
190
+ border_radius=4,
191
+ )
178
192
  move_rows.append(
179
193
  ft.Row([
180
194
  ft.Icon(ft.Icons.CHECK_CIRCLE, color=conf_color, size=16),
181
- ft.Text(f"{src_name}", size=12, width=250, no_wrap=True),
195
+ conf_chip,
196
+ ft.Text(f"{src_name}", size=12, width=220, no_wrap=True),
182
197
  ft.Icon(ft.Icons.ARROW_FORWARD, size=14, color=ft.Colors.GREY_500),
183
198
  ft.Text(f"{dest_folder}/{dest_name}", size=12, expand=True, no_wrap=True),
184
199
  ], spacing=6)
@@ -41,6 +41,39 @@ class ReleaseScreen:
41
41
  return tmdb_match.title
42
42
  return self.app.state["title"]
43
43
 
44
+ # -- manual film-id override (cache-backed) ---------------------------
45
+
46
+ def _override_cache_key(self) -> str:
47
+ """Stable key for caching the user's manual film-id pick.
48
+
49
+ Keyed by ``(title, disc_format)`` so that swapping discs within the
50
+ same physical box set still picks up the override, but ripping a
51
+ different format edition (DVD vs 4K) of the same film does not.
52
+ """
53
+ title = self._current_search_title()
54
+ fmt = self._detect_disc_format() or ""
55
+ return _cache.hash_key(f"{title}|{fmt}")
56
+
57
+ def _load_persisted_override(self) -> int | None:
58
+ entry = _cache.cache_get(_OVERRIDE_CACHE_NS, self._override_cache_key(), ttl_days=365)
59
+ if isinstance(entry, dict):
60
+ fid = entry.get("film_id")
61
+ if isinstance(fid, int):
62
+ return fid
63
+ return None
64
+
65
+ def _save_persisted_override(self, film_id: int) -> None:
66
+ _cache.cache_set(
67
+ _OVERRIDE_CACHE_NS,
68
+ self._override_cache_key(),
69
+ {"film_id": film_id},
70
+ )
71
+
72
+ def _clear_persisted_override(self) -> None:
73
+ # cache module has no delete; write a sentinel that won't pass the
74
+ # int check in _load_persisted_override.
75
+ _cache.cache_set(_OVERRIDE_CACHE_NS, self._override_cache_key(), {"film_id": None})
76
+
44
77
  def _build_search_bar(self, title: str, *, searching: bool) -> ft.Control:
45
78
  """Editable dvdcompare search field."""
46
79
  self._search_field = ft.TextField(
@@ -11,6 +11,7 @@ from riplex.config import get_api_key, get_archive_root, get_output_root
11
11
  from riplex.dedup import find_all_redundant, remove_duplicates
12
12
  from riplex.detect import detect_format, detect_incomplete, group_title_folders, infer_media_type_from_files
13
13
  from riplex.lookup import lookup_metadata
14
+ from riplex.manifest import build_scanned_from_manifests
14
15
  from riplex.matcher import (
15
16
  collect_disc_targets,
16
17
  map_folders_to_discs,
@@ -41,6 +42,38 @@ from riplex_cli.formatting import (
41
42
  log = logging.getLogger(__name__)
42
43
 
43
44
 
45
+ def _has_complete_manifests(folder: Path) -> bool:
46
+ """Return True if every direct subfolder containing MKVs also has a
47
+ ``_rip_manifest.json`` (i.e. the folder was produced by ``riplex rip``).
48
+ """
49
+ subfolders_with_mkvs = [
50
+ c for c in folder.iterdir()
51
+ if c.is_dir() and any(c.glob("*.mkv"))
52
+ ]
53
+ if not subfolders_with_mkvs:
54
+ return False
55
+ return all((c / "_rip_manifest.json").exists() for c in subfolders_with_mkvs)
56
+
57
+
58
+ def _load_scanned(folder: Path, args: argparse.Namespace) -> list:
59
+ """Load ScannedDisc list, preferring rip manifests when present.
60
+
61
+ Re-probes via ffprobe when ``--rescan`` is set or when manifests are
62
+ missing/incomplete.
63
+ """
64
+ if not getattr(args, "rescan", False) and _has_complete_manifests(folder):
65
+ print(
66
+ f"Loading rip manifests from {folder} (use --rescan to force ffprobe).",
67
+ file=sys.stderr,
68
+ )
69
+ scanned = build_scanned_from_manifests(folder)
70
+ if scanned:
71
+ return scanned
72
+ print("Manifests found but no usable entries; falling back to ffprobe scan.", file=sys.stderr)
73
+ print(f"Scanning {folder} ...", file=sys.stderr)
74
+ return scan_folder(folder)
75
+
76
+
44
77
  async def run_organize(args: argparse.Namespace) -> int:
45
78
  """Run the organize workflow: scan, look up metadata, match, organize."""
46
79
  log_file = setup_logging(verbose=getattr(args, "verbose", False))
@@ -203,9 +236,8 @@ async def _organize_multi_folder(
203
236
 
204
237
  all_scanned: list[ScannedDisc] = []
205
238
  for folder in folders:
206
- print(f"Scanning {folder} ...", file=sys.stderr)
207
239
  try:
208
- scanned = scan_folder(folder)
240
+ scanned = _load_scanned(folder, args)
209
241
  snapshot_out = folder / f"{folder.name}.snapshot.json"
210
242
  if not snapshot_out.exists():
211
243
  snapshot_save_from_scanned(folder, scanned, snapshot_out)
@@ -244,9 +276,8 @@ async def _organize_single(
244
276
  )
245
277
  return 0
246
278
 
247
- print(f"Scanning {folder} ...", file=sys.stderr)
248
279
  try:
249
- scanned = scan_folder(folder)
280
+ scanned = _load_scanned(folder, args)
250
281
  except RuntimeError as exc:
251
282
  print(f"Error: {exc}", file=sys.stderr)
252
283
  return 1
@@ -110,6 +110,16 @@ def _build_parser() -> argparse.ArgumentParser:
110
110
  default=False,
111
111
  help="Skip interactive prompts, use best-guess defaults.",
112
112
  )
113
+ org_parser.add_argument(
114
+ "--rescan",
115
+ action="store_true",
116
+ default=False,
117
+ help=(
118
+ "Ignore _rip_manifest.json files and re-probe every MKV with "
119
+ "ffprobe. By default, riplex reuses rip-time metadata from "
120
+ "the manifest when present (faster, preserves classification)."
121
+ ),
122
+ )
113
123
 
114
124
  # --- lookup ---
115
125
  guide_parser = subs.add_parser(
@@ -65,8 +65,8 @@
65
65
  "is_movie": false,
66
66
  "movie_runtime": null,
67
67
  "disc_number": 1,
68
- "rip_indices": [0, 1, 2, 3, 4],
69
- "skip_indices": [5, 6, 7, 8, 9, 10, 11, 12],
68
+ "rip_indices": [0, 1, 2, 3, 4, 10, 11, 12],
69
+ "skip_indices": [5, 6, 7, 8, 9],
70
70
  "episode_names": {
71
71
  "0": "1:23:45",
72
72
  "3": "Open Wide, O Earth",
@@ -237,6 +237,43 @@ class TestIsSkipTitle:
237
237
  False, None, 0, 0, dvd_entries,
238
238
  ) is False
239
239
 
240
+ def test_keep_1080p_extra_on_4k_disc_without_4k_counterpart(self):
241
+ """On a 4K disc with 1080p-only extras (Universal BttF 40th Anniversary
242
+ pattern), the 1080p extras should NOT be skipped just because a 4K
243
+ main film exists on the same disc. They're the only copy."""
244
+ # 4K main film (movie length) so the disc qualifies as "has_4k"
245
+ main_4k = _make_title(0, 7098, resolution="3840x2160")
246
+ # 1080p featurette with a dvdcompare entry but NO 4K counterpart
247
+ feat_1080 = _make_title(1, 1027, resolution="1920x1080")
248
+ dvd_entries = [
249
+ ("Tales from the Future: Third Time's the Charm", 1027, "featurette"),
250
+ ]
251
+ assert is_skip_title(
252
+ feat_1080, [main_4k, feat_1080],
253
+ True, 7098, 0, 0, dvd_entries,
254
+ ) is False
255
+
256
+ def test_skip_1080p_extra_when_4k_counterpart_exists(self):
257
+ """When the same extra exists in both 4K and 1080p on the disc
258
+ (true duplicate), keep the 4K version and skip the 1080p."""
259
+ main_4k = _make_title(0, 7098, resolution="3840x2160")
260
+ # Same featurette, both resolutions, same duration
261
+ feat_4k = _make_title(1, 248, resolution="3840x2160")
262
+ feat_1080 = _make_title(2, 248, resolution="1920x1080")
263
+ dvd_entries = [
264
+ ("Music Video by ZZ Top: Doubleback", 248, "extra"),
265
+ ]
266
+ # 1080p version should be skipped (4K duplicate exists)
267
+ assert is_skip_title(
268
+ feat_1080, [main_4k, feat_4k, feat_1080],
269
+ True, 7098, 0, 0, dvd_entries,
270
+ ) is True
271
+ # 4K version should be kept
272
+ assert is_skip_title(
273
+ feat_4k, [main_4k, feat_4k, feat_1080],
274
+ True, 7098, 0, 0, dvd_entries,
275
+ ) is False
276
+
240
277
 
241
278
  class TestBuildDvdEntries:
242
279
  def test_builds_entries(self):
@@ -216,7 +216,8 @@ class TestChernobylDisc1:
216
216
  )
217
217
 
218
218
  def test_1080p_skipped_when_4k_exists(self, disc_data):
219
- """1080p titles should be skipped when 4K versions exist."""
219
+ """1080p titles should be skipped only when a 4K version exists on
220
+ the same disc. 1080p-only extras (no 4K counterpart) must be kept."""
220
221
  disc_info, planned_discs, expected = disc_data
221
222
 
222
223
  analysis = analyze_disc(
@@ -227,9 +228,15 @@ class TestChernobylDisc1:
227
228
  )
228
229
 
229
230
  rip_indices = [t.index for t in analysis.rippable_titles]
230
- # #5 (1080p version of #1) and #10, #11, #12 (1080p featurettes)
231
- # should not be in rip list
232
- for idx in [5, 10, 11, 12]:
233
- assert idx not in rip_indices, (
234
- f"Title #{idx} (1080p) should be skipped: {analysis.classifications[idx]}"
231
+ # #5 (1080p version of #1, 4K counterpart exists) should be skipped.
232
+ # #10, #11, #12 (1080p featurette children with no 4K counterpart)
233
+ # must NOT be skipped they're the only copy of that content.
234
+ assert 5 not in rip_indices, (
235
+ f"Title #5 (1080p dup of 4K #1) should be skipped: "
236
+ f"{analysis.classifications[5]}"
237
+ )
238
+ for idx in [10, 11, 12]:
239
+ assert idx in rip_indices, (
240
+ f"Title #{idx} (1080p featurette with no 4K counterpart) "
241
+ f"should NOT be skipped: {analysis.classifications[idx]}"
235
242
  )
@@ -542,7 +542,9 @@ class TestMaxDeltaThreshold:
542
542
  assert len(result.unmatched) == 1
543
543
 
544
544
  def test_within_threshold_still_matches(self):
545
- """A file 200s away from the target still matches (under 300s)."""
545
+ """A file 100s away from the target still matches (under the
546
+ extra cap of 120s).
547
+ """
546
548
  discs = [
547
549
  PlannedDisc(
548
550
  number=1,
@@ -556,13 +558,65 @@ class TestMaxDeltaThreshold:
556
558
  ScannedDisc(
557
559
  folder_name="Disc 1",
558
560
  files=[
559
- ScannedFile(name="a.mkv", path="x", duration_seconds=3200),
561
+ ScannedFile(name="a.mkv", path="x", duration_seconds=3100),
560
562
  ],
561
563
  ),
562
564
  ]
563
565
  result = match_discs(scanned, discs)
564
566
  assert len(result.matched) == 1
565
- assert result.matched[0].confidence == "low"
567
+ assert result.matched[0].confidence == "medium"
568
+
569
+ def test_movie_target_still_uses_300s_cap(self):
570
+ """The main-movie target keeps the generous 300s cap so TMDb
571
+ runtimes rounded to the nearest minute still match.
572
+ """
573
+ from riplex.models import PlannedMovie
574
+
575
+ plan = PlannedMovie(
576
+ canonical_title="Foo", year=2020,
577
+ runtime="120m", runtime_seconds=7200,
578
+ )
579
+ discs = [PlannedDisc(number=1, disc_format="Blu-ray", is_film=True)]
580
+ scanned = [
581
+ ScannedDisc(
582
+ folder_name="Disc 1",
583
+ files=[
584
+ ScannedFile(name="film.mkv", path="x", duration_seconds=7450),
585
+ ],
586
+ ),
587
+ ]
588
+ result = match_discs(scanned, discs, plan)
589
+ assert len(result.matched) == 1
590
+ assert "(movie)" in result.matched[0].matched_label
591
+
592
+ def test_extra_with_unidentified_classification_rejected(self):
593
+ """A file the rip-time classifier flagged as 'Unmatched content'
594
+ must not be paired with a named extra target unless the delta
595
+ is very small.
596
+ """
597
+ discs = [
598
+ PlannedDisc(
599
+ number=1,
600
+ disc_format="Blu-ray",
601
+ extras=[
602
+ PlannedExtra(title="Hoverboard Commercial", runtime_seconds=66),
603
+ ],
604
+ ),
605
+ ]
606
+ scanned = [
607
+ ScannedDisc(
608
+ folder_name="Disc 1",
609
+ files=[
610
+ ScannedFile(
611
+ name="t14.mkv", path="x", duration_seconds=170,
612
+ classification="Unmatched content (4K, 2:50)",
613
+ ),
614
+ ],
615
+ ),
616
+ ]
617
+ result = match_discs(scanned, discs)
618
+ assert len(result.matched) == 0
619
+ assert len(result.unmatched) == 1
566
620
 
567
621
  def test_blue_planet_ii_scenario(self):
568
622
  """INTO THE BLUE files rejected by max delta and play-all filter."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes