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