riplex 0.7.2__tar.gz → 0.7.4__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 (137) hide show
  1. {riplex-0.7.2/src/riplex.egg-info → riplex-0.7.4}/PKG-INFO +1 -1
  2. {riplex-0.7.2 → riplex-0.7.4}/docs/changelog.md +12 -0
  3. riplex-0.7.4/issues/manual-rip-and-organize-escape-hatch.md +106 -0
  4. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/detect.py +41 -5
  5. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/disc/makemkv.py +59 -2
  6. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/lookup.py +4 -1
  7. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/metadata/planner.py +2 -0
  8. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/models.py +1 -0
  9. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/title.py +33 -0
  10. {riplex-0.7.2 → riplex-0.7.4/src/riplex.egg-info}/PKG-INFO +1 -1
  11. {riplex-0.7.2 → riplex-0.7.4}/src/riplex.egg-info/SOURCES.txt +3 -0
  12. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/disc_detection.py +37 -3
  13. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/folder_picker.py +114 -2
  14. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/organize_preview.py +1 -0
  15. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/release.py +15 -1
  16. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/commands/orchestrate.py +5 -2
  17. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/commands/organize.py +28 -7
  18. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/commands/rip.py +5 -2
  19. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/main.py +18 -0
  20. {riplex-0.7.2 → riplex-0.7.4}/tests/test_cli_utils.py +29 -1
  21. {riplex-0.7.2 → riplex-0.7.4}/tests/test_detect.py +51 -0
  22. riplex-0.7.4/tests/test_lookup.py +48 -0
  23. {riplex-0.7.2 → riplex-0.7.4}/tests/test_makemkv.py +77 -0
  24. {riplex-0.7.2 → riplex-0.7.4}/tests/test_planner.py +53 -0
  25. riplex-0.7.4/tests/test_release_screen.py +40 -0
  26. {riplex-0.7.2 → riplex-0.7.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {riplex-0.7.2 → riplex-0.7.4}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  28. {riplex-0.7.2 → riplex-0.7.4}/.github/agents/riplex.agent.md +0 -0
  29. {riplex-0.7.2 → riplex-0.7.4}/.github/copilot-instructions.md +0 -0
  30. {riplex-0.7.2 → riplex-0.7.4}/.github/workflows/publish.yml +0 -0
  31. {riplex-0.7.2 → riplex-0.7.4}/.github/workflows/release.yml +0 -0
  32. {riplex-0.7.2 → riplex-0.7.4}/.gitignore +0 -0
  33. {riplex-0.7.2 → riplex-0.7.4}/.vscode/settings.json +0 -0
  34. {riplex-0.7.2 → riplex-0.7.4}/CONTRIBUTORS.md +0 -0
  35. {riplex-0.7.2 → riplex-0.7.4}/LICENSE +0 -0
  36. {riplex-0.7.2 → riplex-0.7.4}/README.md +0 -0
  37. {riplex-0.7.2 → riplex-0.7.4}/docs/architecture.md +0 -0
  38. {riplex-0.7.2 → riplex-0.7.4}/docs/cli-guide/lookup.md +0 -0
  39. {riplex-0.7.2 → riplex-0.7.4}/docs/cli-guide/orchestrate.md +0 -0
  40. {riplex-0.7.2 → riplex-0.7.4}/docs/cli-guide/organize.md +0 -0
  41. {riplex-0.7.2 → riplex-0.7.4}/docs/cli-guide/workflow.md +0 -0
  42. {riplex-0.7.2 → riplex-0.7.4}/docs/getting-started/configuration.md +0 -0
  43. {riplex-0.7.2 → riplex-0.7.4}/docs/getting-started/installation.md +0 -0
  44. {riplex-0.7.2 → riplex-0.7.4}/docs/gui-guide/gui-walkthrough.md +0 -0
  45. {riplex-0.7.2 → riplex-0.7.4}/docs/index.md +0 -0
  46. {riplex-0.7.2 → riplex-0.7.4}/docs/naming-rules.md +0 -0
  47. {riplex-0.7.2 → riplex-0.7.4}/docs/reference/cli.md +0 -0
  48. {riplex-0.7.2 → riplex-0.7.4}/docs/troubleshooting.md +0 -0
  49. {riplex-0.7.2 → riplex-0.7.4}/issues/debug-artifacts-consolidation.md +0 -0
  50. {riplex-0.7.2 → riplex-0.7.4}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  51. {riplex-0.7.2 → riplex-0.7.4}/issues/planned-features.md +0 -0
  52. {riplex-0.7.2 → riplex-0.7.4}/mkdocs.yml +0 -0
  53. {riplex-0.7.2 → riplex-0.7.4}/pyproject.toml +0 -0
  54. {riplex-0.7.2 → riplex-0.7.4}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  55. {riplex-0.7.2 → riplex-0.7.4}/screenshots/1_Welcome_Screen.png +0 -0
  56. {riplex-0.7.2 → riplex-0.7.4}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  57. {riplex-0.7.2 → riplex-0.7.4}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  58. {riplex-0.7.2 → riplex-0.7.4}/screenshots/4_Disc_Release_BTTF.png +0 -0
  59. {riplex-0.7.2 → riplex-0.7.4}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  60. {riplex-0.7.2 → riplex-0.7.4}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  61. {riplex-0.7.2 → riplex-0.7.4}/setup.cfg +0 -0
  62. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/__init__.py +0 -0
  63. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/cache.py +0 -0
  64. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/config.py +0 -0
  65. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/dedup.py +0 -0
  66. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/disc/__init__.py +0 -0
  67. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/disc/analysis.py +0 -0
  68. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/disc/provider.py +0 -0
  69. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/formatter.py +0 -0
  70. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/manifest.py +0 -0
  71. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/matcher.py +0 -0
  72. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/metadata/__init__.py +0 -0
  73. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/metadata/provider.py +0 -0
  74. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/metadata/sources/__init__.py +0 -0
  75. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/metadata/sources/tmdb.py +0 -0
  76. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/normalize.py +0 -0
  77. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/organizer.py +0 -0
  78. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/scanner.py +0 -0
  79. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/snapshot.py +0 -0
  80. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/splitter.py +0 -0
  81. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/tagger.py +0 -0
  82. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/ui.py +0 -0
  83. {riplex-0.7.2 → riplex-0.7.4}/src/riplex/updater.py +0 -0
  84. {riplex-0.7.2 → riplex-0.7.4}/src/riplex.egg-info/dependency_links.txt +0 -0
  85. {riplex-0.7.2 → riplex-0.7.4}/src/riplex.egg-info/entry_points.txt +0 -0
  86. {riplex-0.7.2 → riplex-0.7.4}/src/riplex.egg-info/requires.txt +0 -0
  87. {riplex-0.7.2 → riplex-0.7.4}/src/riplex.egg-info/top_level.txt +0 -0
  88. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/__init__.py +0 -0
  89. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/bug_report.py +0 -0
  90. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/crash_dump.py +0 -0
  91. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/keep_awake.py +0 -0
  92. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/main.py +0 -0
  93. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/__init__.py +0 -0
  94. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/disc_overview.py +0 -0
  95. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/disc_swap.py +0 -0
  96. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/done.py +0 -0
  97. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/metadata.py +0 -0
  98. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/orchestrate_done.py +0 -0
  99. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/organize_done.py +0 -0
  100. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/progress.py +0 -0
  101. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/selection.py +0 -0
  102. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/update.py +0 -0
  103. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_app/screens/welcome.py +0 -0
  104. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/__init__.py +0 -0
  105. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/commands/__init__.py +0 -0
  106. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/commands/lookup.py +0 -0
  107. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/commands/setup.py +0 -0
  108. {riplex-0.7.2 → riplex-0.7.4}/src/riplex_cli/formatting.py +0 -0
  109. {riplex-0.7.2 → riplex-0.7.4}/tests/__init__.py +0 -0
  110. {riplex-0.7.2 → riplex-0.7.4}/tests/fixtures/chernobyl_disc1.json +0 -0
  111. {riplex-0.7.2 → riplex-0.7.4}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  112. {riplex-0.7.2 → riplex-0.7.4}/tests/fixtures/makemkvcon_list.txt +0 -0
  113. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  114. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  115. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  116. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  117. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  118. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  119. {riplex-0.7.2 → riplex-0.7.4}/tests/snapshots/Waterworld.snapshot.json +0 -0
  120. {riplex-0.7.2 → riplex-0.7.4}/tests/test_cache.py +0 -0
  121. {riplex-0.7.2 → riplex-0.7.4}/tests/test_config.py +0 -0
  122. {riplex-0.7.2 → riplex-0.7.4}/tests/test_dedup.py +0 -0
  123. {riplex-0.7.2 → riplex-0.7.4}/tests/test_disc_analysis.py +0 -0
  124. {riplex-0.7.2 → riplex-0.7.4}/tests/test_disc_detection_screen.py +0 -0
  125. {riplex-0.7.2 → riplex-0.7.4}/tests/test_disc_fixtures.py +0 -0
  126. {riplex-0.7.2 → riplex-0.7.4}/tests/test_disc_provider.py +0 -0
  127. {riplex-0.7.2 → riplex-0.7.4}/tests/test_formatter.py +0 -0
  128. {riplex-0.7.2 → riplex-0.7.4}/tests/test_matcher.py +0 -0
  129. {riplex-0.7.2 → riplex-0.7.4}/tests/test_normalize.py +0 -0
  130. {riplex-0.7.2 → riplex-0.7.4}/tests/test_organizer.py +0 -0
  131. {riplex-0.7.2 → riplex-0.7.4}/tests/test_rip_guide.py +0 -0
  132. {riplex-0.7.2 → riplex-0.7.4}/tests/test_scanner.py +0 -0
  133. {riplex-0.7.2 → riplex-0.7.4}/tests/test_snapshot.py +0 -0
  134. {riplex-0.7.2 → riplex-0.7.4}/tests/test_splitter.py +0 -0
  135. {riplex-0.7.2 → riplex-0.7.4}/tests/test_tagger.py +0 -0
  136. {riplex-0.7.2 → riplex-0.7.4}/tests/test_ui.py +0 -0
  137. {riplex-0.7.2 → riplex-0.7.4}/tests/test_updater.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.7.2
3
+ Version: 0.7.4
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,18 @@ 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.4 — 2026-06-10
8
+
9
+ ### Fixed
10
+
11
+ - **GUI: empty Disc Overview after continuing without dvdcompare data.** When dvdcompare had no matching release, the orchestrate flow still navigated to the multi-disc overview, which only renders dvdcompare-provided disc rows. The no-dvdcompare fallback now treats the inserted disc as a single-disc rip and jumps directly to title selection, where TMDb runtime heuristics can pick the main feature.
12
+
13
+ ## v0.7.3 — 2026-06-10
14
+
15
+ ### Fixed
16
+
17
+ - **GUI: silent "No optical drives detected" when MakeMKV is expired or unregistered.** When `makemkvcon` rejects requests with a fatal MSG (codes `5021` too-old, `5022` key-expired, `5023` key-invalid) it exits cleanly with zero `DRV:` lines, so riplex previously rendered an empty drive list. The shared library now parses these fatal MSGs and raises `MakeMKVError`; the Disc Detection screen surfaces the verbatim `makemkvcon` message along with **Download MakeMKV ↗** and **Get beta key ↗** buttons so users can resolve the lockout in one click.
18
+
7
19
  ## v0.7.2 — 2026-05-17
8
20
 
9
21
  ### Fixed
@@ -0,0 +1,106 @@
1
+ # GUI: manual rip and manual organize escape hatches
2
+
3
+ ## Problem
4
+
5
+ When riplex can't classify a disc — no dvdcompare match, ambiguous TMDb
6
+ result, multi-feature compilation, region-coded oddity, etc. — the GUI flow
7
+ dead-ends at the title-selection screen. The user has to leave riplex-ui,
8
+ launch the MakeMKV GUI to rip the disc directly, and then either keep those
9
+ files unorganized or manually rename and move them into Plex layout.
10
+
11
+ We hit this in practice with a BBC Earth Blu-ray containing two unrelated
12
+ features (*Wild Africa* + *Tiny Giants*). The disc isn't on dvdcompare, TMDb
13
+ has only a combined "double feature" entry, and riplex's planner assumes one
14
+ disc → one TMDb item, so the rip flow couldn't proceed at all.
15
+
16
+ ## Why we don't want to model compilations directly
17
+
18
+ Compilation discs that contain multiple unrelated films are rare enough that
19
+ building first-class support (per-title TMDb matching, per-title disc
20
+ metadata, multi-target organize) isn't justified. A generic escape hatch is
21
+ cheaper and covers other niche scenarios as a side benefit (foreign releases
22
+ without metadata, damaged-disc recovery, special workshop discs, etc.).
23
+
24
+ ## Proposed UX
25
+
26
+ Two new entry points in the GUI, both intentionally generic:
27
+
28
+ ### 1. "Manual rip" button on the title-selection screen
29
+
30
+ When riplex can't classify titles confidently (no dvdcompare match **and**
31
+ TMDb selection is ambiguous, or user just wants raw control), surface a
32
+ "Manual rip" button alongside the existing "Rip selected" button.
33
+
34
+ Clicking it drops the user into a flat MakeMKV-style title list:
35
+
36
+ - All titles, sorted by index, with duration / size / chapter count
37
+ - Checkbox per title (default: all titles longer than 2 minutes)
38
+ - Output folder picker (defaults to `<output_root>/_MakeMKV/<disc_label>/`)
39
+ - "Rip" button → calls `makemkvcon mkv all` (or per-title) and shows the
40
+ existing progress UI
41
+
42
+ No metadata lookup, no organize, no Plex naming. The user lands in the "Done"
43
+ screen with a folder of raw MKVs, with a follow-up button: "Organize this
44
+ folder now → opens the manual organize screen pre-pointed at that path."
45
+
46
+ ### 2. "Organize existing folder" entry on the welcome screen
47
+
48
+ A second card on welcome (next to "Rip a disc") titled "Organize existing
49
+ files." Picks up an arbitrary folder of MKVs and walks the user through:
50
+
51
+ 1. **Folder picker** → list MKVs with sizes and ffprobe runtimes.
52
+ 2. **TMDb search** (reuses existing `metadata` screen) → select the canonical
53
+ entry.
54
+ 3. **File assignment** → for each MKV, drop-down: `Main feature`, `Part 1
55
+ (-pt1)`, `Part 2 (-pt2)`, `Extra → behindthescenes`, `Extra → featurette`,
56
+ `Extra → trailer`, `Skip`. Auto-suggest based on duration.
57
+ 4. **Plan preview** → show source → destination paths.
58
+ 5. **Apply** → move + rename into Plex layout, with the existing dry-run
59
+ default and `Execute` toggle.
60
+
61
+ This is essentially the existing `riplex organize` CLI behavior with a GUI in
62
+ front of it. No new business logic — just a thin wrapper that also handles
63
+ the "two parts of one TMDb movie" case via the assignment step.
64
+
65
+ ## Why this works for the BBC Earth case
66
+
67
+ End-to-end flow stays inside riplex-ui:
68
+
69
+ 1. Insert disc → riplex tries to classify, fails to find dvdcompare match.
70
+ 2. User clicks **Manual rip**, picks the two main titles, hits Rip.
71
+ 3. Done screen → click **Organize this folder**.
72
+ 4. TMDb search → "Wild Africa Tiny Giants" → pick `tmdb-1657586`.
73
+ 5. Assign t00 → pt1, t01 → pt2.
74
+ 6. Apply → files land in `Movies\Wild Africa - Tiny Giants (2016)\`.
75
+
76
+ No external tool, no manual `Move-Item`.
77
+
78
+ ## Tradeoffs
79
+
80
+ - **Pro**: covers any disc riplex can't classify, not just compilations.
81
+ - **Pro**: reuses existing rip + organize plumbing; mostly UI work.
82
+ - **Con**: more GUI surface (two screens, file-assignment widget).
83
+ - **Con**: risk of users defaulting to manual mode and bypassing the smart
84
+ classification we built. Mitigation: gate the manual-rip button behind a
85
+ "we couldn't classify this disc" trigger or an "Advanced" reveal, so it's
86
+ not a peer to the normal "Rip selected" button on every disc.
87
+
88
+ ## Out of scope
89
+
90
+ - Per-title TMDb matching (full compilation support).
91
+ - Multi-target organize where one folder of MKVs maps to N different TMDb
92
+ entries. Manual mode handles this by running the organize wizard once per
93
+ target with file selection.
94
+ - Saving manual-mode decisions for repeat use (cache).
95
+
96
+ ## Files likely involved
97
+
98
+ - `src/riplex_app/screens/selection.py` — add "Manual rip" button + branch.
99
+ - `src/riplex_app/screens/manual_rip.py` (new) — flat title list + ripper.
100
+ - `src/riplex_app/screens/welcome.py` — add "Organize existing files" card.
101
+ - `src/riplex_app/screens/manual_organize.py` (new) — folder picker → TMDb →
102
+ assignment → plan preview → apply.
103
+ - `src/riplex/organizer.py` — already has the path-building logic; may need
104
+ a small helper for the assignment data structure.
105
+ - `src/riplex_cli/commands/organize.py` — sanity-check the CLI still works
106
+ after any planner refactor.
@@ -10,7 +10,9 @@ import logging
10
10
  import re
11
11
  from dataclasses import dataclass, field
12
12
  from pathlib import Path
13
- from typing import TYPE_CHECKING
13
+ from typing import TYPE_CHECKING, Literal
14
+
15
+ from riplex.title import parse_season_number, parse_title_and_season, strip_year_from_title
14
16
 
15
17
  if TYPE_CHECKING:
16
18
  from riplex.models import ScannedDisc, ScannedFile
@@ -34,9 +36,18 @@ class TitleGroup:
34
36
 
35
37
  title: str
36
38
  folders: list[Path] = field(default_factory=list)
39
+ season_number: int | None = None
37
40
  detected_format: str | None = None
38
41
 
39
42
 
43
+ @dataclass
44
+ class OrganizeLayout:
45
+ """How an organize root should be processed."""
46
+
47
+ mode: Literal["single", "batch", "empty"]
48
+ groups: list[TitleGroup] = field(default_factory=list)
49
+
50
+
40
51
  def detect_format(discs: list[ScannedDisc]) -> str | None:
41
52
  """Infer disc format from the maximum video resolution across all files.
42
53
 
@@ -124,7 +135,8 @@ def group_title_folders(root: Path) -> list[TitleGroup]:
124
135
  Ignores folders starting with ``_`` (e.g. ``_archive``).
125
136
  Returns groups sorted by title, each containing the original folder paths.
126
137
  """
127
- groups: dict[str, list[Path]] = {}
138
+ groups: dict[tuple[str, int | None], list[Path]] = {}
139
+ root_title, _ = strip_year_from_title(root.name)
128
140
 
129
141
  for sub in sorted(root.iterdir()):
130
142
  if not sub.is_dir():
@@ -142,14 +154,38 @@ def group_title_folders(root: Path) -> list[TitleGroup]:
142
154
  continue
143
155
 
144
156
  base = _normalize_title(sub.name)
145
- groups.setdefault(base, []).append(sub)
157
+ title, season_number = parse_title_and_season(base)
158
+ if title is None and season_number is not None:
159
+ title = root_title
160
+ if title is None:
161
+ title = base
162
+ season_number = parse_season_number(base)
163
+ groups.setdefault((title, season_number), []).append(sub)
146
164
 
147
165
  result: list[TitleGroup] = []
148
- for title in sorted(groups):
149
- result.append(TitleGroup(title=title, folders=groups[title]))
166
+ for title, season_number in sorted(groups):
167
+ result.append(TitleGroup(title=title, folders=groups[(title, season_number)], season_number=season_number))
150
168
  return result
151
169
 
152
170
 
171
+ def detect_organize_layout(root: Path) -> OrganizeLayout:
172
+ """Classify an organize root as single-title, batch, or empty.
173
+
174
+ Single mode covers a flat folder of MKVs or a folder whose immediate
175
+ subfolders are disc folders. Batch mode covers roots that contain nested
176
+ season/title folders such as ``Show/Season 6/Disc 1``.
177
+ """
178
+ has_root_mkvs = any(root.glob("*.mkv"))
179
+ has_sub_mkvs = any(root.glob("*/*.mkv"))
180
+ has_nested_mkvs = any(root.glob("*/*/*.mkv"))
181
+
182
+ if has_root_mkvs or (has_sub_mkvs and not has_nested_mkvs):
183
+ return OrganizeLayout(mode="single")
184
+ if has_sub_mkvs or has_nested_mkvs:
185
+ return OrganizeLayout(mode="batch", groups=group_title_folders(root))
186
+ return OrganizeLayout(mode="empty")
187
+
188
+
153
189
  def infer_media_type(disc_info) -> str:
154
190
  """Infer 'movie' or 'tv' from disc title structure.
155
191
 
@@ -114,6 +114,15 @@ class MakeMKVPreflight:
114
114
  error: str = "" # short human-readable failure reason
115
115
 
116
116
 
117
+ class MakeMKVError(RuntimeError):
118
+ """makemkvcon ran but refused to do useful work (e.g. expired beta key)."""
119
+
120
+ def __init__(self, message: str, *, code: int | None = None, raw: str = ""):
121
+ super().__init__(message)
122
+ self.code = code
123
+ self.raw = raw
124
+
125
+
117
126
  @dataclass
118
127
  class DiscInfo:
119
128
  """Parsed disc information from makemkvcon -r info."""
@@ -276,6 +285,43 @@ def parse_drive_list(output: str) -> list[DriveInfo]:
276
285
  return drives
277
286
 
278
287
 
288
+ # MSG codes makemkvcon emits when it refuses to do real work. The user-facing
289
+ # message is the first quoted field. Anything in this set should be surfaced
290
+ # verbatim to the user with an actionable hint, since the rest of the run
291
+ # will silently produce empty results otherwise (no DRV lines, no titles).
292
+ _MAKEMKV_FATAL_MSG_CODES: frozenset[int] = frozenset({
293
+ 5021, # "This application version is too old..." (expired beta key)
294
+ 5022, # registration key expired
295
+ 5023, # registration key invalid
296
+ })
297
+
298
+
299
+ def parse_fatal_message(output: str) -> tuple[int, str] | None:
300
+ """Return ``(code, message)`` for the first known fatal MSG line, else None.
301
+
302
+ makemkvcon emits ``MSG:<code>,<flags>,<count>,"<text>",...`` lines.
303
+ When it refuses to scan (expired beta, invalid key, etc.) it prints
304
+ one of those and then exits without any ``DRV:`` lines, which makes
305
+ ``parse_drive_list`` legitimately return ``[]``. Callers can use
306
+ this helper to distinguish "no drives" from "makemkvcon won't run".
307
+ """
308
+ for line in output.splitlines():
309
+ if not line.startswith("MSG:"):
310
+ continue
311
+ parts = _split_robot_line(line[4:])
312
+ if len(parts) < 4:
313
+ continue
314
+ try:
315
+ code = int(parts[0])
316
+ except ValueError:
317
+ continue
318
+ if code not in _MAKEMKV_FATAL_MSG_CODES:
319
+ continue
320
+ # parts[3] is the rendered, human-readable message text.
321
+ return code, parts[3]
322
+ return None
323
+
324
+
279
325
  def _split_robot_line(text: str) -> list[str]:
280
326
  """Split a makemkvcon robot-mode CSV line, respecting quoted strings."""
281
327
  parts: list[str] = []
@@ -415,7 +461,12 @@ class MakeMKV:
415
461
  return parse_disc_info(output)
416
462
 
417
463
  def drive_list(self) -> list[DriveInfo]:
418
- """List available optical drives."""
464
+ """List available optical drives.
465
+
466
+ Raises :class:`MakeMKVError` if makemkvcon ran but returned no
467
+ drive lines because of a known fatal condition (most commonly
468
+ an expired beta version that refuses to scan until updated).
469
+ """
419
470
  exe = self._require_exe()
420
471
 
421
472
  cmd = [str(exe), "-r", "info", "list"]
@@ -429,7 +480,13 @@ class MakeMKV:
429
480
  **_SUBPROCESS_FLAGS,
430
481
  )
431
482
  output = result.stdout + result.stderr
432
- return parse_drive_list(output)
483
+ drives = parse_drive_list(output)
484
+ if not drives:
485
+ fatal = parse_fatal_message(output)
486
+ if fatal is not None:
487
+ code, message = fatal
488
+ raise MakeMKVError(message, code=code, raw=output)
489
+ return drives
433
490
 
434
491
  def resolve_drive(self, drive_arg: str = "auto") -> DriveInfo:
435
492
  """Resolve a drive argument to a :class:`DriveInfo`.
@@ -63,8 +63,11 @@ async def lookup_metadata(
63
63
 
64
64
  if not skip_dvdcompare:
65
65
  try:
66
+ dvdcompare_title = canonical
67
+ if not is_movie and request.season_number is not None:
68
+ dvdcompare_title = f"{canonical}: Season {request.season_number}"
66
69
  discs, release_name = await fetch_and_select_release(
67
- canonical,
70
+ dvdcompare_title,
68
71
  disc_format=disc_format,
69
72
  disc_info=disc_info,
70
73
  preferred=preferred_release,
@@ -139,6 +139,8 @@ async def _plan_show(
139
139
 
140
140
  seasons: list[PlannedSeason] = []
141
141
  for sm in detail.seasons:
142
+ if request.season_number is not None and sm.season_number != request.season_number:
143
+ continue
142
144
  episodes: list[PlannedEpisode] = []
143
145
  for em in sm.episodes:
144
146
  fname = episode_file_name(
@@ -12,6 +12,7 @@ class SearchRequest:
12
12
 
13
13
  title: str
14
14
  year: int | None = None
15
+ season_number: int | None = None
15
16
  media_type: Literal["movie", "tv", "auto"] = "auto"
16
17
  include_specials: bool = True
17
18
  include_extras_skeleton: bool = True
@@ -15,6 +15,14 @@ _TRAILING_SEASON_DISC_RE = re.compile(
15
15
  r"\s*[-_]?\s*S(?:eason|t)?\s*\d+[\s_-]*(?:BD|B(?:lu[-_ ]*ray)|D(?:isc)?)[\s_-]*\d+\s*$",
16
16
  re.IGNORECASE,
17
17
  )
18
+ _SEASON_NUMBER_RE = re.compile(
19
+ r"(?:^|[\s_-])(?:Season|S|St)\s*0*(\d{1,2})(?=$|[\s_-])",
20
+ re.IGNORECASE,
21
+ )
22
+ _SEASON_TOKEN_RE = re.compile(
23
+ r"(?:^|[\s_-])(?:Season|S|St)\s*0*(\d{1,2})(?=$|[\s_-])",
24
+ re.IGNORECASE,
25
+ )
18
26
 
19
27
 
20
28
  def strip_year_from_title(name: str) -> tuple[str, int | None]:
@@ -81,3 +89,28 @@ def parse_volume_label(label: str) -> str | None:
81
89
  else:
82
90
  result.append(w.capitalize())
83
91
  return " ".join(result)
92
+
93
+
94
+ def parse_season_number(label: str) -> int | None:
95
+ """Extract a season number from a folder or label string."""
96
+ if not label:
97
+ return None
98
+ match = _SEASON_NUMBER_RE.search(label)
99
+ if not match:
100
+ return None
101
+ try:
102
+ return int(match.group(1))
103
+ except ValueError:
104
+ return None
105
+
106
+
107
+ def parse_title_and_season(label: str) -> tuple[str | None, int | None]:
108
+ """Extract a base title and optional season number from a label string."""
109
+ if not label or len(label) < 2:
110
+ return None, None
111
+
112
+ season_number = parse_season_number(label)
113
+ cleaned = _TRAILING_SEASON_DISC_RE.sub("", label)
114
+ cleaned = _SEASON_TOKEN_RE.sub(" ", cleaned)
115
+ title = parse_volume_label(cleaned)
116
+ return title, season_number
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.7.2
3
+ Version: 0.7.4
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
@@ -25,6 +25,7 @@ docs/getting-started/installation.md
25
25
  docs/gui-guide/gui-walkthrough.md
26
26
  docs/reference/cli.md
27
27
  issues/debug-artifacts-consolidation.md
28
+ issues/manual-rip-and-organize-escape-hatch.md
28
29
  issues/orchestrate-dvdcompare-fallback.md
29
30
  issues/planned-features.md
30
31
  screenshots/0_Rip_Flow_BTTF.gif
@@ -108,11 +109,13 @@ tests/test_disc_detection_screen.py
108
109
  tests/test_disc_fixtures.py
109
110
  tests/test_disc_provider.py
110
111
  tests/test_formatter.py
112
+ tests/test_lookup.py
111
113
  tests/test_makemkv.py
112
114
  tests/test_matcher.py
113
115
  tests/test_normalize.py
114
116
  tests/test_organizer.py
115
117
  tests/test_planner.py
118
+ tests/test_release_screen.py
116
119
  tests/test_rip_guide.py
117
120
  tests/test_scanner.py
118
121
  tests/test_snapshot.py
@@ -26,11 +26,12 @@ import flet as ft
26
26
  from riplex.disc.makemkv import (
27
27
  DriveInfo,
28
28
  MakeMKV,
29
+ MakeMKVError,
29
30
  MakeMKVPreflight,
30
31
  makemkv_preflight,
31
32
  run_disc_info,
32
33
  )
33
- from riplex.title import parse_volume_label
34
+ from riplex.title import parse_title_and_season, parse_volume_label
34
35
 
35
36
  log = logging.getLogger(__name__)
36
37
 
@@ -278,6 +279,19 @@ class DiscDetectionScreen:
278
279
  try:
279
280
  mk = MakeMKV(self.app.state.get("makemkvcon"))
280
281
  drives = mk.drive_list()
282
+ except MakeMKVError as exc:
283
+ log.warning("makemkvcon refused to list drives (code %s): %s", exc.code, exc)
284
+ self._show_error(
285
+ "MakeMKV won\u2019t scan drives",
286
+ f"makemkvcon reported: {exc}\n\n"
287
+ "MakeMKV must be updated (or a valid registration key entered) "
288
+ "before riplex can detect discs. The free beta key is refreshed "
289
+ "monthly on the MakeMKV forum.",
290
+ allow_retry=True,
291
+ install_hint=True,
292
+ key_hint=True,
293
+ )
294
+ return
281
295
  except Exception as exc:
282
296
  log.warning("drive_list failed: %s", exc)
283
297
  if initial:
@@ -439,7 +453,12 @@ class DiscDetectionScreen:
439
453
  return
440
454
 
441
455
  self.app.state["disc_info"] = disc_info
442
- self.app.state["title"] = self._parse_volume_label(drive.disc_label)
456
+ parsed_title, parsed_season = parse_title_and_season(drive.disc_label)
457
+ self.app.state["title"] = parsed_title or self._parse_volume_label(drive.disc_label)
458
+ if parsed_season is not None:
459
+ self.app.state["season_number"] = parsed_season
460
+ else:
461
+ self.app.state.pop("season_number", None)
443
462
  self.app.state["_disc_read_done"] = True
444
463
 
445
464
  async def _nav():
@@ -486,12 +505,19 @@ class DiscDetectionScreen:
486
505
  detail: str,
487
506
  *,
488
507
  allow_retry: bool = True,
508
+ install_hint: bool = False,
509
+ key_hint: bool = False,
489
510
  ) -> None:
490
511
  self.spinner.visible = False
491
512
  self.status_text.value = title
492
513
  self.status_text.color = ft.Colors.RED
514
+ self.drive_panel.controls.clear()
493
515
  self.error_panel.content = self._build_error_panel(
494
- title, detail, allow_retry=allow_retry, install_hint=False,
516
+ title,
517
+ detail,
518
+ allow_retry=allow_retry,
519
+ install_hint=install_hint,
520
+ key_hint=key_hint,
495
521
  )
496
522
  self.error_panel.visible = True
497
523
  _safe_update(self.app.page)
@@ -503,6 +529,7 @@ class DiscDetectionScreen:
503
529
  *,
504
530
  allow_retry: bool,
505
531
  install_hint: bool,
532
+ key_hint: bool = False,
506
533
  ) -> ft.Control:
507
534
  children: list[ft.Control] = [
508
535
  ft.Row(
@@ -535,6 +562,13 @@ class DiscDetectionScreen:
535
562
  url="https://www.makemkv.com/download/",
536
563
  )
537
564
  )
565
+ if key_hint:
566
+ actions.append(
567
+ ft.TextButton(
568
+ "Get beta key \u2197",
569
+ url="https://forum.makemkv.com/forum/viewtopic.php?t=1053",
570
+ )
571
+ )
538
572
  actions.append(
539
573
  ft.OutlinedButton(
540
574
  "Open bug report",
@@ -10,11 +10,11 @@ import flet as ft
10
10
  log = logging.getLogger(__name__)
11
11
 
12
12
  from riplex.config import get_rip_output
13
- from riplex.detect import detect_format
13
+ from riplex.detect import TitleGroup, detect_format, detect_organize_layout
14
14
  from riplex.manifest import build_scanned_from_manifests
15
15
  from riplex.scanner import scan_folder
16
16
  from riplex.snapshot import load_organized_marker
17
- from riplex.title import infer_title_from_scanned
17
+ from riplex.title import infer_title_from_scanned, parse_season_number
18
18
 
19
19
 
20
20
  class FolderPickerScreen:
@@ -22,6 +22,10 @@ class FolderPickerScreen:
22
22
  self.app = app
23
23
 
24
24
  def build(self) -> ft.Control:
25
+ batch_groups = self.app.state.pop("_batch_groups", None)
26
+ if batch_groups is not None:
27
+ return self._build_batch_groups_view(batch_groups)
28
+
25
29
  # Check for scan results from background thread
26
30
  scan_result = self.app.state.pop("_scan_result", None)
27
31
  if scan_result is not None:
@@ -128,6 +132,16 @@ class FolderPickerScreen:
128
132
  folder = Path(path)
129
133
  self.app.state["source_folder"] = folder
130
134
 
135
+ layout = detect_organize_layout(folder)
136
+ if layout.mode == "batch":
137
+ self.app.state["_batch_groups"] = layout.groups
138
+ self.app.navigate("folder_picker")
139
+ return
140
+ if layout.mode == "empty":
141
+ self.folder_field.error_text = "No MKV files found in that folder."
142
+ self.folder_field.update()
143
+ return
144
+
131
145
  # Fast path: if every disc subfolder has a _rip_manifest.json,
132
146
  # load metadata from the manifest instead of running ffprobe.
133
147
  if self._has_complete_manifests(folder):
@@ -231,6 +245,85 @@ class FolderPickerScreen:
231
245
 
232
246
  self.app.page.run_task(_nav)
233
247
 
248
+ def _build_batch_groups_view(self, groups: list[TitleGroup]) -> ft.Control:
249
+ rows: list[ft.Control] = []
250
+ for group in groups:
251
+ label = group.title
252
+ if group.season_number is not None:
253
+ label = f"{label} Season {group.season_number}"
254
+ folder_list = ", ".join(folder.name for folder in group.folders)
255
+ rows.append(
256
+ ft.Container(
257
+ content=ft.Column(
258
+ [
259
+ ft.Text(label, size=14, weight=ft.FontWeight.BOLD),
260
+ ft.Text(folder_list, size=12, color=ft.Colors.GREY_500),
261
+ ft.Row(
262
+ [
263
+ ft.ElevatedButton(
264
+ "Open",
265
+ icon=ft.Icons.ARROW_FORWARD,
266
+ on_click=lambda _, g=group: self._open_group(g),
267
+ )
268
+ ]
269
+ ),
270
+ ],
271
+ spacing=6,
272
+ ),
273
+ padding=12,
274
+ border=ft.Border.all(1, ft.Colors.GREY_800),
275
+ border_radius=8,
276
+ )
277
+ )
278
+
279
+ return ft.Column(
280
+ [
281
+ ft.Text("Organize Rips", size=24, weight=ft.FontWeight.BOLD),
282
+ ft.Text(
283
+ "This folder contains multiple organize groups. Pick the one you want to process.",
284
+ size=13,
285
+ color=ft.Colors.GREY_500,
286
+ ),
287
+ ft.Divider(height=20),
288
+ ft.Column(rows, spacing=10, scroll=ft.ScrollMode.AUTO),
289
+ ft.Container(expand=True),
290
+ ft.TextButton("Back", on_click=lambda _: self.app.navigate("welcome")),
291
+ ],
292
+ spacing=10,
293
+ expand=True,
294
+ )
295
+
296
+ def _open_group(self, group: TitleGroup) -> None:
297
+ if len(group.folders) != 1:
298
+ # Multi-folder groups still need a shared library batch scanner.
299
+ self.app.state["_scan_error"] = (
300
+ "This organize group spans multiple folders. Pick a season folder directly for now."
301
+ )
302
+ self.app.navigate("folder_picker")
303
+ return
304
+ folder = group.folders[0]
305
+ self.app.state["source_folder"] = folder
306
+ self.app.state["title"] = group.title
307
+ if group.season_number is not None:
308
+ self.app.state["season_number"] = group.season_number
309
+ else:
310
+ self.app.state.pop("season_number", None)
311
+
312
+ if self._has_complete_manifests(folder):
313
+ try:
314
+ scanned = build_scanned_from_manifests(folder)
315
+ except Exception:
316
+ log.exception("manifest load failed; falling back to ffprobe")
317
+ scanned = None
318
+ if scanned:
319
+ self.app.state["_scan_from_manifest"] = True
320
+ self.app.state["_scan_result"] = scanned
321
+ self.app.navigate("folder_picker")
322
+ return
323
+
324
+ self.app.state["_scan_from_manifest"] = False
325
+ self._start_ffprobe_scan(folder)
326
+
234
327
  def _build_results_view(self, scanned) -> ft.Control:
235
328
  """Show scan results and let user confirm/edit title."""
236
329
  self.app.state["scanned"] = scanned
@@ -307,6 +400,13 @@ class FolderPickerScreen:
307
400
  value=inferred,
308
401
  width=500,
309
402
  )
403
+ inferred_season = parse_season_number(self.app.state["source_folder"].name)
404
+ self.season_field = ft.TextField(
405
+ label="Season number (TV only, optional)",
406
+ value=str(inferred_season) if inferred_season is not None else "",
407
+ width=240,
408
+ hint_text="e.g. 6",
409
+ )
310
410
 
311
411
  return ft.Column(
312
412
  [
@@ -330,6 +430,8 @@ class FolderPickerScreen:
330
430
  ft.Container(height=10),
331
431
  ft.Text("Detected title:", size=14),
332
432
  self.title_field,
433
+ ft.Text("Season override:", size=14),
434
+ self.season_field,
333
435
  ft.Container(expand=True),
334
436
  ft.Row([
335
437
  ft.TextButton("Back", on_click=lambda _: self.app.navigate("welcome")),
@@ -367,5 +469,15 @@ class FolderPickerScreen:
367
469
  self.title_field.error_text = "Title is required."
368
470
  self.title_field.update()
369
471
  return
472
+ season_text = self.season_field.value.strip() if self.season_field.value else ""
473
+ if season_text:
474
+ if not season_text.isdigit() or int(season_text) < 0:
475
+ self.season_field.error_text = "Enter a valid season number."
476
+ self.season_field.update()
477
+ return
478
+ self.app.state["season_number"] = int(season_text)
479
+ self.season_field.error_text = None
480
+ else:
481
+ self.app.state.pop("season_number", None)
370
482
  self.app.state["title"] = title
371
483
  self.app.navigate("metadata")
@@ -91,6 +91,7 @@ class OrganizePreviewScreen:
91
91
  request = SearchRequest(
92
92
  title=tmdb_match.title,
93
93
  year=tmdb_match.year,
94
+ season_number=self.app.state.get("season_number"),
94
95
  media_type=tmdb_match.media_type,
95
96
  )
96
97
  if tmdb_match.media_type == "movie":