riplex 0.9.0__tar.gz → 0.9.2__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 (143) hide show
  1. {riplex-0.9.0 → riplex-0.9.2}/.github/workflows/release.yml +20 -0
  2. {riplex-0.9.0/src/riplex.egg-info → riplex-0.9.2}/PKG-INFO +2 -1
  3. {riplex-0.9.0 → riplex-0.9.2}/docs/changelog.md +6 -0
  4. {riplex-0.9.0 → riplex-0.9.2}/docs/getting-started/installation.md +3 -0
  5. {riplex-0.9.0 → riplex-0.9.2}/docs/troubleshooting.md +2 -0
  6. {riplex-0.9.0 → riplex-0.9.2}/pyproject.toml +1 -0
  7. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/metadata/sources/tmdb.py +35 -6
  8. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/snapshot.py +36 -1
  9. {riplex-0.9.0 → riplex-0.9.2/src/riplex.egg-info}/PKG-INFO +2 -1
  10. {riplex-0.9.0 → riplex-0.9.2}/src/riplex.egg-info/SOURCES.txt +4 -0
  11. {riplex-0.9.0 → riplex-0.9.2}/src/riplex.egg-info/requires.txt +1 -0
  12. riplex-0.9.2/src/riplex.egg-info/scm_file_list.json +136 -0
  13. riplex-0.9.2/src/riplex.egg-info/scm_version.json +8 -0
  14. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/main.py +49 -0
  15. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/disc_overview.py +44 -3
  16. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/release.py +20 -3
  17. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/selection.py +93 -18
  18. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/welcome.py +3 -2
  19. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/commands/setup.py +1 -1
  20. riplex-0.9.2/tests/test_app_main.py +94 -0
  21. riplex-0.9.2/tests/test_release_screen.py +72 -0
  22. {riplex-0.9.0 → riplex-0.9.2}/tests/test_snapshot.py +37 -0
  23. riplex-0.9.2/tests/test_tmdb_provider.py +57 -0
  24. riplex-0.9.0/tests/test_release_screen.py +0 -40
  25. {riplex-0.9.0 → riplex-0.9.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {riplex-0.9.0 → riplex-0.9.2}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  27. {riplex-0.9.0 → riplex-0.9.2}/.github/agents/riplex.agent.md +0 -0
  28. {riplex-0.9.0 → riplex-0.9.2}/.github/copilot-instructions.md +0 -0
  29. {riplex-0.9.0 → riplex-0.9.2}/.github/workflows/publish.yml +0 -0
  30. {riplex-0.9.0 → riplex-0.9.2}/.gitignore +0 -0
  31. {riplex-0.9.0 → riplex-0.9.2}/.vscode/settings.json +0 -0
  32. {riplex-0.9.0 → riplex-0.9.2}/CONTRIBUTORS.md +0 -0
  33. {riplex-0.9.0 → riplex-0.9.2}/LICENSE +0 -0
  34. {riplex-0.9.0 → riplex-0.9.2}/README.md +0 -0
  35. {riplex-0.9.0 → riplex-0.9.2}/docs/architecture.md +0 -0
  36. {riplex-0.9.0 → riplex-0.9.2}/docs/cli-guide/lookup.md +0 -0
  37. {riplex-0.9.0 → riplex-0.9.2}/docs/cli-guide/orchestrate.md +0 -0
  38. {riplex-0.9.0 → riplex-0.9.2}/docs/cli-guide/organize.md +0 -0
  39. {riplex-0.9.0 → riplex-0.9.2}/docs/cli-guide/workflow.md +0 -0
  40. {riplex-0.9.0 → riplex-0.9.2}/docs/getting-started/configuration.md +0 -0
  41. {riplex-0.9.0 → riplex-0.9.2}/docs/gui-guide/gui-walkthrough.md +0 -0
  42. {riplex-0.9.0 → riplex-0.9.2}/docs/index.md +0 -0
  43. {riplex-0.9.0 → riplex-0.9.2}/docs/naming-rules.md +0 -0
  44. {riplex-0.9.0 → riplex-0.9.2}/docs/reference/cli.md +0 -0
  45. {riplex-0.9.0 → riplex-0.9.2}/issues/debug-artifacts-consolidation.md +0 -0
  46. {riplex-0.9.0 → riplex-0.9.2}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
  47. {riplex-0.9.0 → riplex-0.9.2}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  48. {riplex-0.9.0 → riplex-0.9.2}/issues/planned-features.md +0 -0
  49. {riplex-0.9.0 → riplex-0.9.2}/mkdocs.yml +0 -0
  50. {riplex-0.9.0 → riplex-0.9.2}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  51. {riplex-0.9.0 → riplex-0.9.2}/screenshots/1_Welcome_Screen.png +0 -0
  52. {riplex-0.9.0 → riplex-0.9.2}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  53. {riplex-0.9.0 → riplex-0.9.2}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  54. {riplex-0.9.0 → riplex-0.9.2}/screenshots/4_Disc_Release_BTTF.png +0 -0
  55. {riplex-0.9.0 → riplex-0.9.2}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  56. {riplex-0.9.0 → riplex-0.9.2}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  57. {riplex-0.9.0 → riplex-0.9.2}/setup.cfg +0 -0
  58. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/__init__.py +0 -0
  59. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/cache.py +0 -0
  60. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/config.py +0 -0
  61. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/dedup.py +0 -0
  62. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/detect.py +0 -0
  63. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/disc/__init__.py +0 -0
  64. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/disc/analysis.py +0 -0
  65. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/disc/makemkv.py +0 -0
  66. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/disc/provider.py +0 -0
  67. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/formatter.py +0 -0
  68. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/lookup.py +0 -0
  69. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/manifest.py +0 -0
  70. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/matcher.py +0 -0
  71. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/metadata/__init__.py +0 -0
  72. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/metadata/planner.py +0 -0
  73. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/metadata/provider.py +0 -0
  74. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/metadata/sources/__init__.py +0 -0
  75. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/models.py +0 -0
  76. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/normalize.py +0 -0
  77. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/organizer.py +0 -0
  78. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/scanner.py +0 -0
  79. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/splitter.py +0 -0
  80. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/tagger.py +0 -0
  81. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/title.py +0 -0
  82. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/ui.py +0 -0
  83. {riplex-0.9.0 → riplex-0.9.2}/src/riplex/updater.py +0 -0
  84. {riplex-0.9.0 → riplex-0.9.2}/src/riplex.egg-info/dependency_links.txt +0 -0
  85. {riplex-0.9.0 → riplex-0.9.2}/src/riplex.egg-info/entry_points.txt +0 -0
  86. {riplex-0.9.0 → riplex-0.9.2}/src/riplex.egg-info/top_level.txt +0 -0
  87. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/__init__.py +0 -0
  88. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/bug_report.py +0 -0
  89. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/crash_dump.py +0 -0
  90. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/keep_awake.py +0 -0
  91. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/__init__.py +0 -0
  92. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/disc_detection.py +0 -0
  93. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/disc_swap.py +0 -0
  94. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/done.py +0 -0
  95. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/folder_picker.py +0 -0
  96. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/metadata.py +0 -0
  97. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/orchestrate_done.py +0 -0
  98. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/organize_done.py +0 -0
  99. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/organize_preview.py +0 -0
  100. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/progress.py +0 -0
  101. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_app/screens/update.py +0 -0
  102. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/__init__.py +0 -0
  103. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/commands/__init__.py +0 -0
  104. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/commands/lookup.py +0 -0
  105. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/commands/orchestrate.py +0 -0
  106. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/commands/organize.py +0 -0
  107. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/commands/rip.py +0 -0
  108. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/formatting.py +0 -0
  109. {riplex-0.9.0 → riplex-0.9.2}/src/riplex_cli/main.py +0 -0
  110. {riplex-0.9.0 → riplex-0.9.2}/tests/__init__.py +0 -0
  111. {riplex-0.9.0 → riplex-0.9.2}/tests/fixtures/chernobyl_disc1.json +0 -0
  112. {riplex-0.9.0 → riplex-0.9.2}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  113. {riplex-0.9.0 → riplex-0.9.2}/tests/fixtures/makemkvcon_list.txt +0 -0
  114. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  115. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  116. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  117. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  118. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  119. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  120. {riplex-0.9.0 → riplex-0.9.2}/tests/snapshots/Waterworld.snapshot.json +0 -0
  121. {riplex-0.9.0 → riplex-0.9.2}/tests/test_cache.py +0 -0
  122. {riplex-0.9.0 → riplex-0.9.2}/tests/test_cli_utils.py +0 -0
  123. {riplex-0.9.0 → riplex-0.9.2}/tests/test_config.py +0 -0
  124. {riplex-0.9.0 → riplex-0.9.2}/tests/test_dedup.py +0 -0
  125. {riplex-0.9.0 → riplex-0.9.2}/tests/test_detect.py +0 -0
  126. {riplex-0.9.0 → riplex-0.9.2}/tests/test_disc_analysis.py +0 -0
  127. {riplex-0.9.0 → riplex-0.9.2}/tests/test_disc_detection_screen.py +0 -0
  128. {riplex-0.9.0 → riplex-0.9.2}/tests/test_disc_fixtures.py +0 -0
  129. {riplex-0.9.0 → riplex-0.9.2}/tests/test_disc_provider.py +0 -0
  130. {riplex-0.9.0 → riplex-0.9.2}/tests/test_formatter.py +0 -0
  131. {riplex-0.9.0 → riplex-0.9.2}/tests/test_lookup.py +0 -0
  132. {riplex-0.9.0 → riplex-0.9.2}/tests/test_makemkv.py +0 -0
  133. {riplex-0.9.0 → riplex-0.9.2}/tests/test_matcher.py +0 -0
  134. {riplex-0.9.0 → riplex-0.9.2}/tests/test_normalize.py +0 -0
  135. {riplex-0.9.0 → riplex-0.9.2}/tests/test_organizer.py +0 -0
  136. {riplex-0.9.0 → riplex-0.9.2}/tests/test_planner.py +0 -0
  137. {riplex-0.9.0 → riplex-0.9.2}/tests/test_rip_guide.py +0 -0
  138. {riplex-0.9.0 → riplex-0.9.2}/tests/test_scanner.py +0 -0
  139. {riplex-0.9.0 → riplex-0.9.2}/tests/test_splitter.py +0 -0
  140. {riplex-0.9.0 → riplex-0.9.2}/tests/test_tagger.py +0 -0
  141. {riplex-0.9.0 → riplex-0.9.2}/tests/test_ui.py +0 -0
  142. {riplex-0.9.0 → riplex-0.9.2}/tests/test_updater.py +0 -0
  143. {riplex-0.9.0 → riplex-0.9.2}/tests/test_welcome_screen.py +0 -0
@@ -25,6 +25,10 @@ jobs:
25
25
  pip install -e ".[gui]"
26
26
  pip install flet-desktop pyinstaller
27
27
 
28
+ - name: Cache Flet desktop runtime
29
+ run: |
30
+ python -c "import flet_desktop; flet_desktop.ensure_client_cached()"
31
+
28
32
  - name: Build CLI executable
29
33
  run: |
30
34
  pyinstaller --name riplex --onefile --console ^
@@ -33,12 +37,15 @@ jobs:
33
37
  --hidden-import riplex_cli.commands.organize ^
34
38
  --hidden-import riplex_cli.commands.lookup ^
35
39
  --hidden-import riplex_cli.commands.setup ^
40
+ --hidden-import certifi ^
41
+ --collect-data certifi ^
36
42
  --collect-data dvdcompare_scraper ^
37
43
  src/riplex_cli/main.py
38
44
  shell: cmd
39
45
 
40
46
  - name: Build GUI executable
41
47
  run: |
48
+ for /f "delims=" %%i in ('python -c "import flet_desktop; print(flet_desktop.ensure_client_cached())"') do set FLET_CLIENT_CACHE=%%i
42
49
  pyinstaller --name riplex-ui --onefile --windowed ^
43
50
  --hidden-import riplex_app.screens.welcome ^
44
51
  --hidden-import riplex_app.screens.disc_detection ^
@@ -54,9 +61,12 @@ jobs:
54
61
  --hidden-import riplex_app.screens.disc_overview ^
55
62
  --hidden-import riplex_app.screens.disc_swap ^
56
63
  --hidden-import riplex_app.screens.update ^
64
+ --hidden-import certifi ^
57
65
  --collect-all flet ^
58
66
  --collect-all flet_desktop ^
67
+ --collect-data certifi ^
59
68
  --collect-data dvdcompare_scraper ^
69
+ --add-data "%FLET_CLIENT_CACHE%\flet;flet_client" ^
60
70
  src/riplex_app/main.py
61
71
  shell: cmd
62
72
 
@@ -88,6 +98,10 @@ jobs:
88
98
  pip install -e ".[gui]"
89
99
  pip install flet-desktop pyinstaller
90
100
 
101
+ - name: Cache Flet desktop runtime
102
+ run: |
103
+ python -c "import flet_desktop; flet_desktop.ensure_client_cached()"
104
+
91
105
  - name: Build CLI executable
92
106
  run: |
93
107
  pyinstaller --name riplex --onefile --console \
@@ -96,11 +110,14 @@ jobs:
96
110
  --hidden-import riplex_cli.commands.organize \
97
111
  --hidden-import riplex_cli.commands.lookup \
98
112
  --hidden-import riplex_cli.commands.setup \
113
+ --hidden-import certifi \
114
+ --collect-data certifi \
99
115
  --collect-data dvdcompare_scraper \
100
116
  src/riplex_cli/main.py
101
117
 
102
118
  - name: Build GUI app bundle
103
119
  run: |
120
+ FLET_CLIENT_CACHE="$(python -c 'import flet_desktop; print(flet_desktop.ensure_client_cached())')"
104
121
  pyinstaller --name riplex-ui --onedir --windowed \
105
122
  --hidden-import riplex_app.screens.welcome \
106
123
  --hidden-import riplex_app.screens.disc_detection \
@@ -116,9 +133,12 @@ jobs:
116
133
  --hidden-import riplex_app.screens.disc_overview \
117
134
  --hidden-import riplex_app.screens.disc_swap \
118
135
  --hidden-import riplex_app.screens.update \
136
+ --hidden-import certifi \
119
137
  --collect-all flet \
120
138
  --collect-all flet_desktop \
139
+ --collect-data certifi \
121
140
  --collect-data dvdcompare_scraper \
141
+ --add-data "$FLET_CLIENT_CACHE:flet_client" \
122
142
  src/riplex_app/main.py
123
143
 
124
144
  - name: Package macOS artifacts
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.9.0
3
+ Version: 0.9.2
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
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
+ Requires-Dist: certifi>=2024.2.2
9
10
  Requires-Dist: httpx>=0.27
10
11
  Requires-Dist: dvdcompare-scraper>=0.1.15
11
12
  Requires-Dist: platformdirs>=4.0
@@ -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
  ---
@@ -10,6 +10,7 @@ readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = {text = "MIT"}
12
12
  dependencies = [
13
+ "certifi>=2024.2.2",
13
14
  "httpx>=0.27",
14
15
  "dvdcompare-scraper>=0.1.15",
15
16
  "platformdirs>=4.0",
@@ -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:
@@ -34,7 +34,7 @@ https://github.com/AnyCredit5518/riplex/issues/new?template=bug_report.yml
34
34
  Files you may find here:
35
35
  riplex-rip.snapshot.json - Disc info, metadata, and title classifications
36
36
  riplex-scan.snapshot.json - Scanned file metadata (durations, streams, etc.)
37
- riplex-rip.manifest.json - Map of ripped title indexes to output filenames
37
+ riplex-rip.manifest.json - Map of selected/ripped title indexes to output filenames
38
38
  riplex.log - Debug log from the most recent session
39
39
  """
40
40
 
@@ -121,6 +121,8 @@ def save_rip_snapshot(
121
121
  discs: list | None = None,
122
122
  ripped_titles: list[int] | None = None,
123
123
  selected_titles: list[int] | None = None,
124
+ rippable_titles: list[int] | None = None,
125
+ classifications: dict[int, str] | None = None,
124
126
  phase: str = "complete",
125
127
  ) -> Path | None:
126
128
  """Write ``riplex-rip.snapshot.json`` into *debug_dir*.
@@ -132,16 +134,31 @@ def save_rip_snapshot(
132
134
  Returns the path on success, or ``None`` on failure.
133
135
  """
134
136
  try:
137
+ selected_set = set(selected_titles or [])
138
+ rippable_set = set(rippable_titles or [])
139
+ classifications = classifications or {}
135
140
  data = {
136
141
  "disc_name": disc_info.disc_name,
137
142
  "title_count": len(disc_info.titles),
138
143
  "titles": [
139
144
  {
140
145
  "index": t.index,
146
+ "name": getattr(t, "name", ""),
141
147
  "duration_seconds": t.duration_seconds,
142
148
  "resolution": t.resolution,
143
149
  "size_bytes": t.size_bytes,
144
150
  "chapters": getattr(t, "chapter_count", t.chapters if hasattr(t, "chapters") else None),
151
+ "filename": getattr(t, "filename", ""),
152
+ "playlist": getattr(t, "playlist", ""),
153
+ "video_codec": getattr(t, "video_codec", ""),
154
+ "audio_tracks": list(getattr(t, "audio_tracks", []) or []),
155
+ "subtitle_tracks": list(getattr(t, "subtitle_tracks", []) or []),
156
+ "stream_count": getattr(t, "stream_count", 0),
157
+ "segment_count": getattr(t, "segment_count", 1),
158
+ "segment_map": getattr(t, "segment_map", ""),
159
+ "classification": classifications.get(t.index, ""),
160
+ "recommended": t.index in rippable_set,
161
+ "selected": t.index in selected_set,
145
162
  }
146
163
  for t in disc_info.titles
147
164
  ],
@@ -157,14 +174,32 @@ def save_rip_snapshot(
157
174
  "discs": [
158
175
  {
159
176
  "number": d.number,
177
+ "format": getattr(d, "disc_format", ""),
178
+ "is_film": getattr(d, "is_film", False),
160
179
  "episode_count": len(d.episodes),
161
180
  "extra_count": len(d.extras),
181
+ "episodes": [
182
+ {
183
+ "title": e.title,
184
+ "runtime_seconds": e.runtime_seconds,
185
+ }
186
+ for e in getattr(d, "episodes", [])
187
+ ],
188
+ "extras": [
189
+ {
190
+ "title": e.title,
191
+ "runtime_seconds": e.runtime_seconds,
192
+ "feature_type": getattr(e, "feature_type", ""),
193
+ }
194
+ for e in getattr(d, "extras", [])
195
+ ],
162
196
  }
163
197
  for d in (discs or [])
164
198
  ],
165
199
  },
166
200
  "phase": phase,
167
201
  "selected_titles": selected_titles or [],
202
+ "rippable_titles": rippable_titles or [],
168
203
  "ripped_titles": ripped_titles or [],
169
204
  }
170
205
  snapshot = _v2_envelope("rip", data)
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.9.0
3
+ Version: 0.9.2
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
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
+ Requires-Dist: certifi>=2024.2.2
9
10
  Requires-Dist: httpx>=0.27
10
11
  Requires-Dist: dvdcompare-scraper>=0.1.15
11
12
  Requires-Dist: platformdirs>=4.0
@@ -59,6 +59,8 @@ src/riplex.egg-info/SOURCES.txt
59
59
  src/riplex.egg-info/dependency_links.txt
60
60
  src/riplex.egg-info/entry_points.txt
61
61
  src/riplex.egg-info/requires.txt
62
+ src/riplex.egg-info/scm_file_list.json
63
+ src/riplex.egg-info/scm_version.json
62
64
  src/riplex.egg-info/top_level.txt
63
65
  src/riplex/disc/__init__.py
64
66
  src/riplex/disc/analysis.py
@@ -99,6 +101,7 @@ src/riplex_cli/commands/organize.py
99
101
  src/riplex_cli/commands/rip.py
100
102
  src/riplex_cli/commands/setup.py
101
103
  tests/__init__.py
104
+ tests/test_app_main.py
102
105
  tests/test_cache.py
103
106
  tests/test_cli_utils.py
104
107
  tests/test_config.py
@@ -121,6 +124,7 @@ tests/test_scanner.py
121
124
  tests/test_snapshot.py
122
125
  tests/test_splitter.py
123
126
  tests/test_tagger.py
127
+ tests/test_tmdb_provider.py
124
128
  tests/test_ui.py
125
129
  tests/test_updater.py
126
130
  tests/test_welcome_screen.py
@@ -1,3 +1,4 @@
1
+ certifi>=2024.2.2
1
2
  httpx>=0.27
2
3
  dvdcompare-scraper>=0.1.15
3
4
  platformdirs>=4.0
@@ -0,0 +1,136 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "LICENSE",
5
+ "pyproject.toml",
6
+ "mkdocs.yml",
7
+ "CONTRIBUTORS.md",
8
+ ".gitignore",
9
+ "issues/debug-artifacts-consolidation.md",
10
+ "issues/orchestrate-dvdcompare-fallback.md",
11
+ "issues/manual-rip-and-organize-escape-hatch.md",
12
+ "issues/planned-features.md",
13
+ ".vscode/settings.json",
14
+ "docs/naming-rules.md",
15
+ "docs/troubleshooting.md",
16
+ "docs/changelog.md",
17
+ "docs/index.md",
18
+ "docs/architecture.md",
19
+ "docs/cli-guide/organize.md",
20
+ "docs/cli-guide/lookup.md",
21
+ "docs/cli-guide/workflow.md",
22
+ "docs/cli-guide/orchestrate.md",
23
+ "docs/gui-guide/gui-walkthrough.md",
24
+ "docs/reference/cli.md",
25
+ "docs/getting-started/installation.md",
26
+ "docs/getting-started/configuration.md",
27
+ "src/riplex_cli/__init__.py",
28
+ "src/riplex_cli/formatting.py",
29
+ "src/riplex_cli/main.py",
30
+ "src/riplex_cli/commands/__init__.py",
31
+ "src/riplex_cli/commands/setup.py",
32
+ "src/riplex_cli/commands/lookup.py",
33
+ "src/riplex_cli/commands/organize.py",
34
+ "src/riplex_cli/commands/orchestrate.py",
35
+ "src/riplex_cli/commands/rip.py",
36
+ "src/riplex_app/crash_dump.py",
37
+ "src/riplex_app/__init__.py",
38
+ "src/riplex_app/bug_report.py",
39
+ "src/riplex_app/main.py",
40
+ "src/riplex_app/keep_awake.py",
41
+ "src/riplex_app/screens/disc_overview.py",
42
+ "src/riplex_app/screens/__init__.py",
43
+ "src/riplex_app/screens/organize_done.py",
44
+ "src/riplex_app/screens/folder_picker.py",
45
+ "src/riplex_app/screens/selection.py",
46
+ "src/riplex_app/screens/orchestrate_done.py",
47
+ "src/riplex_app/screens/progress.py",
48
+ "src/riplex_app/screens/metadata.py",
49
+ "src/riplex_app/screens/organize_preview.py",
50
+ "src/riplex_app/screens/release.py",
51
+ "src/riplex_app/screens/done.py",
52
+ "src/riplex_app/screens/update.py",
53
+ "src/riplex_app/screens/disc_swap.py",
54
+ "src/riplex_app/screens/disc_detection.py",
55
+ "src/riplex_app/screens/welcome.py",
56
+ "src/riplex/organizer.py",
57
+ "src/riplex/matcher.py",
58
+ "src/riplex/formatter.py",
59
+ "src/riplex/updater.py",
60
+ "src/riplex/__init__.py",
61
+ "src/riplex/normalize.py",
62
+ "src/riplex/config.py",
63
+ "src/riplex/models.py",
64
+ "src/riplex/snapshot.py",
65
+ "src/riplex/tagger.py",
66
+ "src/riplex/lookup.py",
67
+ "src/riplex/title.py",
68
+ "src/riplex/scanner.py",
69
+ "src/riplex/manifest.py",
70
+ "src/riplex/cache.py",
71
+ "src/riplex/dedup.py",
72
+ "src/riplex/ui.py",
73
+ "src/riplex/splitter.py",
74
+ "src/riplex/detect.py",
75
+ "src/riplex/disc/makemkv.py",
76
+ "src/riplex/disc/__init__.py",
77
+ "src/riplex/disc/analysis.py",
78
+ "src/riplex/disc/provider.py",
79
+ "src/riplex/metadata/__init__.py",
80
+ "src/riplex/metadata/planner.py",
81
+ "src/riplex/metadata/provider.py",
82
+ "src/riplex/metadata/sources/__init__.py",
83
+ "src/riplex/metadata/sources/tmdb.py",
84
+ "tests/test_rip_guide.py",
85
+ "tests/test_planner.py",
86
+ "tests/test_makemkv.py",
87
+ "tests/test_config.py",
88
+ "tests/__init__.py",
89
+ "tests/test_matcher.py",
90
+ "tests/test_disc_fixtures.py",
91
+ "tests/test_splitter.py",
92
+ "tests/test_detect.py",
93
+ "tests/test_dedup.py",
94
+ "tests/test_app_main.py",
95
+ "tests/test_scanner.py",
96
+ "tests/test_cli_utils.py",
97
+ "tests/test_disc_detection_screen.py",
98
+ "tests/test_lookup.py",
99
+ "tests/test_welcome_screen.py",
100
+ "tests/test_tagger.py",
101
+ "tests/test_snapshot.py",
102
+ "tests/test_formatter.py",
103
+ "tests/test_updater.py",
104
+ "tests/test_tmdb_provider.py",
105
+ "tests/test_normalize.py",
106
+ "tests/test_disc_analysis.py",
107
+ "tests/test_cache.py",
108
+ "tests/test_ui.py",
109
+ "tests/test_release_screen.py",
110
+ "tests/test_organizer.py",
111
+ "tests/test_disc_provider.py",
112
+ "tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt",
113
+ "tests/fixtures/makemkvcon_list.txt",
114
+ "tests/fixtures/chernobyl_disc1.json",
115
+ "tests/snapshots/The Dark Knight Rises.snapshot.json",
116
+ "tests/snapshots/Blade Runner The Final Cut.snapshot.json",
117
+ "tests/snapshots/Seven Worlds One Planet.snapshot.json",
118
+ "tests/snapshots/Waterworld.snapshot.json",
119
+ "tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json",
120
+ "tests/snapshots/Batman Begins.snapshot.json",
121
+ "tests/snapshots/The Dark Knight.snapshot.json",
122
+ "screenshots/0_Rip_Flow_BTTF.gif",
123
+ "screenshots/5_Select_Title_to_RIP_BTTF.png",
124
+ "screenshots/4_Disc_Release_BTTF.png",
125
+ "screenshots/5_Multi_Disc_Overview_BTTF.png",
126
+ "screenshots/3_Metadata_Lookup_BTTF.png",
127
+ "screenshots/1_Welcome_Screen.png",
128
+ "screenshots/2_Disc_Detection_BTTF.png",
129
+ ".github/copilot-instructions.md",
130
+ ".github/agents/riplex.agent.md",
131
+ ".github/ISSUE_TEMPLATE/crash_report.yml",
132
+ ".github/ISSUE_TEMPLATE/bug_report.yml",
133
+ ".github/workflows/release.yml",
134
+ ".github/workflows/publish.yml"
135
+ ]
136
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.9.2",
3
+ "distance": 0,
4
+ "node": "g775f04026aebf5255891652cd7e5dec5dce26000",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-24"
8
+ }
@@ -1,6 +1,7 @@
1
1
  """Riplex GUI - Flet-based companion app for riplex."""
2
2
 
3
3
  import logging
4
+ import os
4
5
  import sys
5
6
  import threading
6
7
  import traceback as tb_module
@@ -310,7 +311,55 @@ def _parse_exception_summary(traceback_text: str) -> tuple[str, str]:
310
311
  return last_line or "Exception", ""
311
312
 
312
313
 
314
+ def _configure_tls_certificates(env=None):
315
+ """Point urllib/ssl at certifi before Flet's first-launch download."""
316
+ if env is None:
317
+ env = os.environ
318
+ try:
319
+ import certifi
320
+ except Exception: # pragma: no cover - certifi is an explicit dependency.
321
+ return None
322
+
323
+ cert_path = certifi.where()
324
+ env.setdefault("SSL_CERT_FILE", cert_path)
325
+ env.setdefault("REQUESTS_CA_BUNDLE", cert_path)
326
+ return cert_path
327
+
328
+
329
+ def _configure_flet_view_path(env=None, bundle_root=None, platform=None):
330
+ """Point Flet at a bundled desktop client when one is packaged."""
331
+ if env is None:
332
+ env = os.environ
333
+ if env.get("FLET_VIEW_PATH"):
334
+ return env["FLET_VIEW_PATH"]
335
+
336
+ if bundle_root is None:
337
+ bundle_root = getattr(sys, "_MEIPASS", None)
338
+ if not bundle_root:
339
+ return None
340
+
341
+ if platform is None:
342
+ platform = sys.platform
343
+
344
+ client_dir = Path(bundle_root) / "flet_client"
345
+ if platform.startswith("win"):
346
+ ready = (client_dir / "flet.exe").is_file()
347
+ elif platform == "darwin":
348
+ ready = any(client_dir.glob("*.app"))
349
+ else:
350
+ ready = (client_dir / "flet").is_file()
351
+
352
+ if not ready:
353
+ return None
354
+
355
+ env["FLET_VIEW_PATH"] = str(client_dir)
356
+ return str(client_dir)
357
+
358
+
313
359
  def main():
360
+ _configure_tls_certificates()
361
+ _configure_flet_view_path()
362
+
314
363
  logging.basicConfig(
315
364
  level=logging.DEBUG,
316
365
  format="%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
@@ -34,6 +34,9 @@ class DiscOverviewScreen:
34
34
  year = tmdb_match.year or 0 if tmdb_match else 0
35
35
  release_name = release.name if release else ""
36
36
 
37
+ if not dvdcompare_discs:
38
+ return self._build_empty_state(match_label=self._match_label(canonical, year))
39
+
37
40
  # Detect currently inserted disc
38
41
  from riplex.disc.provider import detect_disc_number
39
42
  inserted_disc = None
@@ -123,9 +126,7 @@ class DiscOverviewScreen:
123
126
  for cb in self.checkboxes:
124
127
  cb.on_change = self._update_summary
125
128
 
126
- # Title display
127
- year_str = f" ({year})" if year else ""
128
- match_label = f"{canonical}{year_str}"
129
+ match_label = self._match_label(canonical, year)
129
130
 
130
131
  # Resume info
131
132
  resume_info = ft.Container()
@@ -182,6 +183,46 @@ class DiscOverviewScreen:
182
183
  expand=True,
183
184
  )
184
185
 
186
+ def _match_label(self, canonical: str, year: int) -> str:
187
+ year_str = f" ({year})" if year else ""
188
+ return f"{canonical}{year_str}"
189
+
190
+ def _build_empty_state(self, *, match_label: str) -> ft.Control:
191
+ """Show a recoverable state when orchestrate has no disc structure."""
192
+ return ft.Column(
193
+ [
194
+ ft.Text("Disc Overview", size=24, weight=ft.FontWeight.BOLD),
195
+ ft.Text(match_label, size=14, color=ft.Colors.GREY_400),
196
+ ft.Text(
197
+ "No usable dvdcompare disc structure is selected for this title. "
198
+ "Pick a different dvdcompare result, or continue with duration-only "
199
+ "single-disc selection.",
200
+ size=13,
201
+ color=ft.Colors.ORANGE,
202
+ ),
203
+ ft.Container(expand=True),
204
+ ft.Row([
205
+ ft.TextButton("Back", on_click=lambda _: self.app.navigate("release")),
206
+ ft.ElevatedButton(
207
+ "Continue without",
208
+ icon=ft.Icons.ARROW_FORWARD,
209
+ on_click=self._continue_without_dvdcompare,
210
+ ),
211
+ ]),
212
+ ],
213
+ spacing=10,
214
+ expand=True,
215
+ )
216
+
217
+ def _continue_without_dvdcompare(self, _e):
218
+ """Proceed with a single-disc duration-only rip when no release data exists."""
219
+ self.app.state["dvdcompare_discs"] = []
220
+ self.app.state["_orchestrate_disc_number"] = 1
221
+ self.app.state["disc_queue"] = [1]
222
+ self.app.state["current_disc_idx"] = 0
223
+ self.app.state["all_rip_results"] = {}
224
+ self.app.navigate("selection")
225
+
185
226
  def _update_summary(self, e):
186
227
  """Update summary when checkboxes change."""
187
228
  selected_count = sum(1 for cb in self.checkboxes if cb.value)
@@ -514,12 +514,29 @@ class ReleaseScreen:
514
514
 
515
515
  def _use_release(self, release):
516
516
  """Convert a dvdcompare release to PlannedDiscs and navigate."""
517
- self.app.state["release"] = release
518
517
  try:
519
518
  discs = _convert_release(release)
520
- self.app.state["dvdcompare_discs"] = discs
521
- except Exception:
519
+ except Exception as exc:
520
+ self.app.state["release"] = None
521
+ self.app.state["dvdcompare_discs"] = []
522
+ self.app.state["_dvdcompare_error"] = (
523
+ f"Could not read disc data from the selected dvdcompare release: {exc}"
524
+ )
525
+ self.app.navigate("release")
526
+ return
527
+
528
+ if not discs:
529
+ self.app.state["release"] = None
522
530
  self.app.state["dvdcompare_discs"] = []
531
+ self.app.state["_dvdcompare_error"] = (
532
+ "The selected dvdcompare release did not contain usable disc data. "
533
+ "Try a different search title or continue without disc structure data."
534
+ )
535
+ self.app.navigate("release")
536
+ return
537
+
538
+ self.app.state["release"] = release
539
+ self.app.state["dvdcompare_discs"] = discs
523
540
  self.app.navigate(self._next_screen)
524
541
 
525
542
  def _score_releases(self, releases) -> int: