riplex 0.9.1__tar.gz → 0.9.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {riplex-0.9.1/src/riplex.egg-info → riplex-0.9.3}/PKG-INFO +1 -1
- {riplex-0.9.1 → riplex-0.9.3}/docs/changelog.md +6 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/getting-started/installation.md +3 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/troubleshooting.md +2 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/sources/tmdb.py +35 -6
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/scanner.py +31 -2
- {riplex-0.9.1 → riplex-0.9.3/src/riplex.egg-info}/PKG-INFO +1 -1
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/SOURCES.txt +1 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/scm_file_list.json +1 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/scm_version.json +2 -2
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/welcome.py +3 -2
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/setup.py +1 -1
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_scanner.py +60 -1
- riplex-0.9.3/tests/test_tmdb_provider.py +57 -0
- {riplex-0.9.1 → riplex-0.9.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.github/agents/riplex.agent.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.github/copilot-instructions.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.github/workflows/publish.yml +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.github/workflows/release.yml +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.gitignore +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/.vscode/settings.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/CONTRIBUTORS.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/LICENSE +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/README.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/architecture.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/lookup.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/orchestrate.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/organize.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/workflow.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/getting-started/configuration.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/gui-guide/gui-walkthrough.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/index.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/naming-rules.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/docs/reference/cli.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/issues/planned-features.md +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/mkdocs.yml +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/pyproject.toml +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/1_Welcome_Screen.png +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/2_Disc_Detection_BTTF.png +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/4_Disc_Release_BTTF.png +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/setup.cfg +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/cache.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/config.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/dedup.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/detect.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/analysis.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/makemkv.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/provider.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/formatter.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/lookup.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/manifest.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/matcher.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/planner.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/models.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/normalize.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/organizer.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/snapshot.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/splitter.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/tagger.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/title.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/ui.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex/updater.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/main.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/disc_detection.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/done.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/folder_picker.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/metadata.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/orchestrate_done.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/organize_preview.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/release.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/orchestrate.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/organize.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/rip.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/main.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/__init__.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/fixtures/chernobyl_disc1.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_app_main.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_cache.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_cli_utils.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_config.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_dedup.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_detect.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_analysis.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_fixtures.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_provider.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_formatter.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_lookup.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_makemkv.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_matcher.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_normalize.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_organizer.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_planner.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_release_screen.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_rip_guide.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_snapshot.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_splitter.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_tagger.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_ui.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_updater.py +0 -0
- {riplex-0.9.1 → riplex-0.9.3}/tests/test_welcome_screen.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.3
|
|
4
4
|
Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -4,6 +4,12 @@ All notable changes to the riplex documentation are recorded here.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## v0.9.2 — 2026-06-24
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **TMDb credentials: accept both the API Key and the Read Access Token.** TMDb's settings page offers a v3 API Key (query parameter auth) and a v4 API Read Access Token (bearer header auth). riplex now auto-detects which credential was provided and uses the matching auth scheme, so pasting the Read Access Token no longer fails with `401 Unauthorized`. The setup wizard, GUI welcome screen, and docs now note that either credential works.
|
|
12
|
+
|
|
7
13
|
## v0.9.0 — 2026-06-13
|
|
8
14
|
|
|
9
15
|
Summary: Plex-aligned movie version and edition support for combo-disc releases, with better organize preview matching for 4K, Blu-ray, and 3D movie rips.
|
|
@@ -243,6 +243,9 @@ The setup wizard will:
|
|
|
243
243
|
> enter "riplex" as the app name and `https://github.com/AnyCredit5518/riplex`
|
|
244
244
|
> as the URL. The rest of the form can be filled with basic info -- it
|
|
245
245
|
> doesn't need to be a real business. The key is approved instantly.
|
|
246
|
+
>
|
|
247
|
+
> The settings page offers both an **API Key** (v3) and an **API Read
|
|
248
|
+
> Access Token** (v4). riplex accepts either one.
|
|
246
249
|
|
|
247
250
|
2. Ask where your Plex library and MakeMKV rip folders are
|
|
248
251
|
3. Check for required tools (MakeMKV, ffprobe, mkvmerge, mkvpropedit)
|
|
@@ -74,6 +74,8 @@ riplex setup --force
|
|
|
74
74
|
|
|
75
75
|
**Solution:** TMDb asks for an app name and URL when you request a key. You can enter "riplex" as the app name and `https://github.com/AnyCredit5518/riplex` as the URL. The rest of the form can be filled with basic info - it doesn't need to be a real business. The key is approved instantly.
|
|
76
76
|
|
|
77
|
+
The settings page shows two credentials: an **API Key** (v3) and an **API Read Access Token** (v4). riplex accepts either one - paste whichever you copied. If you get a `401 Unauthorized` error, double-check that the value was copied in full.
|
|
78
|
+
|
|
77
79
|
Sign up and request a key at: https://www.themoviedb.org/settings/api
|
|
78
80
|
|
|
79
81
|
---
|
|
@@ -28,22 +28,44 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
|
|
28
28
|
_TMDB_TTL_DAYS = 7
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _looks_like_read_access_token(key: str) -> bool:
|
|
32
|
+
"""Return ``True`` when *key* looks like a TMDb v4 Read Access Token.
|
|
33
|
+
|
|
34
|
+
TMDb's settings page offers two credentials: a v3 ``API Key`` (a 32-char
|
|
35
|
+
hex string used as the ``api_key`` query parameter) and a v4 ``API Read
|
|
36
|
+
Access Token`` (a JWT used as an ``Authorization: Bearer`` header). The
|
|
37
|
+
v4 token is a JWT, so it starts with ``eyJ`` and contains exactly two
|
|
38
|
+
dots; a v3 key never does.
|
|
39
|
+
"""
|
|
40
|
+
return key.startswith("eyJ") and key.count(".") == 2
|
|
41
|
+
|
|
42
|
+
|
|
31
43
|
class TmdbProvider(MetadataProvider):
|
|
32
44
|
"""Metadata provider backed by TMDb API v3."""
|
|
33
45
|
|
|
34
46
|
def __init__(self, api_key: str | None = None) -> None:
|
|
35
|
-
self._api_key = api_key or os.environ.get("TMDB_API_KEY", "")
|
|
47
|
+
self._api_key = (api_key or os.environ.get("TMDB_API_KEY", "")).strip()
|
|
36
48
|
if not self._api_key:
|
|
37
49
|
raise ValueError(
|
|
38
50
|
"TMDb API key is required. Pass --api-key, set the "
|
|
39
51
|
"TMDB_API_KEY environment variable, or add tmdb_api_key "
|
|
40
52
|
"to your config file."
|
|
41
53
|
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
# TMDb accepts either a v3 API Key (query parameter) or a v4 Read
|
|
55
|
+
# Access Token (Authorization header) against the v3 endpoints. Pick
|
|
56
|
+
# the matching auth scheme so both credential types work.
|
|
57
|
+
if _looks_like_read_access_token(self._api_key):
|
|
58
|
+
self._client = httpx.AsyncClient(
|
|
59
|
+
base_url=TMDB_BASE_URL,
|
|
60
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
61
|
+
timeout=30.0,
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
self._client = httpx.AsyncClient(
|
|
65
|
+
base_url=TMDB_BASE_URL,
|
|
66
|
+
params={"api_key": self._api_key},
|
|
67
|
+
timeout=30.0,
|
|
68
|
+
)
|
|
47
69
|
|
|
48
70
|
async def close(self) -> None:
|
|
49
71
|
await self._client.aclose()
|
|
@@ -61,6 +83,13 @@ class TmdbProvider(MetadataProvider):
|
|
|
61
83
|
if cached is not None:
|
|
62
84
|
return cached
|
|
63
85
|
resp = await self._client.get(path, params=params)
|
|
86
|
+
if resp.status_code == 401:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"TMDb rejected the credential (401 Unauthorized). Check that "
|
|
89
|
+
"your TMDb API Key (v3) or API Read Access Token (v4) is "
|
|
90
|
+
"correct. Both are available at "
|
|
91
|
+
"https://www.themoviedb.org/settings/api."
|
|
92
|
+
)
|
|
64
93
|
resp.raise_for_status()
|
|
65
94
|
data = resp.json()
|
|
66
95
|
if cache_ns and cache_key:
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import platform
|
|
8
9
|
import shutil
|
|
9
10
|
import subprocess
|
|
@@ -30,16 +31,44 @@ _FFPROBE_SEARCH_PATHS = [
|
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _windows_ffprobe_candidates() -> list[Path]:
|
|
35
|
+
"""Return likely ``ffprobe.exe`` locations from winget and manual installs.
|
|
36
|
+
|
|
37
|
+
riplex installs FFmpeg via ``winget install Gyan.FFmpeg``, which is a
|
|
38
|
+
portable package: winget drops a shim under ``WinGet\\Links`` and unpacks
|
|
39
|
+
the payload under ``WinGet\\Packages\\Gyan.FFmpeg*\\...\\bin``. Neither is on
|
|
40
|
+
PATH in the current process right after install, so check them directly.
|
|
41
|
+
"""
|
|
42
|
+
candidates: list[Path] = []
|
|
43
|
+
local_appdata = os.environ.get("LOCALAPPDATA")
|
|
44
|
+
if local_appdata:
|
|
45
|
+
winget = Path(local_appdata) / "Microsoft" / "WinGet"
|
|
46
|
+
candidates.append(winget / "Links" / "ffprobe.exe")
|
|
47
|
+
packages = winget / "Packages"
|
|
48
|
+
if packages.is_dir():
|
|
49
|
+
candidates.extend(packages.glob("Gyan.FFmpeg*/**/bin/ffprobe.exe"))
|
|
50
|
+
# Common manual install spots
|
|
51
|
+
candidates.append(Path(r"C:\ffmpeg\bin\ffprobe.exe"))
|
|
52
|
+
candidates.append(Path(r"C:\Program Files\ffmpeg\bin\ffprobe.exe"))
|
|
53
|
+
return candidates
|
|
54
|
+
|
|
55
|
+
|
|
33
56
|
def find_ffprobe() -> str | None:
|
|
34
57
|
"""Locate the ffprobe executable.
|
|
35
58
|
|
|
36
59
|
Checks PATH first, then ``~/.riplex/bin/``, ``/usr/local/bin/``,
|
|
37
|
-
and ``/opt/homebrew/bin/``.
|
|
60
|
+
and ``/opt/homebrew/bin/``. On Windows, also checks the winget
|
|
61
|
+
portable install locations (the ``WinGet\\Links`` shim and the
|
|
62
|
+
``WinGet\\Packages\\Gyan.FFmpeg*`` payload) and common manual install
|
|
63
|
+
folders. Returns the path string or *None*.
|
|
38
64
|
"""
|
|
39
65
|
path = shutil.which("ffprobe")
|
|
40
66
|
if path:
|
|
41
67
|
return path
|
|
42
|
-
|
|
68
|
+
candidates = list(_FFPROBE_SEARCH_PATHS)
|
|
69
|
+
if platform.system() == "Windows":
|
|
70
|
+
candidates.extend(_windows_ffprobe_candidates())
|
|
71
|
+
for candidate in candidates:
|
|
43
72
|
if candidate.is_file():
|
|
44
73
|
return str(candidate)
|
|
45
74
|
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.3
|
|
4
4
|
Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -164,8 +164,9 @@ class WelcomeScreen:
|
|
|
164
164
|
ft.Container(height=4),
|
|
165
165
|
self.api_key_field,
|
|
166
166
|
ft.Text(
|
|
167
|
-
"Required. Get a free API
|
|
168
|
-
"used to look up movie and
|
|
167
|
+
"Required. Get a free API Key or Read Access Token at "
|
|
168
|
+
"themoviedb.org/settings/api — used to look up movie and "
|
|
169
|
+
"TV show metadata.",
|
|
169
170
|
size=11,
|
|
170
171
|
color=ft.Colors.GREY_600,
|
|
171
172
|
),
|
|
@@ -77,7 +77,7 @@ def run_setup(force: bool = False) -> int:
|
|
|
77
77
|
value = input(f"{label}{prompt_hint}{suffix}: ").strip()
|
|
78
78
|
return value if value else current
|
|
79
79
|
|
|
80
|
-
tmdb_key = prompt("tmdb_api_key", "TMDb API key", "free at themoviedb.org/settings/api", mask=True)
|
|
80
|
+
tmdb_key = prompt("tmdb_api_key", "TMDb API key or Read Access Token", "free at themoviedb.org/settings/api", mask=True)
|
|
81
81
|
output_root = prompt("output_root", "Plex library root", "e.g. E:/Media")
|
|
82
82
|
rip_output = prompt("rip_output", "MakeMKV rip output folder", "e.g. E:/Media/Rips")
|
|
83
83
|
archive_root = prompt("archive_root", "Archive root (optional)", "move raw rips here after organizing")
|
|
@@ -6,7 +6,7 @@ from unittest.mock import patch
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
from riplex.models import ScannedFile
|
|
9
|
-
from riplex.scanner import _probe_file, scan_folder
|
|
9
|
+
from riplex.scanner import _probe_file, find_ffprobe, scan_folder
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@pytest.fixture
|
|
@@ -103,3 +103,62 @@ class TestScanFolder:
|
|
|
103
103
|
for disc in discs:
|
|
104
104
|
for f in disc.files:
|
|
105
105
|
assert Path(f.path).is_absolute()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestFindFfprobe:
|
|
109
|
+
def test_returns_path_when_on_path(self):
|
|
110
|
+
with patch("riplex.scanner.shutil.which", return_value="/usr/bin/ffprobe"):
|
|
111
|
+
assert find_ffprobe() == "/usr/bin/ffprobe"
|
|
112
|
+
|
|
113
|
+
def test_returns_none_when_not_found(self, monkeypatch):
|
|
114
|
+
monkeypatch.setattr("riplex.scanner.shutil.which", lambda _: None)
|
|
115
|
+
monkeypatch.setattr("riplex.scanner.platform.system", lambda: "Linux")
|
|
116
|
+
monkeypatch.setattr("riplex.scanner._FFPROBE_SEARCH_PATHS", [])
|
|
117
|
+
assert find_ffprobe() is None
|
|
118
|
+
|
|
119
|
+
def test_finds_winget_links_shim_on_windows(self, tmp_path, monkeypatch):
|
|
120
|
+
local_appdata = tmp_path / "AppData" / "Local"
|
|
121
|
+
links = local_appdata / "Microsoft" / "WinGet" / "Links"
|
|
122
|
+
links.mkdir(parents=True)
|
|
123
|
+
shim = links / "ffprobe.exe"
|
|
124
|
+
shim.write_text("", encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
monkeypatch.setattr("riplex.scanner.shutil.which", lambda _: None)
|
|
127
|
+
monkeypatch.setattr("riplex.scanner.platform.system", lambda: "Windows")
|
|
128
|
+
monkeypatch.setattr("riplex.scanner._FFPROBE_SEARCH_PATHS", [])
|
|
129
|
+
monkeypatch.setenv("LOCALAPPDATA", str(local_appdata))
|
|
130
|
+
|
|
131
|
+
assert find_ffprobe() == str(shim)
|
|
132
|
+
|
|
133
|
+
def test_finds_winget_package_payload_on_windows(self, tmp_path, monkeypatch):
|
|
134
|
+
local_appdata = tmp_path / "AppData" / "Local"
|
|
135
|
+
bin_dir = (
|
|
136
|
+
local_appdata / "Microsoft" / "WinGet" / "Packages"
|
|
137
|
+
/ "Gyan.FFmpeg_Microsoft.Winget.Source_abc"
|
|
138
|
+
/ "ffmpeg-7.0-full_build" / "bin"
|
|
139
|
+
)
|
|
140
|
+
bin_dir.mkdir(parents=True)
|
|
141
|
+
exe = bin_dir / "ffprobe.exe"
|
|
142
|
+
exe.write_text("", encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr("riplex.scanner.shutil.which", lambda _: None)
|
|
145
|
+
monkeypatch.setattr("riplex.scanner.platform.system", lambda: "Windows")
|
|
146
|
+
monkeypatch.setattr("riplex.scanner._FFPROBE_SEARCH_PATHS", [])
|
|
147
|
+
monkeypatch.setenv("LOCALAPPDATA", str(local_appdata))
|
|
148
|
+
|
|
149
|
+
assert find_ffprobe() == str(exe)
|
|
150
|
+
|
|
151
|
+
def test_windows_candidates_skipped_on_non_windows(self, monkeypatch):
|
|
152
|
+
monkeypatch.setattr("riplex.scanner.shutil.which", lambda _: None)
|
|
153
|
+
monkeypatch.setattr("riplex.scanner.platform.system", lambda: "Linux")
|
|
154
|
+
monkeypatch.setattr("riplex.scanner._FFPROBE_SEARCH_PATHS", [])
|
|
155
|
+
called = {"hit": False}
|
|
156
|
+
|
|
157
|
+
def _should_not_run():
|
|
158
|
+
called["hit"] = True
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
monkeypatch.setattr("riplex.scanner._windows_ffprobe_candidates", _should_not_run)
|
|
162
|
+
assert find_ffprobe() is None
|
|
163
|
+
assert called["hit"] is False
|
|
164
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from riplex.metadata.sources.tmdb import (
|
|
4
|
+
TmdbProvider,
|
|
5
|
+
_looks_like_read_access_token,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
# A JWT-shaped sample (header.payload.signature). Not a real credential.
|
|
9
|
+
_SAMPLE_READ_ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyaXBsZXgifQ.c2lnbmF0dXJl"
|
|
10
|
+
_SAMPLE_API_KEY = "0123456789abcdef0123456789abcdef"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestLooksLikeReadAccessToken:
|
|
14
|
+
def test_jwt_token_is_detected(self):
|
|
15
|
+
assert _looks_like_read_access_token(_SAMPLE_READ_ACCESS_TOKEN) is True
|
|
16
|
+
|
|
17
|
+
def test_v3_hex_key_is_not_detected(self):
|
|
18
|
+
assert _looks_like_read_access_token(_SAMPLE_API_KEY) is False
|
|
19
|
+
|
|
20
|
+
def test_empty_string_is_not_detected(self):
|
|
21
|
+
assert _looks_like_read_access_token("") is False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestTmdbProviderAuth:
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_read_access_token_uses_authorization_header(self):
|
|
27
|
+
provider = TmdbProvider(api_key=_SAMPLE_READ_ACCESS_TOKEN)
|
|
28
|
+
try:
|
|
29
|
+
assert (
|
|
30
|
+
provider._client.headers["Authorization"]
|
|
31
|
+
== f"Bearer {_SAMPLE_READ_ACCESS_TOKEN}"
|
|
32
|
+
)
|
|
33
|
+
assert "api_key" not in provider._client.params
|
|
34
|
+
finally:
|
|
35
|
+
await provider.close()
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_v3_key_uses_query_parameter(self):
|
|
39
|
+
provider = TmdbProvider(api_key=_SAMPLE_API_KEY)
|
|
40
|
+
try:
|
|
41
|
+
assert provider._client.params["api_key"] == _SAMPLE_API_KEY
|
|
42
|
+
assert "Authorization" not in provider._client.headers
|
|
43
|
+
finally:
|
|
44
|
+
await provider.close()
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_whitespace_is_stripped_from_credential(self):
|
|
48
|
+
provider = TmdbProvider(api_key=f" {_SAMPLE_API_KEY} ")
|
|
49
|
+
try:
|
|
50
|
+
assert provider._client.params["api_key"] == _SAMPLE_API_KEY
|
|
51
|
+
finally:
|
|
52
|
+
await provider.close()
|
|
53
|
+
|
|
54
|
+
def test_missing_key_raises(self, monkeypatch):
|
|
55
|
+
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
|
56
|
+
with pytest.raises(ValueError, match="TMDb API key is required"):
|
|
57
|
+
TmdbProvider(api_key="")
|
|
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
|
|
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
|