riplex 0.9.4__tar.gz → 0.9.6__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.6}/.github/agents/riplex.agent.md +105 -35
  2. {riplex-0.9.4 → riplex-0.9.6}/.github/copilot-instructions.md +28 -0
  3. {riplex-0.9.4/src/riplex.egg-info → riplex-0.9.6}/PKG-INFO +1 -1
  4. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/disc/provider.py +31 -5
  5. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/metadata/sources/tmdb.py +109 -9
  6. {riplex-0.9.4 → riplex-0.9.6/src/riplex.egg-info}/PKG-INFO +1 -1
  7. riplex-0.9.6/src/riplex.egg-info/scm_version.json +8 -0
  8. {riplex-0.9.4 → riplex-0.9.6}/tests/test_disc_analysis.py +59 -0
  9. riplex-0.9.6/tests/test_tmdb_provider.py +211 -0
  10. riplex-0.9.4/src/riplex.egg-info/scm_version.json +0 -8
  11. riplex-0.9.4/tests/test_tmdb_provider.py +0 -57
  12. {riplex-0.9.4 → riplex-0.9.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  13. {riplex-0.9.4 → riplex-0.9.6}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  14. {riplex-0.9.4 → riplex-0.9.6}/.github/workflows/publish.yml +0 -0
  15. {riplex-0.9.4 → riplex-0.9.6}/.github/workflows/release.yml +0 -0
  16. {riplex-0.9.4 → riplex-0.9.6}/.gitignore +0 -0
  17. {riplex-0.9.4 → riplex-0.9.6}/.vscode/settings.json +0 -0
  18. {riplex-0.9.4 → riplex-0.9.6}/CONTRIBUTORS.md +0 -0
  19. {riplex-0.9.4 → riplex-0.9.6}/LICENSE +0 -0
  20. {riplex-0.9.4 → riplex-0.9.6}/README.md +0 -0
  21. {riplex-0.9.4 → riplex-0.9.6}/docs/architecture.md +0 -0
  22. {riplex-0.9.4 → riplex-0.9.6}/docs/changelog.md +0 -0
  23. {riplex-0.9.4 → riplex-0.9.6}/docs/cli-guide/lookup.md +0 -0
  24. {riplex-0.9.4 → riplex-0.9.6}/docs/cli-guide/orchestrate.md +0 -0
  25. {riplex-0.9.4 → riplex-0.9.6}/docs/cli-guide/organize.md +0 -0
  26. {riplex-0.9.4 → riplex-0.9.6}/docs/cli-guide/workflow.md +0 -0
  27. {riplex-0.9.4 → riplex-0.9.6}/docs/getting-started/configuration.md +0 -0
  28. {riplex-0.9.4 → riplex-0.9.6}/docs/getting-started/installation.md +0 -0
  29. {riplex-0.9.4 → riplex-0.9.6}/docs/gui-guide/gui-walkthrough.md +0 -0
  30. {riplex-0.9.4 → riplex-0.9.6}/docs/index.md +0 -0
  31. {riplex-0.9.4 → riplex-0.9.6}/docs/naming-rules.md +0 -0
  32. {riplex-0.9.4 → riplex-0.9.6}/docs/reference/cli.md +0 -0
  33. {riplex-0.9.4 → riplex-0.9.6}/docs/troubleshooting.md +0 -0
  34. {riplex-0.9.4 → riplex-0.9.6}/issues/debug-artifacts-consolidation.md +0 -0
  35. {riplex-0.9.4 → riplex-0.9.6}/issues/manual-rip-and-organize-escape-hatch.md +0 -0
  36. {riplex-0.9.4 → riplex-0.9.6}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  37. {riplex-0.9.4 → riplex-0.9.6}/issues/planned-features.md +0 -0
  38. {riplex-0.9.4 → riplex-0.9.6}/mkdocs.yml +0 -0
  39. {riplex-0.9.4 → riplex-0.9.6}/pyproject.toml +0 -0
  40. {riplex-0.9.4 → riplex-0.9.6}/screenshots/0_Rip_Flow_BTTF.gif +0 -0
  41. {riplex-0.9.4 → riplex-0.9.6}/screenshots/1_Welcome_Screen.png +0 -0
  42. {riplex-0.9.4 → riplex-0.9.6}/screenshots/2_Disc_Detection_BTTF.png +0 -0
  43. {riplex-0.9.4 → riplex-0.9.6}/screenshots/3_Metadata_Lookup_BTTF.png +0 -0
  44. {riplex-0.9.4 → riplex-0.9.6}/screenshots/4_Disc_Release_BTTF.png +0 -0
  45. {riplex-0.9.4 → riplex-0.9.6}/screenshots/5_Multi_Disc_Overview_BTTF.png +0 -0
  46. {riplex-0.9.4 → riplex-0.9.6}/screenshots/5_Select_Title_to_RIP_BTTF.png +0 -0
  47. {riplex-0.9.4 → riplex-0.9.6}/setup.cfg +0 -0
  48. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/__init__.py +0 -0
  49. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/cache.py +0 -0
  50. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/config.py +0 -0
  51. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/dedup.py +0 -0
  52. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/detect.py +0 -0
  53. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/disc/__init__.py +0 -0
  54. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/disc/analysis.py +0 -0
  55. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/disc/makemkv.py +0 -0
  56. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/formatter.py +0 -0
  57. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/lookup.py +0 -0
  58. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/manifest.py +0 -0
  59. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/matcher.py +0 -0
  60. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/metadata/__init__.py +0 -0
  61. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/metadata/planner.py +0 -0
  62. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/metadata/provider.py +0 -0
  63. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/metadata/sources/__init__.py +0 -0
  64. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/models.py +0 -0
  65. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/normalize.py +0 -0
  66. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/organizer.py +0 -0
  67. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/scanner.py +0 -0
  68. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/snapshot.py +0 -0
  69. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/splitter.py +0 -0
  70. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/tagger.py +0 -0
  71. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/title.py +0 -0
  72. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/ui.py +0 -0
  73. {riplex-0.9.4 → riplex-0.9.6}/src/riplex/updater.py +0 -0
  74. {riplex-0.9.4 → riplex-0.9.6}/src/riplex.egg-info/SOURCES.txt +0 -0
  75. {riplex-0.9.4 → riplex-0.9.6}/src/riplex.egg-info/dependency_links.txt +0 -0
  76. {riplex-0.9.4 → riplex-0.9.6}/src/riplex.egg-info/entry_points.txt +0 -0
  77. {riplex-0.9.4 → riplex-0.9.6}/src/riplex.egg-info/requires.txt +0 -0
  78. {riplex-0.9.4 → riplex-0.9.6}/src/riplex.egg-info/scm_file_list.json +0 -0
  79. {riplex-0.9.4 → riplex-0.9.6}/src/riplex.egg-info/top_level.txt +0 -0
  80. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/__init__.py +0 -0
  81. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/bug_report.py +0 -0
  82. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/crash_dump.py +0 -0
  83. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/keep_awake.py +0 -0
  84. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/main.py +0 -0
  85. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/__init__.py +0 -0
  86. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/disc_detection.py +0 -0
  87. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/disc_overview.py +0 -0
  88. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/disc_swap.py +0 -0
  89. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/done.py +0 -0
  90. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/folder_picker.py +0 -0
  91. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/metadata.py +0 -0
  92. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/orchestrate_done.py +0 -0
  93. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/organize_done.py +0 -0
  94. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/organize_preview.py +0 -0
  95. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/progress.py +0 -0
  96. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/release.py +0 -0
  97. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/selection.py +0 -0
  98. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/update.py +0 -0
  99. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_app/screens/welcome.py +0 -0
  100. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/__init__.py +0 -0
  101. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/commands/__init__.py +0 -0
  102. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/commands/lookup.py +0 -0
  103. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/commands/orchestrate.py +0 -0
  104. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/commands/organize.py +0 -0
  105. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/commands/rip.py +0 -0
  106. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/commands/setup.py +0 -0
  107. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/formatting.py +0 -0
  108. {riplex-0.9.4 → riplex-0.9.6}/src/riplex_cli/main.py +0 -0
  109. {riplex-0.9.4 → riplex-0.9.6}/tests/__init__.py +0 -0
  110. {riplex-0.9.4 → riplex-0.9.6}/tests/fixtures/chernobyl_disc1.json +0 -0
  111. {riplex-0.9.4 → riplex-0.9.6}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  112. {riplex-0.9.4 → riplex-0.9.6}/tests/fixtures/makemkvcon_list.txt +0 -0
  113. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  114. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  115. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  116. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  117. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  118. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  119. {riplex-0.9.4 → riplex-0.9.6}/tests/snapshots/Waterworld.snapshot.json +0 -0
  120. {riplex-0.9.4 → riplex-0.9.6}/tests/test_app_main.py +0 -0
  121. {riplex-0.9.4 → riplex-0.9.6}/tests/test_cache.py +0 -0
  122. {riplex-0.9.4 → riplex-0.9.6}/tests/test_cli_utils.py +0 -0
  123. {riplex-0.9.4 → riplex-0.9.6}/tests/test_config.py +0 -0
  124. {riplex-0.9.4 → riplex-0.9.6}/tests/test_dedup.py +0 -0
  125. {riplex-0.9.4 → riplex-0.9.6}/tests/test_detect.py +0 -0
  126. {riplex-0.9.4 → riplex-0.9.6}/tests/test_disc_detection_screen.py +0 -0
  127. {riplex-0.9.4 → riplex-0.9.6}/tests/test_disc_fixtures.py +0 -0
  128. {riplex-0.9.4 → riplex-0.9.6}/tests/test_disc_provider.py +0 -0
  129. {riplex-0.9.4 → riplex-0.9.6}/tests/test_formatter.py +0 -0
  130. {riplex-0.9.4 → riplex-0.9.6}/tests/test_lookup.py +0 -0
  131. {riplex-0.9.4 → riplex-0.9.6}/tests/test_makemkv.py +0 -0
  132. {riplex-0.9.4 → riplex-0.9.6}/tests/test_matcher.py +0 -0
  133. {riplex-0.9.4 → riplex-0.9.6}/tests/test_normalize.py +0 -0
  134. {riplex-0.9.4 → riplex-0.9.6}/tests/test_organizer.py +0 -0
  135. {riplex-0.9.4 → riplex-0.9.6}/tests/test_planner.py +0 -0
  136. {riplex-0.9.4 → riplex-0.9.6}/tests/test_release_screen.py +0 -0
  137. {riplex-0.9.4 → riplex-0.9.6}/tests/test_rip_guide.py +0 -0
  138. {riplex-0.9.4 → riplex-0.9.6}/tests/test_scanner.py +0 -0
  139. {riplex-0.9.4 → riplex-0.9.6}/tests/test_snapshot.py +0 -0
  140. {riplex-0.9.4 → riplex-0.9.6}/tests/test_splitter.py +0 -0
  141. {riplex-0.9.4 → riplex-0.9.6}/tests/test_tagger.py +0 -0
  142. {riplex-0.9.4 → riplex-0.9.6}/tests/test_ui.py +0 -0
  143. {riplex-0.9.4 → riplex-0.9.6}/tests/test_updater.py +0 -0
  144. {riplex-0.9.4 → riplex-0.9.6}/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.6
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
@@ -872,10 +872,16 @@ def detect_disc_number(
872
872
  ) -> int | None:
873
873
  """Auto-detect which dvdcompare disc number the physical disc corresponds to.
874
874
 
875
- Tries three strategies:
875
+ Tries three strategies in order:
876
876
  1. Parse the volume label for a disc number (e.g. "FROZEN_PLANET_II_D2" -> 2)
877
- 2. Match live title durations against each dvdcompare disc's episodes and extras.
878
- 3. For movies, match by disc format/resolution.
877
+ 2. For multi-format releases, match by disc format/resolution (4K vs Blu-ray).
878
+ 3. Match live title durations against each dvdcompare disc's episodes and extras.
879
+
880
+ Strategy 2 runs before duration matching because a movie released across
881
+ several format discs (4K + Blu-ray + 3D) carries the *same* main feature on
882
+ every disc, so its runtime cannot distinguish them — and duration matching
883
+ can actively pick the wrong disc when one disc lacks listed runtimes. The
884
+ resolution is the only reliable distinguishing signal in that case.
879
885
 
880
886
  Returns the disc number (1-based) or None if detection fails.
881
887
  """
@@ -885,7 +891,13 @@ def detect_disc_number(
885
891
  if match:
886
892
  return int(match.group(1))
887
893
 
888
- # Strategy 2: duration matching against dvdcompare discs
894
+ # Strategy 2: format/resolution match (authoritative for multi-format
895
+ # movie releases, where every disc shares the same main feature runtime).
896
+ format_disc = _match_disc_by_format(disc_info, dvdcompare_discs)
897
+ if format_disc is not None:
898
+ return format_disc
899
+
900
+ # Strategy 3: duration matching against dvdcompare discs
889
901
  if not dvdcompare_discs or not disc_info.titles:
890
902
  return None
891
903
 
@@ -936,7 +948,20 @@ def detect_disc_number(
936
948
  if best_score >= 0.5:
937
949
  return best_disc
938
950
 
939
- # Strategy 3: for movies, match by disc format/resolution
951
+ return None
952
+
953
+
954
+ def _match_disc_by_format(disc_info, dvdcompare_discs: list) -> int | None:
955
+ """Match the live disc to a dvdcompare disc by format/resolution.
956
+
957
+ Only returns a result when exactly one disc matches the live disc's
958
+ resolution, i.e. when the format is genuinely distinguishing (e.g. a single
959
+ 4K disc among standard Blu-rays). Same-format multi-disc sets (TV box sets)
960
+ yield multiple candidates and fall through to duration matching.
961
+ """
962
+ if not dvdcompare_discs:
963
+ return None
964
+
940
965
  live_resolutions = {t.resolution for t in disc_info.titles if t.resolution}
941
966
  has_4k = any("2160" in r for r in live_resolutions)
942
967
  has_1080 = any("1080" in r for r in live_resolutions)
@@ -955,6 +980,7 @@ def detect_disc_number(
955
980
  return None
956
981
 
957
982
 
983
+
958
984
  # ---------------------------------------------------------------------------
959
985
  # Disc content summary
960
986
  # ---------------------------------------------------------------------------
@@ -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.6
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.6",
3
+ "distance": 0,
4
+ "node": "g3311b030adbd0a061c09263e0868772b3915ab45",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-28"
8
+ }
@@ -412,6 +412,65 @@ class TestDetectDiscNumber:
412
412
  info = DiscInfo(disc_name="DISC", disc_type="Blu-ray disc", titles=titles)
413
413
  assert _detect_disc_number(info, [disc1]) is None
414
414
 
415
+ def test_4k_disc_matched_by_format_not_duration(self):
416
+ """A 4K movie disc whose feature has no listed runtime must still be
417
+ detected as the 4K disc via resolution — not mis-detected as the
418
+ Blu-ray disc that happens to list the same film's runtime.
419
+
420
+ Regression for the multi-format movie release case (issue: a 4K disc
421
+ was detected as disc 2 because disc 1's 4K feature had no runtime and
422
+ the inserted disc's film matched disc 2's listed 2D/3D runtimes).
423
+ """
424
+ class FakeEp:
425
+ def __init__(self, runtime):
426
+ self.title = "The Film"
427
+ self.runtime_seconds = runtime
428
+
429
+ class FakeDisc:
430
+ def __init__(self, number, fmt, ep_runtimes):
431
+ self.number = number
432
+ self.disc_format = fmt
433
+ self.episodes = [FakeEp(rt) for rt in ep_runtimes]
434
+ self.extras = []
435
+
436
+ # Disc 1 = 4K disc, but dvdcompare lists no runtime for its feature.
437
+ disc1 = FakeDisc(1, "Blu-ray 4K", [])
438
+ # Disc 2 = 3D Blu-ray, lists the 2D + 3D film runtimes (~ same length).
439
+ disc2 = FakeDisc(2, "3D Blu-ray", [2880, 2880])
440
+
441
+ # Inserted disc is the 4K disc: its single film title is 2160p and its
442
+ # runtime matches disc 2's listed film runtime.
443
+ titles = [_make_title(0, 2885, resolution="3840x2160")]
444
+ info = DiscInfo(disc_name="THE_LAST_REEF", disc_type="Blu-ray disc", titles=titles)
445
+ assert _detect_disc_number(info, [disc1, disc2]) == 1
446
+
447
+ def test_format_match_skipped_for_same_format_set(self):
448
+ """A multi-disc set where every disc is the same format must not be
449
+ resolved by format — it falls through to duration matching."""
450
+ class FakeEp:
451
+ def __init__(self, runtime):
452
+ self.title = "Ep"
453
+ self.runtime_seconds = runtime
454
+
455
+ class FakeDisc:
456
+ def __init__(self, number, runtimes):
457
+ self.number = number
458
+ self.disc_format = "Blu-ray 4K"
459
+ self.episodes = [FakeEp(rt) for rt in runtimes]
460
+ self.extras = []
461
+
462
+ # Both discs are 4K, so resolution can't distinguish them.
463
+ disc1 = FakeDisc(1, [3000, 3100, 3050])
464
+ disc2 = FakeDisc(2, [2800, 2900, 2950])
465
+ titles = [
466
+ _make_title(0, 2810, resolution="3840x2160"),
467
+ _make_title(1, 2910, resolution="3840x2160"),
468
+ _make_title(2, 2960, resolution="3840x2160"),
469
+ ]
470
+ info = DiscInfo(disc_name="MYSTERY_DISC", disc_type="Blu-ray disc", titles=titles)
471
+ assert _detect_disc_number(info, [disc1, disc2]) == 2
472
+
473
+
415
474
 
416
475
  class TestAnalyzeDisc:
417
476
  """Tests for the analyze_disc() shared entry point."""