riplex 0.6.3__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.3 → riplex-0.7.0}/.github/workflows/release.yml +5 -0
  2. {riplex-0.6.3 → riplex-0.7.0}/.gitignore +3 -0
  3. {riplex-0.6.3 → riplex-0.7.0}/PKG-INFO +1 -1
  4. {riplex-0.6.3 → riplex-0.7.0}/docs/changelog.md +25 -3
  5. {riplex-0.6.3 → riplex-0.7.0}/docs/reference/cli.md +1 -0
  6. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/analysis.py +12 -11
  7. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/provider.py +91 -0
  8. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/matcher.py +49 -7
  9. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/organizer.py +3 -0
  10. {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/PKG-INFO +1 -1
  11. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/folder_picker.py +69 -2
  12. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/organize_preview.py +16 -1
  13. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/release.py +190 -12
  14. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/organize.py +35 -4
  15. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/main.py +10 -0
  16. {riplex-0.6.3 → riplex-0.7.0}/tests/fixtures/chernobyl_disc1.json +2 -2
  17. {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_analysis.py +37 -0
  18. {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_fixtures.py +13 -6
  19. {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_provider.py +43 -0
  20. {riplex-0.6.3 → riplex-0.7.0}/tests/test_matcher.py +57 -3
  21. {riplex-0.6.3 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  22. {riplex-0.6.3 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  23. {riplex-0.6.3 → riplex-0.7.0}/.github/agents/riplex.agent.md +0 -0
  24. {riplex-0.6.3 → riplex-0.7.0}/.github/copilot-instructions.md +0 -0
  25. {riplex-0.6.3 → riplex-0.7.0}/.github/workflows/publish.yml +0 -0
  26. {riplex-0.6.3 → riplex-0.7.0}/.vscode/settings.json +0 -0
  27. {riplex-0.6.3 → riplex-0.7.0}/CONTRIBUTORS.md +0 -0
  28. {riplex-0.6.3 → riplex-0.7.0}/LICENSE +0 -0
  29. {riplex-0.6.3 → riplex-0.7.0}/README.md +0 -0
  30. {riplex-0.6.3 → riplex-0.7.0}/docs/architecture.md +0 -0
  31. {riplex-0.6.3 → riplex-0.7.0}/docs/getting-started/configuration.md +0 -0
  32. {riplex-0.6.3 → riplex-0.7.0}/docs/getting-started/installation.md +0 -0
  33. {riplex-0.6.3 → riplex-0.7.0}/docs/guide/lookup.md +0 -0
  34. {riplex-0.6.3 → riplex-0.7.0}/docs/guide/orchestrate.md +0 -0
  35. {riplex-0.6.3 → riplex-0.7.0}/docs/guide/organize.md +0 -0
  36. {riplex-0.6.3 → riplex-0.7.0}/docs/guide/workflow.md +0 -0
  37. {riplex-0.6.3 → riplex-0.7.0}/docs/index.md +0 -0
  38. {riplex-0.6.3 → riplex-0.7.0}/docs/naming-rules.md +0 -0
  39. {riplex-0.6.3 → riplex-0.7.0}/docs/troubleshooting.md +0 -0
  40. {riplex-0.6.3 → riplex-0.7.0}/issues/debug-artifacts-consolidation.md +0 -0
  41. {riplex-0.6.3 → riplex-0.7.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  42. {riplex-0.6.3 → riplex-0.7.0}/issues/planned-features.md +0 -0
  43. {riplex-0.6.3 → riplex-0.7.0}/mkdocs.yml +0 -0
  44. {riplex-0.6.3 → riplex-0.7.0}/pyproject.toml +0 -0
  45. {riplex-0.6.3 → riplex-0.7.0}/setup.cfg +0 -0
  46. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/__init__.py +0 -0
  47. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/cache.py +0 -0
  48. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/config.py +0 -0
  49. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/dedup.py +0 -0
  50. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/detect.py +0 -0
  51. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/__init__.py +0 -0
  52. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/makemkv.py +0 -0
  53. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/formatter.py +0 -0
  54. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/lookup.py +0 -0
  55. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/manifest.py +0 -0
  56. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/__init__.py +0 -0
  57. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/planner.py +0 -0
  58. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/provider.py +0 -0
  59. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/sources/__init__.py +0 -0
  60. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/sources/tmdb.py +0 -0
  61. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/models.py +0 -0
  62. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/normalize.py +0 -0
  63. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/scanner.py +0 -0
  64. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/snapshot.py +0 -0
  65. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/splitter.py +0 -0
  66. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/tagger.py +0 -0
  67. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/title.py +0 -0
  68. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/ui.py +0 -0
  69. {riplex-0.6.3 → riplex-0.7.0}/src/riplex/updater.py +0 -0
  70. {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/SOURCES.txt +0 -0
  71. {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/dependency_links.txt +0 -0
  72. {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/entry_points.txt +0 -0
  73. {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/requires.txt +0 -0
  74. {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/top_level.txt +0 -0
  75. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/__init__.py +0 -0
  76. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/bug_report.py +0 -0
  77. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/crash_dump.py +0 -0
  78. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/keep_awake.py +0 -0
  79. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/main.py +0 -0
  80. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/__init__.py +0 -0
  81. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/disc_detection.py +0 -0
  82. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/disc_overview.py +0 -0
  83. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/disc_swap.py +0 -0
  84. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/done.py +0 -0
  85. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/metadata.py +0 -0
  86. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/orchestrate_done.py +0 -0
  87. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/organize_done.py +0 -0
  88. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/progress.py +0 -0
  89. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/selection.py +0 -0
  90. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/update.py +0 -0
  91. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/welcome.py +0 -0
  92. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/__init__.py +0 -0
  93. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/__init__.py +0 -0
  94. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/lookup.py +0 -0
  95. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/orchestrate.py +0 -0
  96. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/rip.py +0 -0
  97. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/setup.py +0 -0
  98. {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/formatting.py +0 -0
  99. {riplex-0.6.3 → riplex-0.7.0}/tests/__init__.py +0 -0
  100. {riplex-0.6.3 → riplex-0.7.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  101. {riplex-0.6.3 → riplex-0.7.0}/tests/fixtures/makemkvcon_list.txt +0 -0
  102. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  103. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  104. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  105. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  106. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  107. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  108. {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
  109. {riplex-0.6.3 → riplex-0.7.0}/tests/test_cache.py +0 -0
  110. {riplex-0.6.3 → riplex-0.7.0}/tests/test_cli_utils.py +0 -0
  111. {riplex-0.6.3 → riplex-0.7.0}/tests/test_config.py +0 -0
  112. {riplex-0.6.3 → riplex-0.7.0}/tests/test_dedup.py +0 -0
  113. {riplex-0.6.3 → riplex-0.7.0}/tests/test_detect.py +0 -0
  114. {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_detection_screen.py +0 -0
  115. {riplex-0.6.3 → riplex-0.7.0}/tests/test_formatter.py +0 -0
  116. {riplex-0.6.3 → riplex-0.7.0}/tests/test_makemkv.py +0 -0
  117. {riplex-0.6.3 → riplex-0.7.0}/tests/test_normalize.py +0 -0
  118. {riplex-0.6.3 → riplex-0.7.0}/tests/test_organizer.py +0 -0
  119. {riplex-0.6.3 → riplex-0.7.0}/tests/test_planner.py +0 -0
  120. {riplex-0.6.3 → riplex-0.7.0}/tests/test_rip_guide.py +0 -0
  121. {riplex-0.6.3 → riplex-0.7.0}/tests/test_scanner.py +0 -0
  122. {riplex-0.6.3 → riplex-0.7.0}/tests/test_snapshot.py +0 -0
  123. {riplex-0.6.3 → riplex-0.7.0}/tests/test_splitter.py +0 -0
  124. {riplex-0.6.3 → riplex-0.7.0}/tests/test_tagger.py +0 -0
  125. {riplex-0.6.3 → riplex-0.7.0}/tests/test_ui.py +0 -0
  126. {riplex-0.6.3 → riplex-0.7.0}/tests/test_updater.py +0 -0
@@ -164,6 +164,11 @@ jobs:
164
164
  - name: Create GitHub Release
165
165
  uses: softprops/action-gh-release@v2
166
166
  with:
167
+ # Auto-generate notes from commits and PRs since the previous tag,
168
+ # then prepend the tag annotation (if any) as a header. Using just
169
+ # the tag annotation produces single-line releases when the tag
170
+ # was created with `git tag -a -m "<subject>"` and no body.
171
+ generate_release_notes: true
167
172
  body: ${{ steps.tag_notes.outputs.body }}
168
173
  files: |
169
174
  release/riplex-windows.exe
@@ -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.3
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,14 +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
+ ## 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.
18
+
19
+ ### Changed
20
+
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.
24
+
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
+
7
29
  ## 2026-05-13
8
30
 
9
31
  ### Changed
10
32
 
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.
33
+ - Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing.
12
34
 
13
35
  ## 2026-05-12
14
36
 
@@ -43,7 +65,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
43
65
  ### Added
44
66
 
45
67
  - Troubleshooting guide: macOS-specific sections for tkinter/browse button, SSL certificate errors, Gatekeeper blocking, and tools not found despite being installed.
46
- - 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.
47
69
 
48
70
  ### Changed
49
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:
@@ -208,6 +208,82 @@ class DiscProvider:
208
208
  # in screens / external code may still use this; prefer ``fetch_film``.
209
209
  _fetch_film_cached = fetch_film
210
210
 
211
+ async def fetch_film_by_id(self, film_id: int) -> FilmComparison:
212
+ """Fetch a specific dvdcompare film page by its film id.
213
+
214
+ Used by the UI's manual-override path when our auto-ranking picks
215
+ the wrong film page. Cached on success; HTTP/network failures
216
+ propagate as LookupError after caching a short-TTL negative entry.
217
+ """
218
+ cache_key = cache.hash_key(f"film-by-id|{film_id}")
219
+
220
+ cached = cache.cache_get(self.cache_ns, cache_key, ttl_days=self.ttl_days)
221
+ if cached is not None and not cached.get("_negative"):
222
+ log.debug("dvdcompare film-by-id cache hit for %s", film_id)
223
+ return _dict_to_film(cached)
224
+
225
+ url = film_url(film_id)
226
+ try:
227
+ film = await _throttled_get_film_by_url(url)
228
+ except httpx.HTTPStatusError as exc:
229
+ status = exc.response.status_code if exc.response is not None else "?"
230
+ log.warning("dvdcompare HTTP %s fetching fid=%s", status, film_id)
231
+ raise LookupError(
232
+ f"dvdcompare returned HTTP {status} for fid={film_id}"
233
+ ) from exc
234
+ except httpx.RequestError as exc:
235
+ log.warning("dvdcompare network error fetching fid=%s: %s", film_id, exc)
236
+ raise LookupError(
237
+ f"dvdcompare network error for fid={film_id}: {exc}"
238
+ ) from exc
239
+
240
+ if not film.releases:
241
+ raise LookupError(f"dvdcompare film {film_id} has no releases")
242
+
243
+ cache.cache_set(self.cache_ns, cache_key, dataclasses.asdict(film))
244
+ return film
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Public URL / fid helpers
249
+ # ---------------------------------------------------------------------------
250
+
251
+ from dvdcompare.scraper import BASE_URL as _DVDCOMPARE_BASE_URL # noqa: E402
252
+
253
+
254
+ def film_url(film_id: int | str) -> str:
255
+ """Return the public dvdcompare comparison URL for a film id."""
256
+ return f"{_DVDCOMPARE_BASE_URL}/comparisons/film.php?fid={film_id}"
257
+
258
+
259
+ _FID_RE = re.compile(r"(?:fid=|film\.php\?fid=|^)(\d+)")
260
+
261
+
262
+ def parse_film_id(value: str) -> int | None:
263
+ """Extract a numeric film id from a bare number or a dvdcompare URL.
264
+
265
+ Returns ``None`` if no fid can be parsed.
266
+
267
+ Accepted forms:
268
+ - ``"55540"``
269
+ - ``"fid=55540"``
270
+ - ``"https://www.dvdcompare.net/comparisons/film.php?fid=55540"``
271
+ - ``"https://www.dvdcompare.net/comparisons/film.php?fid=55540#2"``
272
+ """
273
+ if not value:
274
+ return None
275
+ s = value.strip()
276
+ if not s:
277
+ return None
278
+ # Pure digits
279
+ if s.isdigit():
280
+ return int(s)
281
+ m = _FID_RE.search(s)
282
+ if m:
283
+ return int(m.group(1))
284
+ return None
285
+
286
+
211
287
 
212
288
  # ---------------------------------------------------------------------------
213
289
  # Internal helpers: negative cache + throttle
@@ -284,6 +360,21 @@ async def _throttled_find_film(
284
360
  _last_request_at = time.monotonic()
285
361
 
286
362
 
363
+ async def _throttled_get_film_by_url(url: str) -> FilmComparison:
364
+ """Call ``get_film_by_url`` under the same throttle as ``find_film``."""
365
+ global _last_request_at
366
+ async with _request_lock:
367
+ now = time.monotonic()
368
+ wait = _MIN_INTERVAL_S - (now - _last_request_at)
369
+ if wait > 0:
370
+ log.debug("dvdcompare throttle: waiting %.2fs before request", wait)
371
+ await asyncio.sleep(wait)
372
+ try:
373
+ return await get_film_by_url(url)
374
+ finally:
375
+ _last_request_at = time.monotonic()
376
+
377
+
287
378
  async def _find_film_prefer_format(
288
379
  title: str, disc_format: str, year: int | None
289
380
  ) -> FilmComparison:
@@ -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.3
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)