riplex 0.9.4__tar.gz → 0.9.5__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.4 → riplex-0.9.5}/.github/agents/riplex.agent.md +105 -35
- {riplex-0.9.4 → riplex-0.9.5}/.github/copilot-instructions.md +28 -0
- {riplex-0.9.4/src/riplex.egg-info → riplex-0.9.5}/PKG-INFO +1 -1
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/sources/tmdb.py +109 -9
- {riplex-0.9.4 → riplex-0.9.5/src/riplex.egg-info}/PKG-INFO +1 -1
- riplex-0.9.5/src/riplex.egg-info/scm_version.json +8 -0
- riplex-0.9.5/tests/test_tmdb_provider.py +211 -0
- riplex-0.9.4/src/riplex.egg-info/scm_version.json +0 -8
- riplex-0.9.4/tests/test_tmdb_provider.py +0 -57
- {riplex-0.9.4 → riplex-0.9.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/.github/workflows/publish.yml +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/.github/workflows/release.yml +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/.gitignore +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/.vscode/settings.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/CONTRIBUTORS.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/LICENSE +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/README.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/architecture.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/changelog.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/lookup.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/orchestrate.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/organize.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/workflow.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/getting-started/configuration.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/getting-started/installation.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/gui-guide/gui-walkthrough.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/index.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/naming-rules.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/reference/cli.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/docs/troubleshooting.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/issues/debug-artifacts-consolidation.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/issues/orchestrate-dvdcompare-fallback.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/issues/planned-features.md +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/mkdocs.yml +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/pyproject.toml +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/1_Welcome_Screen.png +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/2_Disc_Detection_BTTF.png +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/4_Disc_Release_BTTF.png +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/setup.cfg +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/cache.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/config.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/dedup.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/detect.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/analysis.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/makemkv.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/provider.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/formatter.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/lookup.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/manifest.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/matcher.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/planner.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/provider.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/sources/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/models.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/normalize.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/organizer.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/scanner.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/snapshot.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/splitter.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/tagger.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/title.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/ui.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex/updater.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/SOURCES.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/dependency_links.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/entry_points.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/requires.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/scm_file_list.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/top_level.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/bug_report.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/crash_dump.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/keep_awake.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/main.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/disc_detection.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/disc_overview.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/disc_swap.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/done.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/folder_picker.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/metadata.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/orchestrate_done.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/organize_done.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/organize_preview.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/progress.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/release.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/selection.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/update.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/welcome.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/lookup.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/orchestrate.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/organize.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/rip.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/setup.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/formatting.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/main.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/__init__.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/fixtures/chernobyl_disc1.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/fixtures/makemkvcon_list.txt +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Batman Begins.snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Waterworld.snapshot.json +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_app_main.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_cache.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_cli_utils.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_config.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_dedup.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_detect.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_analysis.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_detection_screen.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_fixtures.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_provider.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_formatter.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_lookup.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_makemkv.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_matcher.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_normalize.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_organizer.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_planner.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_release_screen.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_rip_guide.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_scanner.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_snapshot.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_splitter.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_tagger.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_ui.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_updater.py +0 -0
- {riplex-0.9.4 → riplex-0.9.5}/tests/test_welcome_screen.py +0 -0
|
@@ -21,55 +21,84 @@ riplex eliminates the manual work after MakeMKV dumps raw MKV files from a physi
|
|
|
21
21
|
```
|
|
22
22
|
src/
|
|
23
23
|
riplex/ # Shared library (all business logic)
|
|
24
|
-
|
|
25
|
-
config.py # Config loading (~/.config/riplex/config.toml or platform equivalent)
|
|
24
|
+
config.py # Config loading (platform config dir via platformdirs)
|
|
26
25
|
models.py # Data models (ScannedFile, PlannedDisc, PlannedMovie, PlannedShow, etc.)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
metadata_sources/
|
|
30
|
-
tmdb.py # TMDb API implementation
|
|
31
|
-
disc_provider.py # dvdcompare.net bridge (lookup_discs, find_film, _convert_release)
|
|
32
|
-
disc_analysis.py # Live disc title classification (classify_title, is_skip_title, build_dvd_entries)
|
|
33
|
-
makemkv.py # makemkvcon wrapper (drive scanning, disc reading, ripping, progress parsing)
|
|
34
|
-
scanner.py # MKV folder scanner (ffprobe metadata extraction)
|
|
26
|
+
lookup.py # Shared lookup pipeline logic reusable by CLI and GUI
|
|
27
|
+
scanner.py # MKV folder scanner (ffprobe metadata extraction; find_ffprobe tool detection)
|
|
35
28
|
matcher.py # Runtime-based file-to-entry matching with disc constraints
|
|
36
29
|
organizer.py # Plex destination path builder and file mover
|
|
37
|
-
planner.py # TMDb metadata planning (builds PlannedMovie/PlannedShow)
|
|
38
30
|
detect.py # Format auto-detection, title grouping, incomplete file detection
|
|
39
31
|
dedup.py # Duplicate MKV detection (metadata fingerprint + perceptual hash)
|
|
40
32
|
splitter.py # Chapter-based MKV splitting via mkvmerge
|
|
41
33
|
tagger.py # MKV tagging (marks files as organized via mkvpropedit)
|
|
34
|
+
manifest.py # Disc manifest / debug artifact handling
|
|
42
35
|
cache.py # File-based JSON cache with TTL
|
|
43
36
|
normalize.py # Title normalization
|
|
37
|
+
title.py # Title model / parsing helpers
|
|
44
38
|
formatter.py # Text and JSON output formatting
|
|
45
|
-
snapshot.py # Scan result serialization
|
|
39
|
+
snapshot.py # Scan result serialization (offline replay)
|
|
40
|
+
updater.py # App self-update version check
|
|
46
41
|
ui.py # Interactive prompts (prompt_choice, prompt_confirm, prompt_text, prompt_multi_select)
|
|
42
|
+
disc/ # Live disc subpackage
|
|
43
|
+
analysis.py # Title classification (classify_title, is_skip_title, build_dvd_entries)
|
|
44
|
+
makemkv.py # makemkvcon wrapper (drive scanning, disc reading, ripping, progress parsing)
|
|
45
|
+
provider.py # dvdcompare.net bridge (lookup_discs, find_film, _convert_release)
|
|
46
|
+
metadata/ # Metadata subpackage
|
|
47
|
+
planner.py # TMDb metadata planning (builds PlannedMovie/PlannedShow)
|
|
48
|
+
provider.py # Abstract metadata provider interface
|
|
49
|
+
sources/
|
|
50
|
+
tmdb.py # TMDb API implementation
|
|
47
51
|
riplex_cli/ # CLI thin wrapper (argparse, command dispatch, terminal formatting)
|
|
48
|
-
main.py #
|
|
52
|
+
main.py # Argparse setup + command dispatch, entry point: main()
|
|
53
|
+
formatting.py # Terminal output formatting helpers
|
|
54
|
+
commands/ # One module per subcommand
|
|
55
|
+
orchestrate.py # Full pipeline command
|
|
56
|
+
rip.py # Single-disc rip command
|
|
57
|
+
organize.py # Organize existing MKV folder command
|
|
58
|
+
lookup.py # Preview disc contents command
|
|
59
|
+
setup.py # Interactive config wizard command
|
|
49
60
|
riplex_app/ # Flet GUI (wizard-style screens)
|
|
50
61
|
main.py # App entry point, screen navigation controller
|
|
62
|
+
bug_report.py # Bug report bundling helper
|
|
63
|
+
crash_dump.py # Crash dump capture
|
|
64
|
+
keep_awake.py # Prevent system sleep during long rips
|
|
51
65
|
screens/
|
|
52
|
-
welcome.py
|
|
53
|
-
disc_detection.py
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
welcome.py # Config and tool verification
|
|
67
|
+
disc_detection.py # Drive scanning, disc reading
|
|
68
|
+
disc_overview.py # Multi-disc overview
|
|
69
|
+
disc_swap.py # Disc swap prompt (multi-disc sets)
|
|
70
|
+
metadata.py # TMDb search and selection
|
|
71
|
+
release.py # dvdcompare release picker
|
|
72
|
+
selection.py # Title selection with classify_title
|
|
73
|
+
progress.py # Rip progress with makemkvcon
|
|
74
|
+
organize_preview.py # Organize plan preview
|
|
75
|
+
organize_done.py # Organize results summary
|
|
76
|
+
orchestrate_done.py # Orchestrate results summary
|
|
77
|
+
done.py # Rip results summary
|
|
78
|
+
update.py # Self-update screen
|
|
79
|
+
folder_picker.py # Folder browse dialog (tkinter)
|
|
59
80
|
tests/
|
|
60
|
-
fixtures/ # makemkvcon output samples
|
|
81
|
+
fixtures/ # makemkvcon output samples + disc JSON fixtures
|
|
61
82
|
snapshots/ # Serialized disc scan results for offline replay
|
|
62
83
|
test_*.py # One test file per module (test_matcher.py, test_disc_analysis.py, etc.)
|
|
63
84
|
docs/
|
|
64
85
|
architecture.md # System design, data flow diagrams
|
|
65
86
|
naming-rules.md # Plex naming conventions
|
|
66
87
|
changelog.md # Documentation changelog (Keep a Changelog format)
|
|
88
|
+
troubleshooting.md # Common problems and fixes
|
|
89
|
+
index.md # Docs landing page
|
|
67
90
|
getting-started/ # Installation, configuration
|
|
68
|
-
gui-guide/ # Desktop app
|
|
91
|
+
gui-guide/ # Desktop app walkthrough
|
|
69
92
|
cli-guide/ # Command-by-command workflow guides
|
|
70
93
|
reference/ # CLI reference
|
|
71
94
|
```
|
|
72
95
|
|
|
96
|
+
Note: business logic lives in the `riplex` library. `riplex/disc/` and
|
|
97
|
+
`riplex/metadata/` are proper subpackages (not flat `disc_analysis.py` /
|
|
98
|
+
`metadata_sources/`). The CLI's per-command logic lives in
|
|
99
|
+
`riplex_cli/commands/`. There is no `src/riplex/cli.py` or
|
|
100
|
+
`src/riplex/orchestrate.py`.
|
|
101
|
+
|
|
73
102
|
## Commands
|
|
74
103
|
|
|
75
104
|
| Command | Purpose |
|
|
@@ -110,8 +139,14 @@ Do NOT use `py -m riplex` (errors — riplex is a library package). Do NOT use `
|
|
|
110
139
|
|
|
111
140
|
- Python 3.11+ (use `py` command, never `python` or `python3`)
|
|
112
141
|
- Async: httpx for HTTP, asyncio.run() from sync CLI entry points
|
|
113
|
-
- Dependencies: httpx, dvdcompare-scraper, platformdirs
|
|
114
|
-
- GUI: Flet 0.
|
|
142
|
+
- Dependencies: certifi, httpx, dvdcompare-scraper, platformdirs
|
|
143
|
+
- GUI: Flet, pinned to `flet==0.85.1` in the `[gui]` extra. The pin is
|
|
144
|
+
intentional: the macOS desktop runtime in 0.85.3 ships a malformed nested
|
|
145
|
+
`objective_c.framework` that newer `codesign` rejects, which broke the macOS
|
|
146
|
+
release build and crashed the packaged GUI on launch (issue #21). Keep
|
|
147
|
+
`flet` and `flet-desktop` in lockstep at the same version.
|
|
148
|
+
- Versioning: `setuptools-scm` derives the version from the latest `v*` git
|
|
149
|
+
tag (no hardcoded version in `pyproject.toml`).
|
|
115
150
|
- External tools: makemkvcon, ffprobe, mkvmerge, mkvpropedit
|
|
116
151
|
|
|
117
152
|
## Debugging the GUI
|
|
@@ -167,12 +202,6 @@ Docs live in `docs/` and are referenced from README.md.
|
|
|
167
202
|
- `docs/cli-guide/`: Command-by-command workflow walkthroughs
|
|
168
203
|
- `docs/reference/cli.md`: Complete CLI option reference
|
|
169
204
|
|
|
170
|
-
*** Delete File: c:\Users\asher\Projects\anycredit5518\riplex\docs\guide\gui-walkthrough.md
|
|
171
|
-
*** Delete File: c:\Users\asher\Projects\anycredit5518\riplex\docs\guide\workflow.md
|
|
172
|
-
*** Delete File: c:\Users\asher\Projects\anycredit5518\riplex\docs\guide\orchestrate.md
|
|
173
|
-
*** Delete File: c:\Users\asher\Projects\anycredit5518\riplex\docs\guide\lookup.md
|
|
174
|
-
*** Delete File: c:\Users\asher\Projects\anycredit5518\riplex\docs\guide\organize.md
|
|
175
|
-
|
|
176
205
|
### Documentation rules
|
|
177
206
|
|
|
178
207
|
- When making significant changes (new features, renamed modules, changed behavior), update the relevant docs
|
|
@@ -197,11 +226,52 @@ Docs live in `docs/` and are referenced from README.md.
|
|
|
197
226
|
- Dry-run by default: never move or delete files without explicit `--execute`
|
|
198
227
|
- Config is shared between CLI and GUI (`~/.config/riplex/config.toml` or platform equivalent)
|
|
199
228
|
|
|
200
|
-
##
|
|
201
|
-
|
|
202
|
-
|
|
229
|
+
## Release workflow
|
|
230
|
+
|
|
231
|
+
Releases are built by `.github/workflows/release.yml`.
|
|
232
|
+
|
|
233
|
+
- Triggers: `push` of a `v*` tag, and manual `workflow_dispatch`.
|
|
234
|
+
- Jobs: `build-windows` (windows-latest), `build-macos` (macos-14, arm64),
|
|
235
|
+
and `release` (ubuntu-latest).
|
|
236
|
+
- The `release` job is gated on `if: startsWith(github.ref, 'refs/tags/v')`.
|
|
237
|
+
So a `workflow_dispatch` run on a branch or `main` runs ONLY the build jobs
|
|
238
|
+
and uploads artifacts — it does NOT create a user-facing release. This is
|
|
239
|
+
the safe way to validate packaging changes before tagging.
|
|
240
|
+
- To cut a real release: push an annotated `v*` tag on `main`. The tag
|
|
241
|
+
annotation becomes the release header; `setuptools-scm` picks up the
|
|
242
|
+
version from the tag.
|
|
243
|
+
- macOS GUI packaging: do NOT pass Flet's extracted `Flet.app` runtime to
|
|
244
|
+
PyInstaller. PyInstaller force-signs collected binaries on arm64 and
|
|
245
|
+
`codesign` rejects Flet's nested frameworks. Instead the workflow downloads
|
|
246
|
+
Flet's upstream `flet-macos.tar.gz` and bundles it under
|
|
247
|
+
`flet_desktop/app`; `flet_desktop.ensure_client_cached()` extracts it at
|
|
248
|
+
runtime. The macOS `.app` zip uses `zip -y` to preserve framework symlinks
|
|
249
|
+
(flattened symlinks crash the app on launch).
|
|
250
|
+
- Debugging CI: `gh run view <id> --json status,conclusion,jobs`; for logs of
|
|
251
|
+
a still-running run, fetch a finished job directly with
|
|
252
|
+
`gh api repos/<owner>/<repo>/actions/jobs/<jobId>/logs`
|
|
253
|
+
(the `--log`/`--log-failed` flags refuse until the whole run completes).
|
|
254
|
+
|
|
255
|
+
## Dependency and packaging cautions
|
|
256
|
+
|
|
257
|
+
- A Flet runtime regression can surface as a riplex GUI launch crash even
|
|
258
|
+
when riplex's own code is unchanged — check the Flet/PyInstaller packaging
|
|
259
|
+
path before assuming an app-code bug.
|
|
260
|
+
- `tkinter` (used by the GUI folder picker) is an OS-level dependency, not a
|
|
261
|
+
pip package: macOS Homebrew Python needs `brew install python-tk@3.12`.
|
|
262
|
+
- On Windows, close `riplex-ui` before upgrading deps — the entry-point
|
|
263
|
+
`.exe` shim is locked while the process runs and pip fails with `WinError 32`.
|
|
264
|
+
|
|
265
|
+
## Issue workflow
|
|
266
|
+
|
|
267
|
+
- When fixing a GitHub issue, reference it in the commit and/or tag message.
|
|
268
|
+
- After a tagged release ships, draft a user-facing comment that leads with
|
|
269
|
+
the reported symptom, gives a high-level cause and the fixed version, and
|
|
270
|
+
asks the reporter to retry (and reopen if still broken) before closing.
|
|
271
|
+
|
|
272
|
+
## Source packages
|
|
273
|
+
|
|
274
|
+
The repo contains three source packages:
|
|
203
275
|
- `riplex` — shared library (all business logic)
|
|
204
276
|
- `riplex_cli` — CLI thin wrapper
|
|
205
277
|
- `riplex_app` — Flet GUI
|
|
206
|
-
|
|
207
|
-
See `MONOREPO_PLAN.md` for background on the refactor.
|
|
@@ -105,3 +105,31 @@ All destructive commands (`rip`, `organize`, `orchestrate`) are dry-run by defau
|
|
|
105
105
|
## Testing
|
|
106
106
|
|
|
107
107
|
Run tests with `pytest` (or `python -m pytest`) from the project root with the venv active. All tests must pass before committing.
|
|
108
|
+
|
|
109
|
+
## Debugging the GUI
|
|
110
|
+
|
|
111
|
+
The GUI writes detailed logs to `riplex_app.log` in the working directory
|
|
112
|
+
(project root when launched from the repo). Check this file first when
|
|
113
|
+
debugging GUI behavior. Do NOT try to capture the GUI's terminal output with
|
|
114
|
+
a `2>&1` redirect — it breaks Flet's Flutter-Python IPC.
|
|
115
|
+
|
|
116
|
+
## Releases and packaging
|
|
117
|
+
|
|
118
|
+
Releases are produced by `.github/workflows/release.yml`.
|
|
119
|
+
|
|
120
|
+
- A GitHub Release is created ONLY when a `v*` tag is pushed. The `release`
|
|
121
|
+
job is gated on `refs/tags/v*`.
|
|
122
|
+
- `workflow_dispatch` on a branch or `main` runs only the build jobs and
|
|
123
|
+
uploads artifacts without publishing — use it to validate packaging changes
|
|
124
|
+
before tagging.
|
|
125
|
+
- To release: push an annotated `v*` tag on `main`. `setuptools-scm` derives
|
|
126
|
+
the version from that tag (there is no version string in `pyproject.toml`).
|
|
127
|
+
- macOS builds run on `macos-14` (arm64). The macOS GUI bundles Flet's
|
|
128
|
+
upstream `flet-macos.tar.gz` runtime archive (extracted at runtime) rather
|
|
129
|
+
than feeding the extracted `Flet.app` to PyInstaller, and zips the `.app`
|
|
130
|
+
with `zip -y` to preserve framework symlinks. Do not revert these without
|
|
131
|
+
understanding issue #21.
|
|
132
|
+
- Keep `flet` and `flet-desktop` pinned to the same version. A Flet runtime
|
|
133
|
+
regression can present as a GUI launch crash even when riplex's own code is
|
|
134
|
+
unchanged.
|
|
135
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.5
|
|
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
|
|
@@ -10,6 +10,7 @@ or passed directly to the constructor.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import os
|
|
13
|
+
import re
|
|
13
14
|
from typing import Literal
|
|
14
15
|
|
|
15
16
|
import httpx
|
|
@@ -28,6 +29,47 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
|
|
28
29
|
_TMDB_TTL_DAYS = 7
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def _normalize_for_match(text: str) -> str:
|
|
33
|
+
"""Lowercase *text* and collapse runs of non-alphanumerics to spaces.
|
|
34
|
+
|
|
35
|
+
This makes punctuation-insensitive comparisons possible so that, e.g.,
|
|
36
|
+
``"TRON: Legacy"`` and ``"Tron Legacy"`` compare equal.
|
|
37
|
+
"""
|
|
38
|
+
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _title_match_tier(query: str, title: str) -> int:
|
|
42
|
+
"""Rank how well *title* matches the search *query* (higher is better).
|
|
43
|
+
|
|
44
|
+
TMDb's ``/search/movie`` and ``/search/tv`` endpoints return loosely
|
|
45
|
+
fuzzy matches: a search for ``"Tron"`` surfaces wildly popular but
|
|
46
|
+
unrelated shows like ``House of the Dragon``. Sorting the merged list by
|
|
47
|
+
raw popularity therefore buries the title the user actually searched for.
|
|
48
|
+
This tier ranks literal title overlap ahead of popularity so genuine
|
|
49
|
+
matches win, with popularity only breaking ties within a tier.
|
|
50
|
+
|
|
51
|
+
Tiers (high to low):
|
|
52
|
+
4 - exact title match
|
|
53
|
+
3 - title starts with the query (e.g. ``"TRON: Legacy"`` for ``"tron"``)
|
|
54
|
+
2 - query appears as a whole word/phrase inside the title
|
|
55
|
+
1 - query is a substring of the title
|
|
56
|
+
0 - no literal overlap (TMDb fuzzy match)
|
|
57
|
+
"""
|
|
58
|
+
q = _normalize_for_match(query)
|
|
59
|
+
t = _normalize_for_match(title)
|
|
60
|
+
if not q or not t:
|
|
61
|
+
return 0
|
|
62
|
+
if t == q:
|
|
63
|
+
return 4
|
|
64
|
+
if t.startswith(q + " "):
|
|
65
|
+
return 3
|
|
66
|
+
if f" {q} " in f" {t} ":
|
|
67
|
+
return 2
|
|
68
|
+
if q in t:
|
|
69
|
+
return 1
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
31
73
|
def _looks_like_read_access_token(key: str) -> bool:
|
|
32
74
|
"""Return ``True`` when *key* looks like a TMDb v4 Read Access Token.
|
|
33
75
|
|
|
@@ -105,19 +147,77 @@ class TmdbProvider(MetadataProvider):
|
|
|
105
147
|
year: int | None = None,
|
|
106
148
|
media_type: Literal["movie", "tv", "auto"] = "auto",
|
|
107
149
|
) -> list[MetadataSearchResult]:
|
|
108
|
-
results: list[MetadataSearchResult]
|
|
109
|
-
|
|
110
|
-
if media_type in ("movie", "auto"):
|
|
111
|
-
results.extend(await self._search_movies(query, year))
|
|
150
|
+
results: list[MetadataSearchResult]
|
|
112
151
|
|
|
113
|
-
if media_type
|
|
114
|
-
|
|
152
|
+
if media_type == "auto" and year is None:
|
|
153
|
+
# No media-type or year constraint: use TMDb's /search/multi, the
|
|
154
|
+
# same endpoint the website's search box uses. It applies a
|
|
155
|
+
# server-side relevance score across movies and TV in one ranked
|
|
156
|
+
# list, which avoids the cross-endpoint merge problem (a single
|
|
157
|
+
# "movie" + "tv" merge has no shared score to order by).
|
|
158
|
+
results = await self._search_multi(query)
|
|
159
|
+
else:
|
|
160
|
+
# Explicit media type, or a year filter (which the dedicated
|
|
161
|
+
# endpoints support server-side but /search/multi does not).
|
|
162
|
+
results = []
|
|
163
|
+
if media_type in ("movie", "auto"):
|
|
164
|
+
results.extend(await self._search_movies(query, year))
|
|
165
|
+
if media_type in ("tv", "auto"):
|
|
166
|
+
results.extend(await self._search_tv(query, year))
|
|
115
167
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
168
|
+
# Rank by literal title-match quality first, then popularity. TMDb's
|
|
169
|
+
# results are fuzzy (a "Tron" search returns unrelated high-popularity
|
|
170
|
+
# shows), so this keeps the title the user searched for on top while
|
|
171
|
+
# still using popularity to order within a tier. Applying it uniformly
|
|
172
|
+
# also gives a deterministic, offline-testable final ordering.
|
|
173
|
+
results.sort(
|
|
174
|
+
key=lambda r: (_title_match_tier(query, r.title), r.popularity),
|
|
175
|
+
reverse=True,
|
|
176
|
+
)
|
|
119
177
|
return results
|
|
120
178
|
|
|
179
|
+
async def _search_multi(self, query: str) -> list[MetadataSearchResult]:
|
|
180
|
+
"""Search movies and TV in one ranked list via /search/multi.
|
|
181
|
+
|
|
182
|
+
/search/multi also returns ``person`` and ``collection`` entities,
|
|
183
|
+
which have no single runtime or release year to match a ripped MKV
|
|
184
|
+
against. Those are filtered out; only ``movie`` and ``tv`` survive.
|
|
185
|
+
"""
|
|
186
|
+
ck = cache.hash_key(f"multi|{query}")
|
|
187
|
+
data = await self._get_json("/search/multi", params={"query": query},
|
|
188
|
+
cache_ns="tmdb/search", cache_key=ck)
|
|
189
|
+
out: list[MetadataSearchResult] = []
|
|
190
|
+
for item in data.get("results", []):
|
|
191
|
+
mt = item.get("media_type")
|
|
192
|
+
if mt == "movie":
|
|
193
|
+
release = item.get("release_date", "") or ""
|
|
194
|
+
item_year = int(release[:4]) if len(release) >= 4 else None
|
|
195
|
+
out.append(
|
|
196
|
+
MetadataSearchResult(
|
|
197
|
+
source_id=f"movie:{item['id']}",
|
|
198
|
+
title=item.get("title", ""),
|
|
199
|
+
year=item_year,
|
|
200
|
+
media_type="movie",
|
|
201
|
+
overview=item.get("overview", ""),
|
|
202
|
+
popularity=item.get("popularity", 0.0),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
elif mt == "tv":
|
|
206
|
+
air_date = item.get("first_air_date", "") or ""
|
|
207
|
+
item_year = int(air_date[:4]) if len(air_date) >= 4 else None
|
|
208
|
+
out.append(
|
|
209
|
+
MetadataSearchResult(
|
|
210
|
+
source_id=f"tv:{item['id']}",
|
|
211
|
+
title=item.get("name", ""),
|
|
212
|
+
year=item_year,
|
|
213
|
+
media_type="tv",
|
|
214
|
+
overview=item.get("overview", ""),
|
|
215
|
+
popularity=item.get("popularity", 0.0),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
# person / collection entities are intentionally skipped.
|
|
219
|
+
return out
|
|
220
|
+
|
|
121
221
|
async def _search_movies(
|
|
122
222
|
self, query: str, year: int | None
|
|
123
223
|
) -> list[MetadataSearchResult]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riplex
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.5
|
|
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
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from riplex.metadata.sources.tmdb import (
|
|
4
|
+
TmdbProvider,
|
|
5
|
+
_looks_like_read_access_token,
|
|
6
|
+
_title_match_tier,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
# A JWT-shaped sample (header.payload.signature). Not a real credential.
|
|
10
|
+
_SAMPLE_READ_ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyaXBsZXgifQ.c2lnbmF0dXJl"
|
|
11
|
+
_SAMPLE_API_KEY = "0123456789abcdef0123456789abcdef"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestLooksLikeReadAccessToken:
|
|
15
|
+
def test_jwt_token_is_detected(self):
|
|
16
|
+
assert _looks_like_read_access_token(_SAMPLE_READ_ACCESS_TOKEN) is True
|
|
17
|
+
|
|
18
|
+
def test_v3_hex_key_is_not_detected(self):
|
|
19
|
+
assert _looks_like_read_access_token(_SAMPLE_API_KEY) is False
|
|
20
|
+
|
|
21
|
+
def test_empty_string_is_not_detected(self):
|
|
22
|
+
assert _looks_like_read_access_token("") is False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestTmdbProviderAuth:
|
|
26
|
+
@pytest.mark.asyncio
|
|
27
|
+
async def test_read_access_token_uses_authorization_header(self):
|
|
28
|
+
provider = TmdbProvider(api_key=_SAMPLE_READ_ACCESS_TOKEN)
|
|
29
|
+
try:
|
|
30
|
+
assert (
|
|
31
|
+
provider._client.headers["Authorization"]
|
|
32
|
+
== f"Bearer {_SAMPLE_READ_ACCESS_TOKEN}"
|
|
33
|
+
)
|
|
34
|
+
assert "api_key" not in provider._client.params
|
|
35
|
+
finally:
|
|
36
|
+
await provider.close()
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_v3_key_uses_query_parameter(self):
|
|
40
|
+
provider = TmdbProvider(api_key=_SAMPLE_API_KEY)
|
|
41
|
+
try:
|
|
42
|
+
assert provider._client.params["api_key"] == _SAMPLE_API_KEY
|
|
43
|
+
assert "Authorization" not in provider._client.headers
|
|
44
|
+
finally:
|
|
45
|
+
await provider.close()
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_whitespace_is_stripped_from_credential(self):
|
|
49
|
+
provider = TmdbProvider(api_key=f" {_SAMPLE_API_KEY} ")
|
|
50
|
+
try:
|
|
51
|
+
assert provider._client.params["api_key"] == _SAMPLE_API_KEY
|
|
52
|
+
finally:
|
|
53
|
+
await provider.close()
|
|
54
|
+
|
|
55
|
+
def test_missing_key_raises(self, monkeypatch):
|
|
56
|
+
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
|
57
|
+
with pytest.raises(ValueError, match="TMDb API key is required"):
|
|
58
|
+
TmdbProvider(api_key="")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestTitleMatchTier:
|
|
62
|
+
def test_exact_match_ranks_highest(self):
|
|
63
|
+
assert _title_match_tier("Tron", "Tron") == 4
|
|
64
|
+
|
|
65
|
+
def test_match_is_case_insensitive(self):
|
|
66
|
+
assert _title_match_tier("tron", "TRON") == 4
|
|
67
|
+
|
|
68
|
+
def test_punctuation_is_ignored(self):
|
|
69
|
+
# "TRON: Legacy" should compare equal to "Tron Legacy".
|
|
70
|
+
assert _title_match_tier("Tron Legacy", "TRON: Legacy") == 4
|
|
71
|
+
|
|
72
|
+
def test_title_starting_with_query_outranks_substring(self):
|
|
73
|
+
assert _title_match_tier("Tron", "TRON: Legacy") == 3
|
|
74
|
+
|
|
75
|
+
def test_query_as_whole_word_inside_title(self):
|
|
76
|
+
assert _title_match_tier("Planet", "The Green Planet") == 2
|
|
77
|
+
|
|
78
|
+
def test_substring_without_word_boundary(self):
|
|
79
|
+
assert _title_match_tier("ron", "Tron") == 1
|
|
80
|
+
|
|
81
|
+
def test_no_overlap_ranks_lowest(self):
|
|
82
|
+
assert _title_match_tier("Tron", "House of the Dragon") == 0
|
|
83
|
+
|
|
84
|
+
def test_empty_query_ranks_lowest(self):
|
|
85
|
+
assert _title_match_tier("", "Tron") == 0
|
|
86
|
+
|
|
87
|
+
def test_relevance_beats_popularity_for_tron(self):
|
|
88
|
+
"""The original Tron (low popularity) must outrank an unrelated but
|
|
89
|
+
wildly popular fuzzy match like House of the Dragon."""
|
|
90
|
+
original = ("Tron", 3.54)
|
|
91
|
+
fuzzy = ("House of the Dragon", 494.97)
|
|
92
|
+
ranked = sorted(
|
|
93
|
+
[original, fuzzy],
|
|
94
|
+
key=lambda item: (_title_match_tier("Tron", item[0]), item[1]),
|
|
95
|
+
reverse=True,
|
|
96
|
+
)
|
|
97
|
+
assert ranked[0] == original
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Minimal /search/multi payload mirroring TMDb's real shape for "Tron":
|
|
101
|
+
# a mix of movie, tv, person, and collection entities.
|
|
102
|
+
_MULTI_TRON_PAYLOAD = {
|
|
103
|
+
"results": [
|
|
104
|
+
{
|
|
105
|
+
"id": 20526,
|
|
106
|
+
"media_type": "movie",
|
|
107
|
+
"title": "TRON: Legacy",
|
|
108
|
+
"release_date": "2010-12-14",
|
|
109
|
+
"overview": "Sam Flynn...",
|
|
110
|
+
"popularity": 14.15,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": 97,
|
|
114
|
+
"media_type": "movie",
|
|
115
|
+
"title": "Tron",
|
|
116
|
+
"release_date": "1982-07-09",
|
|
117
|
+
"overview": "A computer hacker...",
|
|
118
|
+
"popularity": 4.36,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"id": 44217,
|
|
122
|
+
"media_type": "tv",
|
|
123
|
+
"name": "TRON: Uprising",
|
|
124
|
+
"first_air_date": "2012-05-18",
|
|
125
|
+
"overview": "Beck becomes...",
|
|
126
|
+
"popularity": 6.34,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": 123,
|
|
130
|
+
"media_type": "person",
|
|
131
|
+
"name": "Tron Mai",
|
|
132
|
+
"popularity": 0.14,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": 456,
|
|
136
|
+
"media_type": "collection",
|
|
137
|
+
"name": "TRON Collection",
|
|
138
|
+
"overview": "The TRON franchise.",
|
|
139
|
+
"popularity": 2.0,
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestSearchMulti:
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_auto_uses_multi_and_filters_person_and_collection(
|
|
148
|
+
self, monkeypatch
|
|
149
|
+
):
|
|
150
|
+
provider = TmdbProvider(api_key=_SAMPLE_API_KEY)
|
|
151
|
+
calls: list[str] = []
|
|
152
|
+
|
|
153
|
+
async def fake_get_json(path, params=None, cache_ns=None, cache_key=None):
|
|
154
|
+
calls.append(path)
|
|
155
|
+
return _MULTI_TRON_PAYLOAD
|
|
156
|
+
|
|
157
|
+
monkeypatch.setattr(provider, "_get_json", fake_get_json)
|
|
158
|
+
try:
|
|
159
|
+
results = await provider.search("Tron")
|
|
160
|
+
finally:
|
|
161
|
+
await provider.close()
|
|
162
|
+
|
|
163
|
+
# Routed through /search/multi exactly once (not the per-type endpoints).
|
|
164
|
+
assert calls == ["/search/multi"]
|
|
165
|
+
# person and collection entities are dropped; only movie/tv survive.
|
|
166
|
+
assert {r.media_type for r in results} == {"movie", "tv"}
|
|
167
|
+
assert all(
|
|
168
|
+
r.title not in ("Tron Mai", "TRON Collection") for r in results
|
|
169
|
+
)
|
|
170
|
+
# Exact title match ("Tron") ranks first despite lower popularity.
|
|
171
|
+
assert results[0].title == "Tron"
|
|
172
|
+
assert results[0].year == 1982
|
|
173
|
+
|
|
174
|
+
@pytest.mark.asyncio
|
|
175
|
+
async def test_auto_with_year_uses_dedicated_endpoints(self, monkeypatch):
|
|
176
|
+
provider = TmdbProvider(api_key=_SAMPLE_API_KEY)
|
|
177
|
+
calls: list[str] = []
|
|
178
|
+
|
|
179
|
+
async def fake_get_json(path, params=None, cache_ns=None, cache_key=None):
|
|
180
|
+
calls.append(path)
|
|
181
|
+
return {"results": []}
|
|
182
|
+
|
|
183
|
+
monkeypatch.setattr(provider, "_get_json", fake_get_json)
|
|
184
|
+
try:
|
|
185
|
+
await provider.search("Tron", year=1982)
|
|
186
|
+
finally:
|
|
187
|
+
await provider.close()
|
|
188
|
+
|
|
189
|
+
# A year filter falls back to the dedicated endpoints (multi can't
|
|
190
|
+
# filter by year), so both movie and tv search are queried.
|
|
191
|
+
assert "/search/movie" in calls
|
|
192
|
+
assert "/search/tv" in calls
|
|
193
|
+
assert "/search/multi" not in calls
|
|
194
|
+
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_explicit_media_type_uses_dedicated_endpoint(self, monkeypatch):
|
|
197
|
+
provider = TmdbProvider(api_key=_SAMPLE_API_KEY)
|
|
198
|
+
calls: list[str] = []
|
|
199
|
+
|
|
200
|
+
async def fake_get_json(path, params=None, cache_ns=None, cache_key=None):
|
|
201
|
+
calls.append(path)
|
|
202
|
+
return {"results": []}
|
|
203
|
+
|
|
204
|
+
monkeypatch.setattr(provider, "_get_json", fake_get_json)
|
|
205
|
+
try:
|
|
206
|
+
await provider.search("Game of Thrones", media_type="tv")
|
|
207
|
+
finally:
|
|
208
|
+
await provider.close()
|
|
209
|
+
|
|
210
|
+
assert calls == ["/search/tv"]
|
|
211
|
+
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|