riplex 0.6.3__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.3 → riplex-0.7.0}/.github/workflows/release.yml +5 -0
- {riplex-0.6.3 → riplex-0.7.0}/.gitignore +3 -0
- {riplex-0.6.3 → riplex-0.7.0}/PKG-INFO +1 -1
- {riplex-0.6.3 → riplex-0.7.0}/docs/changelog.md +25 -3
- {riplex-0.6.3 → riplex-0.7.0}/docs/reference/cli.md +1 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/analysis.py +12 -11
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/provider.py +91 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/matcher.py +49 -7
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/organizer.py +3 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/PKG-INFO +1 -1
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/folder_picker.py +69 -2
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/organize_preview.py +16 -1
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/release.py +190 -12
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/organize.py +35 -4
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/main.py +10 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/fixtures/chernobyl_disc1.json +2 -2
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_analysis.py +37 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_fixtures.py +13 -6
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_provider.py +43 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_matcher.py +57 -3
- {riplex-0.6.3 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/.github/agents/riplex.agent.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/.github/copilot-instructions.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/.github/workflows/publish.yml +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/.vscode/settings.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/CONTRIBUTORS.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/LICENSE +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/README.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/architecture.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/getting-started/configuration.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/getting-started/installation.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/guide/lookup.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/guide/orchestrate.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/guide/organize.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/guide/workflow.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/index.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/naming-rules.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/docs/troubleshooting.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/issues/planned-features.md +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/mkdocs.yml +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/pyproject.toml +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/setup.cfg +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/cache.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/config.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/dedup.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/detect.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/disc/makemkv.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/formatter.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/lookup.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/manifest.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/planner.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/metadata/sources/tmdb.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/models.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/normalize.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/scanner.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/snapshot.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/splitter.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/tagger.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/title.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/ui.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex/updater.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/SOURCES.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/main.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/disc_detection.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/done.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/metadata.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/orchestrate_done.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_app/screens/welcome.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/orchestrate.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/rip.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/commands/setup.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/__init__.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_cache.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_cli_utils.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_config.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_dedup.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_detect.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_formatter.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_makemkv.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_normalize.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_organizer.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_planner.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_rip_guide.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_scanner.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_snapshot.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_splitter.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_tagger.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_ui.py +0 -0
- {riplex-0.6.3 → riplex-0.7.0}/tests/test_updater.py +0 -0
|
@@ -164,6 +164,11 @@ jobs:
|
|
|
164
164
|
- name: Create GitHub Release
|
|
165
165
|
uses: softprops/action-gh-release@v2
|
|
166
166
|
with:
|
|
167
|
+
# Auto-generate notes from commits and PRs since the previous tag,
|
|
168
|
+
# then prepend the tag annotation (if any) as a header. Using just
|
|
169
|
+
# the tag annotation produces single-line releases when the tag
|
|
170
|
+
# was created with `git tag -a -m "<subject>"` and no body.
|
|
171
|
+
generate_release_notes: true
|
|
167
172
|
body: ${{ steps.tag_notes.outputs.body }}
|
|
168
173
|
files: |
|
|
169
174
|
release/riplex-windows.exe
|
|
@@ -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,14 +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
|
+
## 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.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
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.
|
|
24
|
+
|
|
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
|
+
|
|
7
29
|
## 2026-05-13
|
|
8
30
|
|
|
9
31
|
### Changed
|
|
10
32
|
|
|
11
|
-
- Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing.
|
|
33
|
+
- Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing.
|
|
12
34
|
|
|
13
35
|
## 2026-05-12
|
|
14
36
|
|
|
@@ -43,7 +65,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
43
65
|
### Added
|
|
44
66
|
|
|
45
67
|
- Troubleshooting guide: macOS-specific sections for tkinter/browse button, SSL certificate errors, Gatekeeper blocking, and tools not found despite being installed.
|
|
46
|
-
- 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.
|
|
47
69
|
|
|
48
70
|
### Changed
|
|
49
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:
|
|
@@ -208,6 +208,82 @@ class DiscProvider:
|
|
|
208
208
|
# in screens / external code may still use this; prefer ``fetch_film``.
|
|
209
209
|
_fetch_film_cached = fetch_film
|
|
210
210
|
|
|
211
|
+
async def fetch_film_by_id(self, film_id: int) -> FilmComparison:
|
|
212
|
+
"""Fetch a specific dvdcompare film page by its film id.
|
|
213
|
+
|
|
214
|
+
Used by the UI's manual-override path when our auto-ranking picks
|
|
215
|
+
the wrong film page. Cached on success; HTTP/network failures
|
|
216
|
+
propagate as LookupError after caching a short-TTL negative entry.
|
|
217
|
+
"""
|
|
218
|
+
cache_key = cache.hash_key(f"film-by-id|{film_id}")
|
|
219
|
+
|
|
220
|
+
cached = cache.cache_get(self.cache_ns, cache_key, ttl_days=self.ttl_days)
|
|
221
|
+
if cached is not None and not cached.get("_negative"):
|
|
222
|
+
log.debug("dvdcompare film-by-id cache hit for %s", film_id)
|
|
223
|
+
return _dict_to_film(cached)
|
|
224
|
+
|
|
225
|
+
url = film_url(film_id)
|
|
226
|
+
try:
|
|
227
|
+
film = await _throttled_get_film_by_url(url)
|
|
228
|
+
except httpx.HTTPStatusError as exc:
|
|
229
|
+
status = exc.response.status_code if exc.response is not None else "?"
|
|
230
|
+
log.warning("dvdcompare HTTP %s fetching fid=%s", status, film_id)
|
|
231
|
+
raise LookupError(
|
|
232
|
+
f"dvdcompare returned HTTP {status} for fid={film_id}"
|
|
233
|
+
) from exc
|
|
234
|
+
except httpx.RequestError as exc:
|
|
235
|
+
log.warning("dvdcompare network error fetching fid=%s: %s", film_id, exc)
|
|
236
|
+
raise LookupError(
|
|
237
|
+
f"dvdcompare network error for fid={film_id}: {exc}"
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
if not film.releases:
|
|
241
|
+
raise LookupError(f"dvdcompare film {film_id} has no releases")
|
|
242
|
+
|
|
243
|
+
cache.cache_set(self.cache_ns, cache_key, dataclasses.asdict(film))
|
|
244
|
+
return film
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Public URL / fid helpers
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
from dvdcompare.scraper import BASE_URL as _DVDCOMPARE_BASE_URL # noqa: E402
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def film_url(film_id: int | str) -> str:
|
|
255
|
+
"""Return the public dvdcompare comparison URL for a film id."""
|
|
256
|
+
return f"{_DVDCOMPARE_BASE_URL}/comparisons/film.php?fid={film_id}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_FID_RE = re.compile(r"(?:fid=|film\.php\?fid=|^)(\d+)")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_film_id(value: str) -> int | None:
|
|
263
|
+
"""Extract a numeric film id from a bare number or a dvdcompare URL.
|
|
264
|
+
|
|
265
|
+
Returns ``None`` if no fid can be parsed.
|
|
266
|
+
|
|
267
|
+
Accepted forms:
|
|
268
|
+
- ``"55540"``
|
|
269
|
+
- ``"fid=55540"``
|
|
270
|
+
- ``"https://www.dvdcompare.net/comparisons/film.php?fid=55540"``
|
|
271
|
+
- ``"https://www.dvdcompare.net/comparisons/film.php?fid=55540#2"``
|
|
272
|
+
"""
|
|
273
|
+
if not value:
|
|
274
|
+
return None
|
|
275
|
+
s = value.strip()
|
|
276
|
+
if not s:
|
|
277
|
+
return None
|
|
278
|
+
# Pure digits
|
|
279
|
+
if s.isdigit():
|
|
280
|
+
return int(s)
|
|
281
|
+
m = _FID_RE.search(s)
|
|
282
|
+
if m:
|
|
283
|
+
return int(m.group(1))
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
211
287
|
|
|
212
288
|
# ---------------------------------------------------------------------------
|
|
213
289
|
# Internal helpers: negative cache + throttle
|
|
@@ -284,6 +360,21 @@ async def _throttled_find_film(
|
|
|
284
360
|
_last_request_at = time.monotonic()
|
|
285
361
|
|
|
286
362
|
|
|
363
|
+
async def _throttled_get_film_by_url(url: str) -> FilmComparison:
|
|
364
|
+
"""Call ``get_film_by_url`` under the same throttle as ``find_film``."""
|
|
365
|
+
global _last_request_at
|
|
366
|
+
async with _request_lock:
|
|
367
|
+
now = time.monotonic()
|
|
368
|
+
wait = _MIN_INTERVAL_S - (now - _last_request_at)
|
|
369
|
+
if wait > 0:
|
|
370
|
+
log.debug("dvdcompare throttle: waiting %.2fs before request", wait)
|
|
371
|
+
await asyncio.sleep(wait)
|
|
372
|
+
try:
|
|
373
|
+
return await get_film_by_url(url)
|
|
374
|
+
finally:
|
|
375
|
+
_last_request_at = time.monotonic()
|
|
376
|
+
|
|
377
|
+
|
|
287
378
|
async def _find_film_prefer_format(
|
|
288
379
|
title: str, disc_format: str, year: int | None
|
|
289
380
|
) -> FilmComparison:
|
|
@@ -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)
|