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.
Files changed (142) hide show
  1. {riplex-0.9.1/src/riplex.egg-info → riplex-0.9.3}/PKG-INFO +1 -1
  2. {riplex-0.9.1 → riplex-0.9.3}/docs/changelog.md +6 -0
  3. {riplex-0.9.1 → riplex-0.9.3}/docs/getting-started/installation.md +3 -0
  4. {riplex-0.9.1 → riplex-0.9.3}/docs/troubleshooting.md +2 -0
  5. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/sources/tmdb.py +35 -6
  6. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/scanner.py +31 -2
  7. {riplex-0.9.1 → riplex-0.9.3/src/riplex.egg-info}/PKG-INFO +1 -1
  8. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/SOURCES.txt +1 -0
  9. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/scm_file_list.json +1 -0
  10. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/scm_version.json +2 -2
  11. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/welcome.py +3 -2
  12. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/setup.py +1 -1
  13. {riplex-0.9.1 → riplex-0.9.3}/tests/test_scanner.py +60 -1
  14. riplex-0.9.3/tests/test_tmdb_provider.py +57 -0
  15. {riplex-0.9.1 → riplex-0.9.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {riplex-0.9.1 → riplex-0.9.3}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  17. {riplex-0.9.1 → riplex-0.9.3}/.github/agents/riplex.agent.md +0 -0
  18. {riplex-0.9.1 → riplex-0.9.3}/.github/copilot-instructions.md +0 -0
  19. {riplex-0.9.1 → riplex-0.9.3}/.github/workflows/publish.yml +0 -0
  20. {riplex-0.9.1 → riplex-0.9.3}/.github/workflows/release.yml +0 -0
  21. {riplex-0.9.1 → riplex-0.9.3}/.gitignore +0 -0
  22. {riplex-0.9.1 → riplex-0.9.3}/.vscode/settings.json +0 -0
  23. {riplex-0.9.1 → riplex-0.9.3}/CONTRIBUTORS.md +0 -0
  24. {riplex-0.9.1 → riplex-0.9.3}/LICENSE +0 -0
  25. {riplex-0.9.1 → riplex-0.9.3}/README.md +0 -0
  26. {riplex-0.9.1 → riplex-0.9.3}/docs/architecture.md +0 -0
  27. {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/lookup.md +0 -0
  28. {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/orchestrate.md +0 -0
  29. {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/organize.md +0 -0
  30. {riplex-0.9.1 → riplex-0.9.3}/docs/cli-guide/workflow.md +0 -0
  31. {riplex-0.9.1 → riplex-0.9.3}/docs/getting-started/configuration.md +0 -0
  32. {riplex-0.9.1 → riplex-0.9.3}/docs/gui-guide/gui-walkthrough.md +0 -0
  33. {riplex-0.9.1 → riplex-0.9.3}/docs/index.md +0 -0
  34. {riplex-0.9.1 → riplex-0.9.3}/docs/naming-rules.md +0 -0
  35. {riplex-0.9.1 → riplex-0.9.3}/docs/reference/cli.md +0 -0
  36. {riplex-0.9.1 → riplex-0.9.3}/issues/debug-artifacts-consolidation.md +0 -0
  37. {riplex-0.9.1 → riplex-0.9.3}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
  38. {riplex-0.9.1 → riplex-0.9.3}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  39. {riplex-0.9.1 → riplex-0.9.3}/issues/planned-features.md +0 -0
  40. {riplex-0.9.1 → riplex-0.9.3}/mkdocs.yml +0 -0
  41. {riplex-0.9.1 → riplex-0.9.3}/pyproject.toml +0 -0
  42. {riplex-0.9.1 → riplex-0.9.3}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  43. {riplex-0.9.1 → riplex-0.9.3}/screenshots/1_Welcome_Screen.png +0 -0
  44. {riplex-0.9.1 → riplex-0.9.3}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  45. {riplex-0.9.1 → riplex-0.9.3}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  46. {riplex-0.9.1 → riplex-0.9.3}/screenshots/4_Disc_Release_BTTF.png +0 -0
  47. {riplex-0.9.1 → riplex-0.9.3}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  48. {riplex-0.9.1 → riplex-0.9.3}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  49. {riplex-0.9.1 → riplex-0.9.3}/setup.cfg +0 -0
  50. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/__init__.py +0 -0
  51. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/cache.py +0 -0
  52. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/config.py +0 -0
  53. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/dedup.py +0 -0
  54. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/detect.py +0 -0
  55. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/__init__.py +0 -0
  56. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/analysis.py +0 -0
  57. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/makemkv.py +0 -0
  58. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/disc/provider.py +0 -0
  59. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/formatter.py +0 -0
  60. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/lookup.py +0 -0
  61. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/manifest.py +0 -0
  62. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/matcher.py +0 -0
  63. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/__init__.py +0 -0
  64. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/planner.py +0 -0
  65. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/provider.py +0 -0
  66. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/metadata/sources/__init__.py +0 -0
  67. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/models.py +0 -0
  68. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/normalize.py +0 -0
  69. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/organizer.py +0 -0
  70. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/snapshot.py +0 -0
  71. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/splitter.py +0 -0
  72. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/tagger.py +0 -0
  73. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/title.py +0 -0
  74. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/ui.py +0 -0
  75. {riplex-0.9.1 → riplex-0.9.3}/src/riplex/updater.py +0 -0
  76. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/dependency_links.txt +0 -0
  77. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/entry_points.txt +0 -0
  78. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/requires.txt +0 -0
  79. {riplex-0.9.1 → riplex-0.9.3}/src/riplex.egg-info/top_level.txt +0 -0
  80. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/__init__.py +0 -0
  81. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/bug_report.py +0 -0
  82. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/crash_dump.py +0 -0
  83. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/keep_awake.py +0 -0
  84. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/main.py +0 -0
  85. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/__init__.py +0 -0
  86. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/disc_detection.py +0 -0
  87. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/disc_overview.py +0 -0
  88. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/disc_swap.py +0 -0
  89. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/done.py +0 -0
  90. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/folder_picker.py +0 -0
  91. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/metadata.py +0 -0
  92. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/orchestrate_done.py +0 -0
  93. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/organize_done.py +0 -0
  94. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/organize_preview.py +0 -0
  95. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/progress.py +0 -0
  96. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/release.py +0 -0
  97. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/selection.py +0 -0
  98. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_app/screens/update.py +0 -0
  99. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/__init__.py +0 -0
  100. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/__init__.py +0 -0
  101. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/lookup.py +0 -0
  102. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/orchestrate.py +0 -0
  103. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/organize.py +0 -0
  104. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/commands/rip.py +0 -0
  105. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/formatting.py +0 -0
  106. {riplex-0.9.1 → riplex-0.9.3}/src/riplex_cli/main.py +0 -0
  107. {riplex-0.9.1 → riplex-0.9.3}/tests/__init__.py +0 -0
  108. {riplex-0.9.1 → riplex-0.9.3}/tests/fixtures/chernobyl_disc1.json +0 -0
  109. {riplex-0.9.1 → riplex-0.9.3}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  110. {riplex-0.9.1 → riplex-0.9.3}/tests/fixtures/makemkvcon_list.txt +0 -0
  111. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  112. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  113. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  114. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  115. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  116. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  117. {riplex-0.9.1 → riplex-0.9.3}/tests/snapshots/Waterworld.snapshot.json +0 -0
  118. {riplex-0.9.1 → riplex-0.9.3}/tests/test_app_main.py +0 -0
  119. {riplex-0.9.1 → riplex-0.9.3}/tests/test_cache.py +0 -0
  120. {riplex-0.9.1 → riplex-0.9.3}/tests/test_cli_utils.py +0 -0
  121. {riplex-0.9.1 → riplex-0.9.3}/tests/test_config.py +0 -0
  122. {riplex-0.9.1 → riplex-0.9.3}/tests/test_dedup.py +0 -0
  123. {riplex-0.9.1 → riplex-0.9.3}/tests/test_detect.py +0 -0
  124. {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_analysis.py +0 -0
  125. {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_detection_screen.py +0 -0
  126. {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_fixtures.py +0 -0
  127. {riplex-0.9.1 → riplex-0.9.3}/tests/test_disc_provider.py +0 -0
  128. {riplex-0.9.1 → riplex-0.9.3}/tests/test_formatter.py +0 -0
  129. {riplex-0.9.1 → riplex-0.9.3}/tests/test_lookup.py +0 -0
  130. {riplex-0.9.1 → riplex-0.9.3}/tests/test_makemkv.py +0 -0
  131. {riplex-0.9.1 → riplex-0.9.3}/tests/test_matcher.py +0 -0
  132. {riplex-0.9.1 → riplex-0.9.3}/tests/test_normalize.py +0 -0
  133. {riplex-0.9.1 → riplex-0.9.3}/tests/test_organizer.py +0 -0
  134. {riplex-0.9.1 → riplex-0.9.3}/tests/test_planner.py +0 -0
  135. {riplex-0.9.1 → riplex-0.9.3}/tests/test_release_screen.py +0 -0
  136. {riplex-0.9.1 → riplex-0.9.3}/tests/test_rip_guide.py +0 -0
  137. {riplex-0.9.1 → riplex-0.9.3}/tests/test_snapshot.py +0 -0
  138. {riplex-0.9.1 → riplex-0.9.3}/tests/test_splitter.py +0 -0
  139. {riplex-0.9.1 → riplex-0.9.3}/tests/test_tagger.py +0 -0
  140. {riplex-0.9.1 → riplex-0.9.3}/tests/test_ui.py +0 -0
  141. {riplex-0.9.1 → riplex-0.9.3}/tests/test_updater.py +0 -0
  142. {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.1
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
- self._client = httpx.AsyncClient(
43
- base_url=TMDB_BASE_URL,
44
- params={"api_key": self._api_key},
45
- timeout=30.0,
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/``. Returns the path string or *None*.
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
- for candidate in _FFPROBE_SEARCH_PATHS:
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.1
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
@@ -124,6 +124,7 @@ tests/test_scanner.py
124
124
  tests/test_snapshot.py
125
125
  tests/test_splitter.py
126
126
  tests/test_tagger.py
127
+ tests/test_tmdb_provider.py
127
128
  tests/test_ui.py
128
129
  tests/test_updater.py
129
130
  tests/test_welcome_screen.py
@@ -101,6 +101,7 @@
101
101
  "tests/test_snapshot.py",
102
102
  "tests/test_formatter.py",
103
103
  "tests/test_updater.py",
104
+ "tests/test_tmdb_provider.py",
104
105
  "tests/test_normalize.py",
105
106
  "tests/test_disc_analysis.py",
106
107
  "tests/test_cache.py",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.9.1",
2
+ "tag": "0.9.3",
3
3
  "distance": 0,
4
- "node": "g1987f1d9a7fb9e2034793b92c4a94e7cd198682f",
4
+ "node": "g266d2beebd49f78f7e0d5ac851062e54b43dc545",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-06-24"
@@ -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 key at themoviedb.org/settings/api "
168
- "used to look up movie and TV show metadata.",
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