riplex 0.6.4__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {riplex-0.6.4 → riplex-0.7.0}/.gitignore +3 -0
- {riplex-0.6.4 → riplex-0.7.0}/PKG-INFO +1 -1
- {riplex-0.6.4 → riplex-0.7.0}/docs/changelog.md +24 -7
- {riplex-0.6.4 → riplex-0.7.0}/docs/reference/cli.md +1 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/analysis.py +12 -11
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/matcher.py +49 -7
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/organizer.py +3 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/PKG-INFO +1 -1
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/folder_picker.py +69 -2
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/organize_preview.py +16 -1
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/release.py +33 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/organize.py +35 -4
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/main.py +10 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/fixtures/chernobyl_disc1.json +2 -2
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_analysis.py +37 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_fixtures.py +13 -6
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_matcher.py +57 -3
- {riplex-0.6.4 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/.github/agents/riplex.agent.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/.github/copilot-instructions.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/.github/workflows/publish.yml +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/.github/workflows/release.yml +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/.vscode/settings.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/CONTRIBUTORS.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/LICENSE +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/README.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/architecture.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/getting-started/configuration.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/getting-started/installation.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/guide/lookup.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/guide/orchestrate.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/guide/organize.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/guide/workflow.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/index.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/naming-rules.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/docs/troubleshooting.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/issues/planned-features.md +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/mkdocs.yml +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/pyproject.toml +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/setup.cfg +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/cache.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/config.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/dedup.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/detect.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/makemkv.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/disc/provider.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/formatter.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/lookup.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/manifest.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/planner.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/metadata/sources/tmdb.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/models.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/normalize.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/scanner.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/snapshot.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/splitter.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/tagger.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/title.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/ui.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex/updater.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/SOURCES.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/main.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/disc_detection.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/done.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/metadata.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/orchestrate_done.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_app/screens/welcome.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/orchestrate.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/rip.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/commands/setup.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/__init__.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_cache.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_cli_utils.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_config.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_dedup.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_detect.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_disc_provider.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_formatter.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_makemkv.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_normalize.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_organizer.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_planner.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_rip_guide.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_scanner.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_snapshot.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_splitter.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_tagger.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_ui.py +0 -0
- {riplex-0.6.4 → riplex-0.7.0}/tests/test_updater.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -1,19 +1,36 @@
|
|
|
1
|
-
# Documentation Changelog
|
|
1
|
+
# Documentation Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to the riplex documentation are recorded here.
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
-
## 2026-05-
|
|
7
|
+
## v0.7.0 — 2026-05-16
|
|
8
|
+
|
|
9
|
+
Summary: organize-time match quality fixes, faster post-rip organize, and a release-picker affordance for verifying the dvdcompare film page.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **GUI: "View on dvdcompare.net" link** on the disc-release screen. Shows the auto-selected film page so users can verify region/edition before committing to a long rip.
|
|
14
|
+
- **GUI: manual film-id override** on the disc-release screen. Paste either a bare fid (e.g. `55540`) or a full URL (`https://www.dvdcompare.net/comparisons/film.php?fid=55540`) and riplex fetches and uses that film page instead. The chosen fid is persisted per `(title, disc_format)` so swapping discs in the same box set keeps the override, with a "Clear saved override" affordance.
|
|
15
|
+
- **`riplex organize --rescan`** flag. By default `organize` now reads `_rip_manifest.json` from each disc subfolder when present (instant load, preserves rip-time classification). Pass `--rescan` to force a fresh ffprobe scan instead.
|
|
16
|
+
- **GUI organize folder picker**: when every disc subfolder has a `_rip_manifest.json`, the folder loads instantly without running ffprobe. A green banner indicates the manifest load with a "Rescan with ffprobe" button to force a fresh probe.
|
|
17
|
+
- **GUI organize preview**: every matched row now shows a confidence chip with the actual delta in seconds (e.g. `HIGH ±18s`, `MEDIUM ±104s`) so weak matches are easy to spot before executing.
|
|
8
18
|
|
|
9
19
|
### Changed
|
|
10
20
|
|
|
11
|
-
-
|
|
21
|
+
- **Tighter match tolerance for extras and episodes.** The global `_MAX_MATCH_DELTA` of 300 s is now reserved for the main-movie target; episodes and extras use a 120 s cap. This prevents short featurettes from being claimed by unrelated short clips when no good candidate exists.
|
|
22
|
+
- **Classification-aware matching.** Files whose rip-time classification is `Unmatched content`, `Unknown content`, or `Very short` are no longer paired with a named extra target unless the duration delta is within ±30 s. Ambiguous shorts stay unmatched (and visible) instead of being silently assigned to the closest dvdcompare entry within the loose 300 s window.
|
|
23
|
+
- **Release workflow**: GitHub Releases now include both the manually composed release notes and the auto-generated commit list, so tag annotations authored ahead of the tag push aren't lost.
|
|
12
24
|
|
|
13
|
-
###
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **4K disc extras classification**: 1080p extras on a 4K disc are now only skipped when a 4K counterpart actually exists on the same disc. Previously, the duplicate-detection pass could flag legitimate standalone 1080p extras as duplicates of unrelated 4K titles.
|
|
28
|
+
|
|
29
|
+
## 2026-05-13
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
14
32
|
|
|
15
|
-
-
|
|
16
|
-
- Disc Release screen: manual film-id override input. Paste either a bare fid (e.g. `55540`) or a full URL (`https://www.dvdcompare.net/comparisons/film.php?fid=55540`) and riplex will fetch and use that film page instead. The chosen fid is persisted per `(title, disc_format)` so swapping discs in the same box set keeps the override, with a "Clear saved override" affordance.
|
|
33
|
+
- Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing.
|
|
17
34
|
|
|
18
35
|
## 2026-05-12
|
|
19
36
|
|
|
@@ -48,7 +65,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
48
65
|
### Added
|
|
49
66
|
|
|
50
67
|
- Troubleshooting guide: macOS-specific sections for tkinter/browse button, SSL certificate errors, Gatekeeper blocking, and tools not found despite being installed.
|
|
51
|
-
- Installation guide: new "Install with pipx" section as the recommended install method for end users
|
|
68
|
+
- Installation guide: new "Install with pipx" section as the recommended install method for end users — provides globally available `riplex` and `riplex-ui` commands without venv activation.
|
|
52
69
|
|
|
53
70
|
### Changed
|
|
54
71
|
|
|
@@ -59,6 +59,7 @@ riplex organize <folder> [options]
|
|
|
59
59
|
| `--verbose`, `-v` | Print debug logging to stderr (log file is always written) |
|
|
60
60
|
| `--no-cache` | Bypass cached dvdcompare and TMDb responses |
|
|
61
61
|
| `--force` | Re-organize files even if already tagged as organized |
|
|
62
|
+
| `--rescan` | Ignore `_rip_manifest.json` files and re-probe every MKV with ffprobe. By default, riplex reuses rip-time metadata from the manifest when present (faster, preserves classification). |
|
|
62
63
|
| `--json` | Output as JSON |
|
|
63
64
|
| `--api-key` | TMDb API key |
|
|
64
65
|
| `--snapshot` | Replay from a snapshot JSON file instead of scanning live files |
|
|
@@ -289,22 +289,23 @@ def is_skip_title(
|
|
|
289
289
|
return True
|
|
290
290
|
|
|
291
291
|
# On 4K discs, skip 1080p titles that match non-episode dvdcompare entries
|
|
292
|
-
#
|
|
293
|
-
#
|
|
292
|
+
# ONLY when a 4K physical title at the same duration also exists on the
|
|
293
|
+
# disc (i.e. the 1080p title is a true duplicate). Some studios (e.g.
|
|
294
|
+
# Universal) ship the 4K main film on a 4K disc but include most extras
|
|
295
|
+
# at 1080p only — in that case the 1080p extras are the *only* copy and
|
|
296
|
+
# must be ripped, not skipped.
|
|
294
297
|
if not is_4k and dvd_entries:
|
|
295
298
|
has_4k = any("3840" in (t.resolution or "") for t in all_titles if t.duration_seconds > 600)
|
|
296
299
|
if has_4k:
|
|
297
300
|
match = find_duration_match(dur, dvd_entries)
|
|
298
301
|
if match and match[2] != "episode":
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return False
|
|
307
|
-
return True
|
|
302
|
+
has_4k_version = any(
|
|
303
|
+
"3840" in (t.resolution or "")
|
|
304
|
+
and abs(t.duration_seconds - dur) < 30
|
|
305
|
+
for t in all_titles if t is not title
|
|
306
|
+
)
|
|
307
|
+
if has_4k_version:
|
|
308
|
+
return True
|
|
308
309
|
|
|
309
310
|
# Skip dvdcompare-based play-all if individual episodes exist at same resolution
|
|
310
311
|
if total_episode_runtime > 0 and abs(dur - total_episode_runtime) < 120:
|
|
@@ -161,8 +161,32 @@ def format_match_report(candidates: list[MatchCandidate]) -> str:
|
|
|
161
161
|
_FILM_DISC_MARKER = -1
|
|
162
162
|
|
|
163
163
|
# Absolute maximum delta (seconds) beyond which a pairing is rejected.
|
|
164
|
-
#
|
|
165
|
-
|
|
164
|
+
# The movie target gets a generous tolerance because main-feature
|
|
165
|
+
# runtimes from TMDb are often rounded to the nearest minute and may
|
|
166
|
+
# omit credits / disc-specific intros. Extras tend to have precise
|
|
167
|
+
# dvdcompare runtimes, so a much tighter cap prevents short featurettes
|
|
168
|
+
# from being matched to unrelated short clips.
|
|
169
|
+
_MAX_MATCH_DELTA = 300 # movie target
|
|
170
|
+
_MAX_MATCH_DELTA_EXTRA = 120 # episodes + extras
|
|
171
|
+
|
|
172
|
+
# Classification prefixes that signal "rip-time classifier could not
|
|
173
|
+
# identify this title". When set, the file must not be claimed by a
|
|
174
|
+
# named target unless the delta is very small (< _HIGH_THRESHOLD).
|
|
175
|
+
_UNIDENTIFIED_CLASSIFICATION_PREFIXES = (
|
|
176
|
+
"Unmatched content",
|
|
177
|
+
"Unknown content",
|
|
178
|
+
"Very short",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_movie_target(label: str) -> bool:
|
|
183
|
+
return label.endswith("(movie)")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_unidentified(classification: str) -> bool:
|
|
187
|
+
if not classification:
|
|
188
|
+
return False
|
|
189
|
+
return classification.startswith(_UNIDENTIFIED_CLASSIFICATION_PREFIXES)
|
|
166
190
|
|
|
167
191
|
_PLAY_ALL_RE = re.compile(r"\bplay\s*all\b", re.IGNORECASE)
|
|
168
192
|
|
|
@@ -437,12 +461,30 @@ def match_discs(
|
|
|
437
461
|
for delta, fi, ti in pairings:
|
|
438
462
|
if fi in claimed_files or ti in claimed_targets:
|
|
439
463
|
continue
|
|
440
|
-
if delta > _MAX_MATCH_DELTA:
|
|
441
|
-
log.debug("Stopping greedy claims: delta %ds exceeds max %ds",
|
|
442
|
-
delta, _MAX_MATCH_DELTA)
|
|
443
|
-
break
|
|
444
|
-
sf = all_scanned[fi]
|
|
445
464
|
label, runtime_s, _t_disc = targets[ti]
|
|
465
|
+
is_movie = _is_movie_target(label)
|
|
466
|
+
max_delta = _MAX_MATCH_DELTA if is_movie else _MAX_MATCH_DELTA_EXTRA
|
|
467
|
+
if delta > max_delta:
|
|
468
|
+
# Skip — but don't break, since later pairings may involve a
|
|
469
|
+
# movie target with a looser cap. Pairings are still sorted
|
|
470
|
+
# by delta, so any remaining pair this large is also too large.
|
|
471
|
+
log.debug("Skip pairing: %s -> '%s' delta=%ds exceeds cap %ds",
|
|
472
|
+
all_scanned[fi].name, label, delta, max_delta)
|
|
473
|
+
continue
|
|
474
|
+
sf = all_scanned[fi]
|
|
475
|
+
# Honor rip-time classification: if the classifier explicitly
|
|
476
|
+
# flagged this file as unidentified/short, require a tight delta
|
|
477
|
+
# before pairing it with a named target.
|
|
478
|
+
if (
|
|
479
|
+
not is_movie
|
|
480
|
+
and _is_unidentified(sf.classification)
|
|
481
|
+
and delta > _HIGH_THRESHOLD
|
|
482
|
+
):
|
|
483
|
+
log.debug(
|
|
484
|
+
"Reject pairing on classification: %s [%s] -> '%s' delta=%ds",
|
|
485
|
+
sf.name, sf.classification, label, delta,
|
|
486
|
+
)
|
|
487
|
+
continue
|
|
446
488
|
conf = _confidence(delta)
|
|
447
489
|
log.debug("Claim: %s (%ds) -> '%s' (%ds) delta=%ds [%s]",
|
|
448
490
|
sf.name, sf.duration_seconds, label, runtime_s, delta, conf)
|
|
@@ -75,6 +75,7 @@ class FileMove:
|
|
|
75
75
|
destination: str
|
|
76
76
|
label: str # what this file was matched to
|
|
77
77
|
confidence: str
|
|
78
|
+
delta_seconds: int = 0 # |file_runtime - target_runtime|, for diagnostics
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
@dataclass
|
|
@@ -86,6 +87,7 @@ class SplitMove:
|
|
|
86
87
|
chapter_labels: list[str] # one label per chapter
|
|
87
88
|
confidence: str
|
|
88
89
|
original_label: str = "" # the dvdcompare match label
|
|
90
|
+
delta_seconds: int = 0
|
|
89
91
|
|
|
90
92
|
|
|
91
93
|
@dataclass
|
|
@@ -396,6 +398,7 @@ def build_organize_plan(
|
|
|
396
398
|
destination=str(dest),
|
|
397
399
|
label=candidate.matched_label,
|
|
398
400
|
confidence=candidate.confidence,
|
|
401
|
+
delta_seconds=candidate.delta_seconds,
|
|
399
402
|
)
|
|
400
403
|
)
|
|
401
404
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -11,6 +11,7 @@ log = logging.getLogger(__name__)
|
|
|
11
11
|
|
|
12
12
|
from riplex.config import get_rip_output
|
|
13
13
|
from riplex.detect import detect_format
|
|
14
|
+
from riplex.manifest import build_scanned_from_manifests
|
|
14
15
|
from riplex.scanner import scan_folder
|
|
15
16
|
from riplex.snapshot import load_organized_marker
|
|
16
17
|
from riplex.title import infer_title_from_scanned
|
|
@@ -124,8 +125,48 @@ class FolderPickerScreen:
|
|
|
124
125
|
return
|
|
125
126
|
self.folder_field.error_text = None
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
folder = Path(path)
|
|
129
|
+
self.app.state["source_folder"] = folder
|
|
128
130
|
|
|
131
|
+
# Fast path: if every disc subfolder has a _rip_manifest.json,
|
|
132
|
+
# load metadata from the manifest instead of running ffprobe.
|
|
133
|
+
if self._has_complete_manifests(folder):
|
|
134
|
+
log.info("loading scan from rip manifests in %s", folder)
|
|
135
|
+
try:
|
|
136
|
+
scanned = build_scanned_from_manifests(folder)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
log.exception("manifest load failed; falling back to ffprobe")
|
|
139
|
+
scanned = None
|
|
140
|
+
if scanned:
|
|
141
|
+
self.app.state["_scan_from_manifest"] = True
|
|
142
|
+
self.app.state["_scan_result"] = scanned
|
|
143
|
+
self.app.navigate("folder_picker")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
self.app.state["_scan_from_manifest"] = False
|
|
147
|
+
self._start_ffprobe_scan(folder)
|
|
148
|
+
|
|
149
|
+
def _has_complete_manifests(self, folder: Path) -> bool:
|
|
150
|
+
subfolders_with_mkvs = [
|
|
151
|
+
c for c in folder.iterdir()
|
|
152
|
+
if c.is_dir() and any(c.glob("*.mkv"))
|
|
153
|
+
]
|
|
154
|
+
if not subfolders_with_mkvs:
|
|
155
|
+
return False
|
|
156
|
+
return all((c / "_rip_manifest.json").exists() for c in subfolders_with_mkvs)
|
|
157
|
+
|
|
158
|
+
def _rescan_with_ffprobe(self, e):
|
|
159
|
+
"""User clicked the 'Rescan with ffprobe' banner button."""
|
|
160
|
+
folder = self.app.state.get("source_folder")
|
|
161
|
+
if not folder:
|
|
162
|
+
return
|
|
163
|
+
# Clear any cached scan and force a fresh ffprobe scan.
|
|
164
|
+
self.app.state.pop("scanned", None)
|
|
165
|
+
self.app.state.pop("_scan_result", None)
|
|
166
|
+
self.app.state["_scan_from_manifest"] = False
|
|
167
|
+
self._start_ffprobe_scan(folder)
|
|
168
|
+
|
|
169
|
+
def _start_ffprobe_scan(self, folder: Path):
|
|
129
170
|
# Show scanning state with progress
|
|
130
171
|
self._progress_text = ft.Text("Discovering files...", size=14)
|
|
131
172
|
self._progress_bar = ft.ProgressBar(width=400)
|
|
@@ -149,7 +190,7 @@ class FolderPickerScreen:
|
|
|
149
190
|
)
|
|
150
191
|
self.app.page.update()
|
|
151
192
|
|
|
152
|
-
threading.Thread(target=self._do_scan, args=(
|
|
193
|
+
threading.Thread(target=self._do_scan, args=(folder,), daemon=True).start()
|
|
153
194
|
|
|
154
195
|
def _do_scan(self, folder: Path):
|
|
155
196
|
"""Run ffprobe scan in background."""
|
|
@@ -219,6 +260,31 @@ class FolderPickerScreen:
|
|
|
219
260
|
),
|
|
220
261
|
]
|
|
221
262
|
|
|
263
|
+
# Banner shown when the scan came from rip manifests (instant load).
|
|
264
|
+
manifest_banner = []
|
|
265
|
+
if self.app.state.get("_scan_from_manifest"):
|
|
266
|
+
manifest_banner = [
|
|
267
|
+
ft.Container(
|
|
268
|
+
content=ft.Row([
|
|
269
|
+
ft.Icon(ft.Icons.BOLT, color=ft.Colors.GREEN_400, size=18),
|
|
270
|
+
ft.Text(
|
|
271
|
+
"Loaded instantly from rip manifests (no ffprobe needed).",
|
|
272
|
+
size=13,
|
|
273
|
+
color=ft.Colors.GREEN_400,
|
|
274
|
+
expand=True,
|
|
275
|
+
),
|
|
276
|
+
ft.TextButton(
|
|
277
|
+
"Rescan with ffprobe",
|
|
278
|
+
icon=ft.Icons.REFRESH,
|
|
279
|
+
on_click=self._rescan_with_ffprobe,
|
|
280
|
+
),
|
|
281
|
+
], spacing=8),
|
|
282
|
+
bgcolor=ft.Colors.with_opacity(0.08, ft.Colors.GREEN),
|
|
283
|
+
padding=12,
|
|
284
|
+
border_radius=8,
|
|
285
|
+
),
|
|
286
|
+
]
|
|
287
|
+
|
|
222
288
|
# Build disc summary rows
|
|
223
289
|
disc_rows = []
|
|
224
290
|
for d in scanned:
|
|
@@ -252,6 +318,7 @@ class FolderPickerScreen:
|
|
|
252
318
|
color=ft.Colors.GREY_500,
|
|
253
319
|
),
|
|
254
320
|
ft.Divider(height=20),
|
|
321
|
+
*manifest_banner,
|
|
255
322
|
*marker_banner,
|
|
256
323
|
ft.Text(
|
|
257
324
|
f"Scanned {len(scanned)} disc{'s' if len(scanned) != 1 else ''}, "
|
|
@@ -175,10 +175,25 @@ class OrganizePreviewScreen:
|
|
|
175
175
|
"medium": ft.Colors.YELLOW,
|
|
176
176
|
"low": ft.Colors.ORANGE,
|
|
177
177
|
}.get(move.confidence, ft.Colors.GREY)
|
|
178
|
+
# Confidence chip: shows label + delta so the user can spot
|
|
179
|
+
# weak (medium/low) matches before executing.
|
|
180
|
+
delta = getattr(move, "delta_seconds", 0)
|
|
181
|
+
conf_label = (move.confidence or "?").upper()
|
|
182
|
+
if delta:
|
|
183
|
+
conf_text = f"{conf_label} \u00B1{delta}s"
|
|
184
|
+
else:
|
|
185
|
+
conf_text = conf_label
|
|
186
|
+
conf_chip = ft.Container(
|
|
187
|
+
content=ft.Text(conf_text, size=10, color=conf_color, weight=ft.FontWeight.BOLD),
|
|
188
|
+
bgcolor=ft.Colors.with_opacity(0.12, conf_color),
|
|
189
|
+
padding=ft.Padding(left=6, right=6, top=2, bottom=2),
|
|
190
|
+
border_radius=4,
|
|
191
|
+
)
|
|
178
192
|
move_rows.append(
|
|
179
193
|
ft.Row([
|
|
180
194
|
ft.Icon(ft.Icons.CHECK_CIRCLE, color=conf_color, size=16),
|
|
181
|
-
|
|
195
|
+
conf_chip,
|
|
196
|
+
ft.Text(f"{src_name}", size=12, width=220, no_wrap=True),
|
|
182
197
|
ft.Icon(ft.Icons.ARROW_FORWARD, size=14, color=ft.Colors.GREY_500),
|
|
183
198
|
ft.Text(f"{dest_folder}/{dest_name}", size=12, expand=True, no_wrap=True),
|
|
184
199
|
], spacing=6)
|
|
@@ -41,6 +41,39 @@ class ReleaseScreen:
|
|
|
41
41
|
return tmdb_match.title
|
|
42
42
|
return self.app.state["title"]
|
|
43
43
|
|
|
44
|
+
# -- manual film-id override (cache-backed) ---------------------------
|
|
45
|
+
|
|
46
|
+
def _override_cache_key(self) -> str:
|
|
47
|
+
"""Stable key for caching the user's manual film-id pick.
|
|
48
|
+
|
|
49
|
+
Keyed by ``(title, disc_format)`` so that swapping discs within the
|
|
50
|
+
same physical box set still picks up the override, but ripping a
|
|
51
|
+
different format edition (DVD vs 4K) of the same film does not.
|
|
52
|
+
"""
|
|
53
|
+
title = self._current_search_title()
|
|
54
|
+
fmt = self._detect_disc_format() or ""
|
|
55
|
+
return _cache.hash_key(f"{title}|{fmt}")
|
|
56
|
+
|
|
57
|
+
def _load_persisted_override(self) -> int | None:
|
|
58
|
+
entry = _cache.cache_get(_OVERRIDE_CACHE_NS, self._override_cache_key(), ttl_days=365)
|
|
59
|
+
if isinstance(entry, dict):
|
|
60
|
+
fid = entry.get("film_id")
|
|
61
|
+
if isinstance(fid, int):
|
|
62
|
+
return fid
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def _save_persisted_override(self, film_id: int) -> None:
|
|
66
|
+
_cache.cache_set(
|
|
67
|
+
_OVERRIDE_CACHE_NS,
|
|
68
|
+
self._override_cache_key(),
|
|
69
|
+
{"film_id": film_id},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _clear_persisted_override(self) -> None:
|
|
73
|
+
# cache module has no delete; write a sentinel that won't pass the
|
|
74
|
+
# int check in _load_persisted_override.
|
|
75
|
+
_cache.cache_set(_OVERRIDE_CACHE_NS, self._override_cache_key(), {"film_id": None})
|
|
76
|
+
|
|
44
77
|
def _build_search_bar(self, title: str, *, searching: bool) -> ft.Control:
|
|
45
78
|
"""Editable dvdcompare search field."""
|
|
46
79
|
self._search_field = ft.TextField(
|
|
@@ -11,6 +11,7 @@ from riplex.config import get_api_key, get_archive_root, get_output_root
|
|
|
11
11
|
from riplex.dedup import find_all_redundant, remove_duplicates
|
|
12
12
|
from riplex.detect import detect_format, detect_incomplete, group_title_folders, infer_media_type_from_files
|
|
13
13
|
from riplex.lookup import lookup_metadata
|
|
14
|
+
from riplex.manifest import build_scanned_from_manifests
|
|
14
15
|
from riplex.matcher import (
|
|
15
16
|
collect_disc_targets,
|
|
16
17
|
map_folders_to_discs,
|
|
@@ -41,6 +42,38 @@ from riplex_cli.formatting import (
|
|
|
41
42
|
log = logging.getLogger(__name__)
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def _has_complete_manifests(folder: Path) -> bool:
|
|
46
|
+
"""Return True if every direct subfolder containing MKVs also has a
|
|
47
|
+
``_rip_manifest.json`` (i.e. the folder was produced by ``riplex rip``).
|
|
48
|
+
"""
|
|
49
|
+
subfolders_with_mkvs = [
|
|
50
|
+
c for c in folder.iterdir()
|
|
51
|
+
if c.is_dir() and any(c.glob("*.mkv"))
|
|
52
|
+
]
|
|
53
|
+
if not subfolders_with_mkvs:
|
|
54
|
+
return False
|
|
55
|
+
return all((c / "_rip_manifest.json").exists() for c in subfolders_with_mkvs)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_scanned(folder: Path, args: argparse.Namespace) -> list:
|
|
59
|
+
"""Load ScannedDisc list, preferring rip manifests when present.
|
|
60
|
+
|
|
61
|
+
Re-probes via ffprobe when ``--rescan`` is set or when manifests are
|
|
62
|
+
missing/incomplete.
|
|
63
|
+
"""
|
|
64
|
+
if not getattr(args, "rescan", False) and _has_complete_manifests(folder):
|
|
65
|
+
print(
|
|
66
|
+
f"Loading rip manifests from {folder} (use --rescan to force ffprobe).",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
scanned = build_scanned_from_manifests(folder)
|
|
70
|
+
if scanned:
|
|
71
|
+
return scanned
|
|
72
|
+
print("Manifests found but no usable entries; falling back to ffprobe scan.", file=sys.stderr)
|
|
73
|
+
print(f"Scanning {folder} ...", file=sys.stderr)
|
|
74
|
+
return scan_folder(folder)
|
|
75
|
+
|
|
76
|
+
|
|
44
77
|
async def run_organize(args: argparse.Namespace) -> int:
|
|
45
78
|
"""Run the organize workflow: scan, look up metadata, match, organize."""
|
|
46
79
|
log_file = setup_logging(verbose=getattr(args, "verbose", False))
|
|
@@ -203,9 +236,8 @@ async def _organize_multi_folder(
|
|
|
203
236
|
|
|
204
237
|
all_scanned: list[ScannedDisc] = []
|
|
205
238
|
for folder in folders:
|
|
206
|
-
print(f"Scanning {folder} ...", file=sys.stderr)
|
|
207
239
|
try:
|
|
208
|
-
scanned =
|
|
240
|
+
scanned = _load_scanned(folder, args)
|
|
209
241
|
snapshot_out = folder / f"{folder.name}.snapshot.json"
|
|
210
242
|
if not snapshot_out.exists():
|
|
211
243
|
snapshot_save_from_scanned(folder, scanned, snapshot_out)
|
|
@@ -244,9 +276,8 @@ async def _organize_single(
|
|
|
244
276
|
)
|
|
245
277
|
return 0
|
|
246
278
|
|
|
247
|
-
print(f"Scanning {folder} ...", file=sys.stderr)
|
|
248
279
|
try:
|
|
249
|
-
scanned =
|
|
280
|
+
scanned = _load_scanned(folder, args)
|
|
250
281
|
except RuntimeError as exc:
|
|
251
282
|
print(f"Error: {exc}", file=sys.stderr)
|
|
252
283
|
return 1
|
|
@@ -110,6 +110,16 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
110
110
|
default=False,
|
|
111
111
|
help="Skip interactive prompts, use best-guess defaults.",
|
|
112
112
|
)
|
|
113
|
+
org_parser.add_argument(
|
|
114
|
+
"--rescan",
|
|
115
|
+
action="store_true",
|
|
116
|
+
default=False,
|
|
117
|
+
help=(
|
|
118
|
+
"Ignore _rip_manifest.json files and re-probe every MKV with "
|
|
119
|
+
"ffprobe. By default, riplex reuses rip-time metadata from "
|
|
120
|
+
"the manifest when present (faster, preserves classification)."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
113
123
|
|
|
114
124
|
# --- lookup ---
|
|
115
125
|
guide_parser = subs.add_parser(
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"is_movie": false,
|
|
66
66
|
"movie_runtime": null,
|
|
67
67
|
"disc_number": 1,
|
|
68
|
-
"rip_indices": [0, 1, 2, 3, 4],
|
|
69
|
-
"skip_indices": [5, 6, 7, 8, 9
|
|
68
|
+
"rip_indices": [0, 1, 2, 3, 4, 10, 11, 12],
|
|
69
|
+
"skip_indices": [5, 6, 7, 8, 9],
|
|
70
70
|
"episode_names": {
|
|
71
71
|
"0": "1:23:45",
|
|
72
72
|
"3": "Open Wide, O Earth",
|
|
@@ -237,6 +237,43 @@ class TestIsSkipTitle:
|
|
|
237
237
|
False, None, 0, 0, dvd_entries,
|
|
238
238
|
) is False
|
|
239
239
|
|
|
240
|
+
def test_keep_1080p_extra_on_4k_disc_without_4k_counterpart(self):
|
|
241
|
+
"""On a 4K disc with 1080p-only extras (Universal BttF 40th Anniversary
|
|
242
|
+
pattern), the 1080p extras should NOT be skipped just because a 4K
|
|
243
|
+
main film exists on the same disc. They're the only copy."""
|
|
244
|
+
# 4K main film (movie length) so the disc qualifies as "has_4k"
|
|
245
|
+
main_4k = _make_title(0, 7098, resolution="3840x2160")
|
|
246
|
+
# 1080p featurette with a dvdcompare entry but NO 4K counterpart
|
|
247
|
+
feat_1080 = _make_title(1, 1027, resolution="1920x1080")
|
|
248
|
+
dvd_entries = [
|
|
249
|
+
("Tales from the Future: Third Time's the Charm", 1027, "featurette"),
|
|
250
|
+
]
|
|
251
|
+
assert is_skip_title(
|
|
252
|
+
feat_1080, [main_4k, feat_1080],
|
|
253
|
+
True, 7098, 0, 0, dvd_entries,
|
|
254
|
+
) is False
|
|
255
|
+
|
|
256
|
+
def test_skip_1080p_extra_when_4k_counterpart_exists(self):
|
|
257
|
+
"""When the same extra exists in both 4K and 1080p on the disc
|
|
258
|
+
(true duplicate), keep the 4K version and skip the 1080p."""
|
|
259
|
+
main_4k = _make_title(0, 7098, resolution="3840x2160")
|
|
260
|
+
# Same featurette, both resolutions, same duration
|
|
261
|
+
feat_4k = _make_title(1, 248, resolution="3840x2160")
|
|
262
|
+
feat_1080 = _make_title(2, 248, resolution="1920x1080")
|
|
263
|
+
dvd_entries = [
|
|
264
|
+
("Music Video by ZZ Top: Doubleback", 248, "extra"),
|
|
265
|
+
]
|
|
266
|
+
# 1080p version should be skipped (4K duplicate exists)
|
|
267
|
+
assert is_skip_title(
|
|
268
|
+
feat_1080, [main_4k, feat_4k, feat_1080],
|
|
269
|
+
True, 7098, 0, 0, dvd_entries,
|
|
270
|
+
) is True
|
|
271
|
+
# 4K version should be kept
|
|
272
|
+
assert is_skip_title(
|
|
273
|
+
feat_4k, [main_4k, feat_4k, feat_1080],
|
|
274
|
+
True, 7098, 0, 0, dvd_entries,
|
|
275
|
+
) is False
|
|
276
|
+
|
|
240
277
|
|
|
241
278
|
class TestBuildDvdEntries:
|
|
242
279
|
def test_builds_entries(self):
|
|
@@ -216,7 +216,8 @@ class TestChernobylDisc1:
|
|
|
216
216
|
)
|
|
217
217
|
|
|
218
218
|
def test_1080p_skipped_when_4k_exists(self, disc_data):
|
|
219
|
-
"""1080p titles should be skipped when 4K
|
|
219
|
+
"""1080p titles should be skipped only when a 4K version exists on
|
|
220
|
+
the same disc. 1080p-only extras (no 4K counterpart) must be kept."""
|
|
220
221
|
disc_info, planned_discs, expected = disc_data
|
|
221
222
|
|
|
222
223
|
analysis = analyze_disc(
|
|
@@ -227,9 +228,15 @@ class TestChernobylDisc1:
|
|
|
227
228
|
)
|
|
228
229
|
|
|
229
230
|
rip_indices = [t.index for t in analysis.rippable_titles]
|
|
230
|
-
# #5 (1080p version of #1
|
|
231
|
-
#
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
231
|
+
# #5 (1080p version of #1, 4K counterpart exists) should be skipped.
|
|
232
|
+
# #10, #11, #12 (1080p featurette children with no 4K counterpart)
|
|
233
|
+
# must NOT be skipped — they're the only copy of that content.
|
|
234
|
+
assert 5 not in rip_indices, (
|
|
235
|
+
f"Title #5 (1080p dup of 4K #1) should be skipped: "
|
|
236
|
+
f"{analysis.classifications[5]}"
|
|
237
|
+
)
|
|
238
|
+
for idx in [10, 11, 12]:
|
|
239
|
+
assert idx in rip_indices, (
|
|
240
|
+
f"Title #{idx} (1080p featurette with no 4K counterpart) "
|
|
241
|
+
f"should NOT be skipped: {analysis.classifications[idx]}"
|
|
235
242
|
)
|
|
@@ -542,7 +542,9 @@ class TestMaxDeltaThreshold:
|
|
|
542
542
|
assert len(result.unmatched) == 1
|
|
543
543
|
|
|
544
544
|
def test_within_threshold_still_matches(self):
|
|
545
|
-
"""A file
|
|
545
|
+
"""A file 100s away from the target still matches (under the
|
|
546
|
+
extra cap of 120s).
|
|
547
|
+
"""
|
|
546
548
|
discs = [
|
|
547
549
|
PlannedDisc(
|
|
548
550
|
number=1,
|
|
@@ -556,13 +558,65 @@ class TestMaxDeltaThreshold:
|
|
|
556
558
|
ScannedDisc(
|
|
557
559
|
folder_name="Disc 1",
|
|
558
560
|
files=[
|
|
559
|
-
ScannedFile(name="a.mkv", path="x", duration_seconds=
|
|
561
|
+
ScannedFile(name="a.mkv", path="x", duration_seconds=3100),
|
|
560
562
|
],
|
|
561
563
|
),
|
|
562
564
|
]
|
|
563
565
|
result = match_discs(scanned, discs)
|
|
564
566
|
assert len(result.matched) == 1
|
|
565
|
-
assert result.matched[0].confidence == "
|
|
567
|
+
assert result.matched[0].confidence == "medium"
|
|
568
|
+
|
|
569
|
+
def test_movie_target_still_uses_300s_cap(self):
|
|
570
|
+
"""The main-movie target keeps the generous 300s cap so TMDb
|
|
571
|
+
runtimes rounded to the nearest minute still match.
|
|
572
|
+
"""
|
|
573
|
+
from riplex.models import PlannedMovie
|
|
574
|
+
|
|
575
|
+
plan = PlannedMovie(
|
|
576
|
+
canonical_title="Foo", year=2020,
|
|
577
|
+
runtime="120m", runtime_seconds=7200,
|
|
578
|
+
)
|
|
579
|
+
discs = [PlannedDisc(number=1, disc_format="Blu-ray", is_film=True)]
|
|
580
|
+
scanned = [
|
|
581
|
+
ScannedDisc(
|
|
582
|
+
folder_name="Disc 1",
|
|
583
|
+
files=[
|
|
584
|
+
ScannedFile(name="film.mkv", path="x", duration_seconds=7450),
|
|
585
|
+
],
|
|
586
|
+
),
|
|
587
|
+
]
|
|
588
|
+
result = match_discs(scanned, discs, plan)
|
|
589
|
+
assert len(result.matched) == 1
|
|
590
|
+
assert "(movie)" in result.matched[0].matched_label
|
|
591
|
+
|
|
592
|
+
def test_extra_with_unidentified_classification_rejected(self):
|
|
593
|
+
"""A file the rip-time classifier flagged as 'Unmatched content'
|
|
594
|
+
must not be paired with a named extra target unless the delta
|
|
595
|
+
is very small.
|
|
596
|
+
"""
|
|
597
|
+
discs = [
|
|
598
|
+
PlannedDisc(
|
|
599
|
+
number=1,
|
|
600
|
+
disc_format="Blu-ray",
|
|
601
|
+
extras=[
|
|
602
|
+
PlannedExtra(title="Hoverboard Commercial", runtime_seconds=66),
|
|
603
|
+
],
|
|
604
|
+
),
|
|
605
|
+
]
|
|
606
|
+
scanned = [
|
|
607
|
+
ScannedDisc(
|
|
608
|
+
folder_name="Disc 1",
|
|
609
|
+
files=[
|
|
610
|
+
ScannedFile(
|
|
611
|
+
name="t14.mkv", path="x", duration_seconds=170,
|
|
612
|
+
classification="Unmatched content (4K, 2:50)",
|
|
613
|
+
),
|
|
614
|
+
],
|
|
615
|
+
),
|
|
616
|
+
]
|
|
617
|
+
result = match_discs(scanned, discs)
|
|
618
|
+
assert len(result.matched) == 0
|
|
619
|
+
assert len(result.unmatched) == 1
|
|
566
620
|
|
|
567
621
|
def test_blue_planet_ii_scenario(self):
|
|
568
622
|
"""INTO THE BLUE files rejected by max delta and play-all filter."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|