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.
- {riplex-0.7.2/src/riplex.egg-info → riplex-0.7.3}/PKG-INFO +1 -1
- {riplex-0.7.2 → riplex-0.7.3}/docs/changelog.md +6 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/detect.py +41 -5
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/makemkv.py +59 -2
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/lookup.py +4 -1
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/planner.py +2 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/models.py +1 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/title.py +33 -0
- {riplex-0.7.2 → riplex-0.7.3/src/riplex.egg-info}/PKG-INFO +1 -1
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/SOURCES.txt +1 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/disc_detection.py +37 -3
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/folder_picker.py +114 -2
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/organize_preview.py +1 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/release.py +3 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/orchestrate.py +5 -2
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/organize.py +28 -7
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/rip.py +5 -2
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/main.py +18 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_cli_utils.py +29 -1
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_detect.py +51 -0
- riplex-0.7.3/tests/test_lookup.py +48 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_makemkv.py +77 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_planner.py +53 -0
- {riplex-0.7.2 → riplex-0.7.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.github/agents/riplex.agent.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.github/copilot-instructions.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.github/workflows/publish.yml +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.github/workflows/release.yml +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.gitignore +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/.vscode/settings.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/CONTRIBUTORS.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/LICENSE +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/README.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/architecture.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/lookup.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/orchestrate.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/organize.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/cli-guide/workflow.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/getting-started/configuration.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/getting-started/installation.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/gui-guide/gui-walkthrough.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/index.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/naming-rules.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/reference/cli.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/docs/troubleshooting.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/issues/planned-features.md +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/mkdocs.yml +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/pyproject.toml +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/1_Welcome_Screen.png +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/2_Disc_Detection_BTTF.png +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/4_Disc_Release_BTTF.png +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/setup.cfg +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/cache.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/config.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/dedup.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/analysis.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/disc/provider.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/formatter.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/manifest.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/matcher.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/metadata/sources/tmdb.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/normalize.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/organizer.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/scanner.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/snapshot.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/splitter.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/tagger.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/ui.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex/updater.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/main.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/done.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/metadata.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/orchestrate_done.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_app/screens/welcome.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/commands/setup.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/__init__.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/fixtures/chernobyl_disc1.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_cache.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_config.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_dedup.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_analysis.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_fixtures.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_disc_provider.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_formatter.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_matcher.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_normalize.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_organizer.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_rip_guide.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_scanner.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_snapshot.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_splitter.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_tagger.py +0 -0
- {riplex-0.7.2 → riplex-0.7.3}/tests/test_ui.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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,
|
|
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")
|
|
@@ -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 =
|
|
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)
|