riplex 0.7.2__tar.gz → 0.7.3__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 (135) hide show
  1. {riplex-0.7.2/src/riplex.egg-info → riplex-0.7.3}/PKG-INFO +1 -1
  2. {riplex-0.7.2 → riplex-0.7.3}/docs/changelog.md +6 -0
  3. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/detect.py +41 -5
  4. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/makemkv.py +59 -2
  5. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/lookup.py +4 -1
  6. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/planner.py +2 -0
  7. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/models.py +1 -0
  8. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/title.py +33 -0
  9. {riplex-0.7.2 → riplex-0.7.3/src/riplex.egg-info}/PKG-INFO +1 -1
  10. {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/SOURCES.txt +1 -0
  11. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/disc_detection.py +37 -3
  12. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/folder_picker.py +114 -2
  13. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/organize_preview.py +1 -0
  14. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/release.py +3 -0
  15. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/orchestrate.py +5 -2
  16. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/organize.py +28 -7
  17. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/rip.py +5 -2
  18. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/main.py +18 -0
  19. {riplex-0.7.2 → riplex-0.7.3}/tests/test_cli_utils.py +29 -1
  20. {riplex-0.7.2 → riplex-0.7.3}/tests/test_detect.py +51 -0
  21. riplex-0.7.3/tests/test_lookup.py +48 -0
  22. {riplex-0.7.2 → riplex-0.7.3}/tests/test_makemkv.py +77 -0
  23. {riplex-0.7.2 → riplex-0.7.3}/tests/test_planner.py +53 -0
  24. {riplex-0.7.2 → riplex-0.7.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  25. {riplex-0.7.2 → riplex-0.7.3}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  26. {riplex-0.7.2 → riplex-0.7.3}/.github/agents/riplex.agent.md +0 -0
  27. {riplex-0.7.2 → riplex-0.7.3}/.github/copilot-instructions.md +0 -0
  28. {riplex-0.7.2 → riplex-0.7.3}/.github/workflows/publish.yml +0 -0
  29. {riplex-0.7.2 → riplex-0.7.3}/.github/workflows/release.yml +0 -0
  30. {riplex-0.7.2 → riplex-0.7.3}/.gitignore +0 -0
  31. {riplex-0.7.2 → riplex-0.7.3}/.vscode/settings.json +0 -0
  32. {riplex-0.7.2 → riplex-0.7.3}/CONTRIBUTORS.md +0 -0
  33. {riplex-0.7.2 → riplex-0.7.3}/LICENSE +0 -0
  34. {riplex-0.7.2 → riplex-0.7.3}/README.md +0 -0
  35. {riplex-0.7.2 → riplex-0.7.3}/docs/architecture.md +0 -0
  36. {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/lookup.md +0 -0
  37. {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/orchestrate.md +0 -0
  38. {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/organize.md +0 -0
  39. {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/workflow.md +0 -0
  40. {riplex-0.7.2 → riplex-0.7.3}/docs/getting-started/configuration.md +0 -0
  41. {riplex-0.7.2 → riplex-0.7.3}/docs/getting-started/installation.md +0 -0
  42. {riplex-0.7.2 → riplex-0.7.3}/docs/gui-guide/gui-walkthrough.md +0 -0
  43. {riplex-0.7.2 → riplex-0.7.3}/docs/index.md +0 -0
  44. {riplex-0.7.2 → riplex-0.7.3}/docs/naming-rules.md +0 -0
  45. {riplex-0.7.2 → riplex-0.7.3}/docs/reference/cli.md +0 -0
  46. {riplex-0.7.2 → riplex-0.7.3}/docs/troubleshooting.md +0 -0
  47. {riplex-0.7.2 → riplex-0.7.3}/issues/debug-artifacts-consolidation.md +0 -0
  48. {riplex-0.7.2 → riplex-0.7.3}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  49. {riplex-0.7.2 → riplex-0.7.3}/issues/planned-features.md +0 -0
  50. {riplex-0.7.2 → riplex-0.7.3}/mkdocs.yml +0 -0
  51. {riplex-0.7.2 → riplex-0.7.3}/pyproject.toml +0 -0
  52. {riplex-0.7.2 → riplex-0.7.3}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  53. {riplex-0.7.2 → riplex-0.7.3}/screenshots/1_Welcome_Screen.png +0 -0
  54. {riplex-0.7.2 → riplex-0.7.3}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  55. {riplex-0.7.2 → riplex-0.7.3}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  56. {riplex-0.7.2 → riplex-0.7.3}/screenshots/4_Disc_Release_BTTF.png +0 -0
  57. {riplex-0.7.2 → riplex-0.7.3}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  58. {riplex-0.7.2 → riplex-0.7.3}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  59. {riplex-0.7.2 → riplex-0.7.3}/setup.cfg +0 -0
  60. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/__init__.py +0 -0
  61. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/cache.py +0 -0
  62. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/config.py +0 -0
  63. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/dedup.py +0 -0
  64. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/__init__.py +0 -0
  65. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/analysis.py +0 -0
  66. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/provider.py +0 -0
  67. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/formatter.py +0 -0
  68. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/manifest.py +0 -0
  69. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/matcher.py +0 -0
  70. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/__init__.py +0 -0
  71. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/provider.py +0 -0
  72. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/sources/__init__.py +0 -0
  73. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/sources/tmdb.py +0 -0
  74. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/normalize.py +0 -0
  75. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/organizer.py +0 -0
  76. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/scanner.py +0 -0
  77. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/snapshot.py +0 -0
  78. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/splitter.py +0 -0
  79. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/tagger.py +0 -0
  80. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/ui.py +0 -0
  81. {riplex-0.7.2 → riplex-0.7.3}/src/riplex/updater.py +0 -0
  82. {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/dependency_links.txt +0 -0
  83. {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/entry_points.txt +0 -0
  84. {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/requires.txt +0 -0
  85. {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/top_level.txt +0 -0
  86. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/__init__.py +0 -0
  87. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/bug_report.py +0 -0
  88. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/crash_dump.py +0 -0
  89. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/keep_awake.py +0 -0
  90. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/main.py +0 -0
  91. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/__init__.py +0 -0
  92. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/disc_overview.py +0 -0
  93. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/disc_swap.py +0 -0
  94. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/done.py +0 -0
  95. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/metadata.py +0 -0
  96. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/orchestrate_done.py +0 -0
  97. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/organize_done.py +0 -0
  98. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/progress.py +0 -0
  99. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/selection.py +0 -0
  100. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/update.py +0 -0
  101. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/welcome.py +0 -0
  102. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/__init__.py +0 -0
  103. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/__init__.py +0 -0
  104. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/lookup.py +0 -0
  105. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/setup.py +0 -0
  106. {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/formatting.py +0 -0
  107. {riplex-0.7.2 → riplex-0.7.3}/tests/__init__.py +0 -0
  108. {riplex-0.7.2 → riplex-0.7.3}/tests/fixtures/chernobyl_disc1.json +0 -0
  109. {riplex-0.7.2 → riplex-0.7.3}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  110. {riplex-0.7.2 → riplex-0.7.3}/tests/fixtures/makemkvcon_list.txt +0 -0
  111. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  112. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  113. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  114. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  115. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  116. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  117. {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Waterworld.snapshot.json +0 -0
  118. {riplex-0.7.2 → riplex-0.7.3}/tests/test_cache.py +0 -0
  119. {riplex-0.7.2 → riplex-0.7.3}/tests/test_config.py +0 -0
  120. {riplex-0.7.2 → riplex-0.7.3}/tests/test_dedup.py +0 -0
  121. {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_analysis.py +0 -0
  122. {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_detection_screen.py +0 -0
  123. {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_fixtures.py +0 -0
  124. {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_provider.py +0 -0
  125. {riplex-0.7.2 → riplex-0.7.3}/tests/test_formatter.py +0 -0
  126. {riplex-0.7.2 → riplex-0.7.3}/tests/test_matcher.py +0 -0
  127. {riplex-0.7.2 → riplex-0.7.3}/tests/test_normalize.py +0 -0
  128. {riplex-0.7.2 → riplex-0.7.3}/tests/test_organizer.py +0 -0
  129. {riplex-0.7.2 → riplex-0.7.3}/tests/test_rip_guide.py +0 -0
  130. {riplex-0.7.2 → riplex-0.7.3}/tests/test_scanner.py +0 -0
  131. {riplex-0.7.2 → riplex-0.7.3}/tests/test_snapshot.py +0 -0
  132. {riplex-0.7.2 → riplex-0.7.3}/tests/test_splitter.py +0 -0
  133. {riplex-0.7.2 → riplex-0.7.3}/tests/test_tagger.py +0 -0
  134. {riplex-0.7.2 → riplex-0.7.3}/tests/test_ui.py +0 -0
  135. {riplex-0.7.2 → riplex-0.7.3}/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.3
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,12 @@ 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.3 — 2026-06-10
8
+
9
+ ### Fixed
10
+
11
+ - **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.
12
+
7
13
  ## v0.7.2 — 2026-05-17
8
14
 
9
15
  ### Fixed
@@ -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.3
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
@@ -108,6 +108,7 @@ tests/test_disc_detection_screen.py
108
108
  tests/test_disc_fixtures.py
109
109
  tests/test_disc_provider.py
110
110
  tests/test_formatter.py
111
+ tests/test_lookup.py
111
112
  tests/test_makemkv.py
112
113
  tests/test_matcher.py
113
114
  tests/test_normalize.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":
@@ -36,8 +36,11 @@ class ReleaseScreen:
36
36
  override = self.app.state.get("dvdcompare_title_override")
37
37
  if override:
38
38
  return override
39
+ season_number = self.app.state.get("season_number")
39
40
  tmdb_match = self.app.state.get("tmdb_match")
40
41
  if tmdb_match:
42
+ if tmdb_match.media_type == "tv" and season_number is not None:
43
+ return f"{tmdb_match.title}: Season {season_number}"
41
44
  return tmdb_match.title
42
45
  return self.app.state["title"]
43
46
 
@@ -31,7 +31,7 @@ from riplex.manifest import (
31
31
  )
32
32
  from riplex.metadata.sources.tmdb import TmdbProvider
33
33
  from riplex.models import SearchRequest
34
- from riplex.title import parse_volume_label
34
+ from riplex.title import parse_title_and_season, parse_volume_label
35
35
  from riplex.ui import is_interactive, prompt_choice, prompt_confirm, prompt_text
36
36
 
37
37
  from riplex_cli.formatting import (
@@ -182,10 +182,12 @@ async def run_orchestrate(args: argparse.Namespace) -> int:
182
182
  # Auto-detect title
183
183
  title_arg = getattr(args, "title", None)
184
184
  if not title_arg:
185
- title_arg = parse_volume_label(volume_label)
185
+ title_arg, parsed_season = parse_title_and_season(volume_label)
186
186
  if title_arg:
187
187
  print(f"Auto-detected title from volume label: {title_arg}", file=sys.stderr)
188
188
  title_arg = prompt_text("Title", default=title_arg)
189
+ if getattr(args, "season_number", None) is None and parsed_season is not None:
190
+ args.season_number = parsed_season
189
191
  else:
190
192
  print("Error: could not detect title from volume label. Provide --title.", file=sys.stderr)
191
193
  return 1
@@ -216,6 +218,7 @@ async def run_orchestrate(args: argparse.Namespace) -> int:
216
218
  request = SearchRequest(
217
219
  title=title_arg,
218
220
  year=getattr(args, "year", None),
221
+ season_number=getattr(args, "season_number", None),
219
222
  media_type=media_type_arg,
220
223
  )
221
224
  release = getattr(args, "release", None)