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.
Files changed (144) hide show
  1. {riplex-0.9.4 → riplex-0.9.5}/.github/agents/riplex.agent.md +105 -35
  2. {riplex-0.9.4 → riplex-0.9.5}/.github/copilot-instructions.md +28 -0
  3. {riplex-0.9.4/src/riplex.egg-info → riplex-0.9.5}/PKG-INFO +1 -1
  4. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/sources/tmdb.py +109 -9
  5. {riplex-0.9.4 → riplex-0.9.5/src/riplex.egg-info}/PKG-INFO +1 -1
  6. riplex-0.9.5/src/riplex.egg-info/scm_version.json +8 -0
  7. riplex-0.9.5/tests/test_tmdb_provider.py +211 -0
  8. riplex-0.9.4/src/riplex.egg-info/scm_version.json +0 -8
  9. riplex-0.9.4/tests/test_tmdb_provider.py +0 -57
  10. {riplex-0.9.4 → riplex-0.9.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {riplex-0.9.4 → riplex-0.9.5}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  12. {riplex-0.9.4 → riplex-0.9.5}/.github/workflows/publish.yml +0 -0
  13. {riplex-0.9.4 → riplex-0.9.5}/.github/workflows/release.yml +0 -0
  14. {riplex-0.9.4 → riplex-0.9.5}/.gitignore +0 -0
  15. {riplex-0.9.4 → riplex-0.9.5}/.vscode/settings.json +0 -0
  16. {riplex-0.9.4 → riplex-0.9.5}/CONTRIBUTORS.md +0 -0
  17. {riplex-0.9.4 → riplex-0.9.5}/LICENSE +0 -0
  18. {riplex-0.9.4 → riplex-0.9.5}/README.md +0 -0
  19. {riplex-0.9.4 → riplex-0.9.5}/docs/architecture.md +0 -0
  20. {riplex-0.9.4 → riplex-0.9.5}/docs/changelog.md +0 -0
  21. {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/lookup.md +0 -0
  22. {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/orchestrate.md +0 -0
  23. {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/organize.md +0 -0
  24. {riplex-0.9.4 → riplex-0.9.5}/docs/cli-guide/workflow.md +0 -0
  25. {riplex-0.9.4 → riplex-0.9.5}/docs/getting-started/configuration.md +0 -0
  26. {riplex-0.9.4 → riplex-0.9.5}/docs/getting-started/installation.md +0 -0
  27. {riplex-0.9.4 → riplex-0.9.5}/docs/gui-guide/gui-walkthrough.md +0 -0
  28. {riplex-0.9.4 → riplex-0.9.5}/docs/index.md +0 -0
  29. {riplex-0.9.4 → riplex-0.9.5}/docs/naming-rules.md +0 -0
  30. {riplex-0.9.4 → riplex-0.9.5}/docs/reference/cli.md +0 -0
  31. {riplex-0.9.4 → riplex-0.9.5}/docs/troubleshooting.md +0 -0
  32. {riplex-0.9.4 → riplex-0.9.5}/issues/debug-artifacts-consolidation.md +0 -0
  33. {riplex-0.9.4 → riplex-0.9.5}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
  34. {riplex-0.9.4 → riplex-0.9.5}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  35. {riplex-0.9.4 → riplex-0.9.5}/issues/planned-features.md +0 -0
  36. {riplex-0.9.4 → riplex-0.9.5}/mkdocs.yml +0 -0
  37. {riplex-0.9.4 → riplex-0.9.5}/pyproject.toml +0 -0
  38. {riplex-0.9.4 → riplex-0.9.5}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  39. {riplex-0.9.4 → riplex-0.9.5}/screenshots/1_Welcome_Screen.png +0 -0
  40. {riplex-0.9.4 → riplex-0.9.5}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  41. {riplex-0.9.4 → riplex-0.9.5}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  42. {riplex-0.9.4 → riplex-0.9.5}/screenshots/4_Disc_Release_BTTF.png +0 -0
  43. {riplex-0.9.4 → riplex-0.9.5}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  44. {riplex-0.9.4 → riplex-0.9.5}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  45. {riplex-0.9.4 → riplex-0.9.5}/setup.cfg +0 -0
  46. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/__init__.py +0 -0
  47. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/cache.py +0 -0
  48. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/config.py +0 -0
  49. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/dedup.py +0 -0
  50. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/detect.py +0 -0
  51. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/__init__.py +0 -0
  52. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/analysis.py +0 -0
  53. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/makemkv.py +0 -0
  54. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/disc/provider.py +0 -0
  55. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/formatter.py +0 -0
  56. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/lookup.py +0 -0
  57. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/manifest.py +0 -0
  58. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/matcher.py +0 -0
  59. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/__init__.py +0 -0
  60. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/planner.py +0 -0
  61. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/provider.py +0 -0
  62. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/metadata/sources/__init__.py +0 -0
  63. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/models.py +0 -0
  64. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/normalize.py +0 -0
  65. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/organizer.py +0 -0
  66. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/scanner.py +0 -0
  67. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/snapshot.py +0 -0
  68. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/splitter.py +0 -0
  69. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/tagger.py +0 -0
  70. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/title.py +0 -0
  71. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/ui.py +0 -0
  72. {riplex-0.9.4 → riplex-0.9.5}/src/riplex/updater.py +0 -0
  73. {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/SOURCES.txt +0 -0
  74. {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/dependency_links.txt +0 -0
  75. {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/entry_points.txt +0 -0
  76. {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/requires.txt +0 -0
  77. {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/scm_file_list.json +0 -0
  78. {riplex-0.9.4 → riplex-0.9.5}/src/riplex.egg-info/top_level.txt +0 -0
  79. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/__init__.py +0 -0
  80. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/bug_report.py +0 -0
  81. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/crash_dump.py +0 -0
  82. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/keep_awake.py +0 -0
  83. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/main.py +0 -0
  84. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/__init__.py +0 -0
  85. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/disc_detection.py +0 -0
  86. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/disc_overview.py +0 -0
  87. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/disc_swap.py +0 -0
  88. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/done.py +0 -0
  89. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/folder_picker.py +0 -0
  90. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/metadata.py +0 -0
  91. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/orchestrate_done.py +0 -0
  92. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/organize_done.py +0 -0
  93. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/organize_preview.py +0 -0
  94. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/progress.py +0 -0
  95. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/release.py +0 -0
  96. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/selection.py +0 -0
  97. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/update.py +0 -0
  98. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_app/screens/welcome.py +0 -0
  99. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/__init__.py +0 -0
  100. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/__init__.py +0 -0
  101. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/lookup.py +0 -0
  102. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/orchestrate.py +0 -0
  103. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/organize.py +0 -0
  104. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/rip.py +0 -0
  105. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/commands/setup.py +0 -0
  106. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/formatting.py +0 -0
  107. {riplex-0.9.4 → riplex-0.9.5}/src/riplex_cli/main.py +0 -0
  108. {riplex-0.9.4 → riplex-0.9.5}/tests/__init__.py +0 -0
  109. {riplex-0.9.4 → riplex-0.9.5}/tests/fixtures/chernobyl_disc1.json +0 -0
  110. {riplex-0.9.4 → riplex-0.9.5}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  111. {riplex-0.9.4 → riplex-0.9.5}/tests/fixtures/makemkvcon_list.txt +0 -0
  112. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  113. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  114. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  115. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  116. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  117. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  118. {riplex-0.9.4 → riplex-0.9.5}/tests/snapshots/Waterworld.snapshot.json +0 -0
  119. {riplex-0.9.4 → riplex-0.9.5}/tests/test_app_main.py +0 -0
  120. {riplex-0.9.4 → riplex-0.9.5}/tests/test_cache.py +0 -0
  121. {riplex-0.9.4 → riplex-0.9.5}/tests/test_cli_utils.py +0 -0
  122. {riplex-0.9.4 → riplex-0.9.5}/tests/test_config.py +0 -0
  123. {riplex-0.9.4 → riplex-0.9.5}/tests/test_dedup.py +0 -0
  124. {riplex-0.9.4 → riplex-0.9.5}/tests/test_detect.py +0 -0
  125. {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_analysis.py +0 -0
  126. {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_detection_screen.py +0 -0
  127. {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_fixtures.py +0 -0
  128. {riplex-0.9.4 → riplex-0.9.5}/tests/test_disc_provider.py +0 -0
  129. {riplex-0.9.4 → riplex-0.9.5}/tests/test_formatter.py +0 -0
  130. {riplex-0.9.4 → riplex-0.9.5}/tests/test_lookup.py +0 -0
  131. {riplex-0.9.4 → riplex-0.9.5}/tests/test_makemkv.py +0 -0
  132. {riplex-0.9.4 → riplex-0.9.5}/tests/test_matcher.py +0 -0
  133. {riplex-0.9.4 → riplex-0.9.5}/tests/test_normalize.py +0 -0
  134. {riplex-0.9.4 → riplex-0.9.5}/tests/test_organizer.py +0 -0
  135. {riplex-0.9.4 → riplex-0.9.5}/tests/test_planner.py +0 -0
  136. {riplex-0.9.4 → riplex-0.9.5}/tests/test_release_screen.py +0 -0
  137. {riplex-0.9.4 → riplex-0.9.5}/tests/test_rip_guide.py +0 -0
  138. {riplex-0.9.4 → riplex-0.9.5}/tests/test_scanner.py +0 -0
  139. {riplex-0.9.4 → riplex-0.9.5}/tests/test_snapshot.py +0 -0
  140. {riplex-0.9.4 → riplex-0.9.5}/tests/test_splitter.py +0 -0
  141. {riplex-0.9.4 → riplex-0.9.5}/tests/test_tagger.py +0 -0
  142. {riplex-0.9.4 → riplex-0.9.5}/tests/test_ui.py +0 -0
  143. {riplex-0.9.4 → riplex-0.9.5}/tests/test_updater.py +0 -0
  144. {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
- cli.py # Backward-compatible shim (re-exports from riplex_cli.main)
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
- orchestrate.py # Shared pipeline logic reusable by CLI and GUI
28
- metadata_provider.py # Abstract metadata provider interface
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 # Full CLI implementation, entry point: main()
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 # Config and tool verification
53
- disc_detection.py # Drive scanning, disc reading
54
- metadata.py # TMDb search and selection
55
- release.py # dvdcompare release picker
56
- selection.py # Title selection with classify_title
57
- progress.py # Rip progress with makemkvcon
58
- done.py # Results summary
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 for parsing tests
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 walkthroughs
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.84+ (optional `[gui]` extra)
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
- ## Current active work
201
-
202
- The monorepo refactor is complete. The repo contains three source packages:
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.4
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 in ("tv", "auto"):
114
- results.extend(await self._search_tv(query, year))
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
- # Sort by descending popularity (TMDb returns popularity-ordered, but
117
- # we merged two lists).
118
- results.sort(key=lambda r: r.popularity, reverse=True)
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.4
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,8 @@
1
+ {
2
+ "tag": "0.9.5",
3
+ "distance": 0,
4
+ "node": "g907de02a593122de68322be39151f1092d623813",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-28"
8
+ }
@@ -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,8 +0,0 @@
1
- {
2
- "tag": "0.9.4",
3
- "distance": 0,
4
- "node": "g2e958718f2247e29a80b9323f72e3feab086e258",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-06-25"
8
- }
@@ -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