yt-tools-cli 0.24.0__tar.gz → 0.24.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/.claude-plugin/plugin.json +1 -1
  2. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/.github/workflows/ci.yml +7 -3
  3. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/CHANGELOG.md +65 -0
  4. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/PKG-INFO +64 -22
  5. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/README.md +62 -20
  6. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/pyproject.toml +13 -2
  7. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/skills/using-yt-tools/SKILL.md +32 -20
  8. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_doctor.py +7 -1
  9. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_missing_extra.py +29 -7
  10. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_ocr.py +8 -3
  11. yt_tools_cli-0.24.2/tests/test_ocr_engine.py +121 -0
  12. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_packaging.py +74 -0
  13. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_skill_portable.py +31 -0
  14. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/doctor.py +4 -3
  15. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/extras.py +26 -4
  16. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/.github/workflows/release.yml +0 -0
  17. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/.gitignore +0 -0
  18. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/CONTRIBUTING.md +0 -0
  19. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/LICENSE +0 -0
  20. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/hooks/hooks.json +0 -0
  21. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/check-core-install.sh +0 -0
  22. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/check-metadata.py +0 -0
  23. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/check-pypi-name.py +0 -0
  24. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/ensure-install.ps1 +0 -0
  25. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/ensure-install.py +0 -0
  26. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/ensure-install.sh +0 -0
  27. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/publish-check.py +0 -0
  28. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/scripts/sync-version.py +0 -0
  29. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/__init__.py +0 -0
  30. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_cache.py +0 -0
  31. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_check_metadata.py +0 -0
  32. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_cli_smoke.py +0 -0
  33. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_comments.py +0 -0
  34. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_core.py +0 -0
  35. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_distribution_name.py +0 -0
  36. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_failure_modes.py +0 -0
  37. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_frames.py +0 -0
  38. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_listen.py +0 -0
  39. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_markdown.py +0 -0
  40. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_meta.py +0 -0
  41. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_parse_interval.py +0 -0
  42. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_publish_check.py +0 -0
  43. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_pypi_name.py +0 -0
  44. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_search.py +0 -0
  45. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_transcript.py +0 -0
  46. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_version.py +0 -0
  47. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/tests/test_watch.py +0 -0
  48. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/__init__.py +0 -0
  49. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/_metadata.py +0 -0
  50. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/cache.py +0 -0
  51. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/cli.py +0 -0
  52. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/comments.py +0 -0
  53. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/core.py +0 -0
  54. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/frames.py +0 -0
  55. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/listen.py +0 -0
  56. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/markdown.py +0 -0
  57. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/meta.py +0 -0
  58. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/ocr.py +0 -0
  59. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/search.py +0 -0
  60. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/transcript.py +0 -0
  61. {yt_tools_cli-0.24.0 → yt_tools_cli-0.24.2}/yt_tools/watch.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yt-tools",
3
3
  "displayName": "yt-tools",
4
- "version": "0.24.0",
4
+ "version": "0.24.2",
5
5
  "description": "Iterative YouTube watching for agents: clean-markdown transcripts with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), full-metadata extraction (description, chapters, most-replayed, counts, tags), comment scraping, and OCR over cached frames (RapidOCR PP-OCRv5) for silent-with-text videos. Light core (transcript / metadata / comments / search) with heavy stacks in [frames] and [audio] extras. Bundles the using-yt-tools skill and a SessionStart hook that auto-installs from the plugin's local clone via pipx (default [full] extras, fallback to [frames,audio]) and probes ffmpeg.",
6
6
  "author": {
7
7
  "name": "Victor Kuznetsov",
@@ -20,10 +20,14 @@ jobs:
20
20
  python-version: "3.12"
21
21
  # The heavy stacks are installed on purpose: a handful of tests exercise the
22
22
  # real audio pipeline rather than a mock, and a suite that is red on a clean
23
- # machine teaches everyone to ignore it. The light-core contract is covered
24
- # by the gate job below, which runs without any extra.
23
+ # machine teaches everyone to ignore it. `[ocr]` is in the list for the same
24
+ # reason as the rest: the whole suite mocked the engine factory while the
25
+ # published extra resolved to a rapidocr whose params schema yt-ocr does not
26
+ # speak, so a clean `pipx install "yt-tools-cli[ocr]"` broke with the suite
27
+ # green ([[task:2831]]). tests/test_ocr_engine.py drives the real engine and
28
+ # needs the network once, for the ~10 MB PP-OCRv5 model.
25
29
  - name: Install
26
- run: pip install -e ".[full,test]"
30
+ run: pip install -e ".[full,ocr,test]"
27
31
  - name: Unit + CLI smoke tests
28
32
  run: pytest -q
29
33
 
@@ -11,6 +11,71 @@ Releases before 0.8.0 are summarized only in the git log.
11
11
 
12
12
  _(nothing yet)_
13
13
 
14
+ ## [0.24.2] — 2026-09-13
15
+
16
+ ### Fixed
17
+
18
+ - **`yt-ocr` on a clean `[ocr]` install** ([[issue:77]], [[task:2831]]). The extra
19
+ declared `rapidocr>=3.8`, so the resolver picked 3.9.2 — where the bundled
20
+ defaults moved to PP-OCRv6 with `model_type=small`, while PP-OCRv5 (which
21
+ `yt_tools/ocr.py` pins) ships `mobile`/`server` only. Engine construction died
22
+ with `error: Invalid OCR configuration`, i.e. the install did exactly what the
23
+ metadata asked and the first `yt-ocr` run failed. `[ocr]` now reads
24
+ `rapidocr>=3.8,<3.9`, with the reason in the metadata, and the bound is lifted
25
+ only in the change that teaches `ocr.py` the 3.9 model matrix.
26
+ - **The `pipx inject` hint installed the version the extra excludes.** The
27
+ refusal, `doctor`'s `next_step` and the README all printed
28
+ `pipx inject yt-tools-cli rapidocr onnxruntime` — an inject resolves from PyPI
29
+ directly, so following the message reproduced the bug above. The hint now
30
+ carries the same bound as the extra (one constant, pinned equal by test) and
31
+ double-quotes every spec, because `>` and `<` are redirections in bash,
32
+ cmd.exe and PowerShell alike; `doctor` formats its multi-extra command through
33
+ the same helper instead of re-joining the list.
34
+
35
+ ### Added
36
+
37
+ - **A real-engine test for the `[ocr]` extra** (`tests/test_ocr_engine.py`).
38
+ Every yt-ocr integration test mocked `_load_engine`, so the whole suite stayed
39
+ green while the published extra was broken — the mocked seam was the broken
40
+ seam. The new test builds the engine through `_load_engine` and OCRs a frame
41
+ it renders itself (OpenCV's built-in Hershey font, no binary fixture), and the
42
+ CI `pytest` job installs `[full,ocr,test]` so it cannot pass by being skipped.
43
+ Verified against both ends: rapidocr 3.9.2 → the `Invalid OCR configuration`
44
+ failure above, 3.8.4 → text read back and a non-empty `ocr.md`.
45
+ - **A guard on the skill description length.** `using-yt-tools`'s description was
46
+ 2511 characters as the YAML scalar (2261 as the host measured it) against a
47
+ 1024 cap — the part the host matches triggers on, so a harness reported the
48
+ skill as conflicted. Trimmed to 987 with prose cut and every flow name and
49
+ trigger phrase kept; the new test fails on growth and on a trim that drops a
50
+ flow's command name.
51
+
52
+ ### Changed
53
+
54
+ - **Docs corrected against a real machine install** ([[wiki:3595]]): `[full]` is
55
+ a flow default, **not** "everything" — it excludes `[ocr]` and carries no
56
+ `bpm-detector` VCS dependency, so chord progression / structural segments need
57
+ the inject and read `n/a` without it; the OCR section names the `rapidocr`
58
+ bound; the standalone install block offers
59
+ `pipx install "yt-tools-cli[full,ocr]"` (metadata-resolved) alongside the
60
+ bounded inject, and pins `0.24.2`; `yt-frames` is documented as requiring
61
+ `--timestamps` or `--mode {interval|scene}`, and `yt-ocr` as reading
62
+ `./yt-cache/<vid>/frames/` unless given `--timestamps`.
63
+ - **The OCR model path.** The README and skill claimed the PP-OCRv5 weights land
64
+ in `~/.cache/rapidocr/`. In RapidOCR 3.8.x they land in the installed package's
65
+ own `rapidocr/models/` directory — found while running the clean-venv
66
+ acceptance, so the claim is replaced by the measured one.
67
+
68
+ ## [0.24.1] — 2026-09-13
69
+
70
+ ### Fixed
71
+
72
+ - The plugin install line named a marketplace that no longer exists:
73
+ `yt-tools@opeitcloc03-claude-plugins` → `yt-tools@kzntsv-dev-claude-plugins`.
74
+ The marketplace *name* is a string in `.claude-plugin/marketplace.json`, not a
75
+ property of the GitHub account — renaming the account rewrites paths (and
76
+ redirects the old one), never file contents. The catalog was renamed today;
77
+ this repository's README and skill still pointed at the retired name.
78
+
14
79
  ## [0.24.0] — 2026-09-13
15
80
 
16
81
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: yt-tools-cli
3
- Version: 0.24.0
3
+ Version: 0.24.2
4
4
  Summary: CLI suite for iterative YouTube watching by LLM agents: transcript with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), and OCR over cached frames for silent-with-text videos.
5
5
  Project-URL: Homepage, https://github.com/kzntsv-dev/yt-tools
6
6
  Project-URL: Repository, https://github.com/kzntsv-dev/yt-tools
@@ -35,7 +35,7 @@ Requires-Dist: matplotlib>=3.8; extra == 'full'
35
35
  Requires-Dist: scenedetect[opencv]>=0.6.4; extra == 'full'
36
36
  Provides-Extra: ocr
37
37
  Requires-Dist: onnxruntime>=1.18; extra == 'ocr'
38
- Requires-Dist: rapidocr>=3.8; extra == 'ocr'
38
+ Requires-Dist: rapidocr<3.9,>=3.8; extra == 'ocr'
39
39
  Provides-Extra: test
40
40
  Requires-Dist: pytest>=7.0; extra == 'test'
41
41
  Description-Content-Type: text/markdown
@@ -69,7 +69,9 @@ has to guess where the artefact landed:
69
69
  text (schematic labels, chord matrices, parameter walkthroughs). Needs
70
70
  the `[ocr]` extra.
71
71
  - `yt-listen` — FFT audio analysis: per-timestamp clip + mel-spectrogram PNG +
72
- features `.md` with BPM, key, chord progression, and spectral statistics.
72
+ features `.md` with BPM, key, and spectral statistics (chord progression and
73
+ structural segments come from the optional `bpm-detector` enrichment —
74
+ [see below](#installing-bpm-detector); without it those rows read `n/a`).
73
75
  - `yt-watch` — combined transcript + scene-frames in one `.md` with
74
76
  `![](frames/...)` sidecar embeds.
75
77
  - `yt-tools cache list | prune` — manage the source-mp4 cache.
@@ -77,14 +79,14 @@ has to guess where the artefact landed:
77
79
  ## Installation
78
80
 
79
81
  `yt-tools` v1 ships **via a Claude Code plugin** (recommended for agent
80
- workflows) or **directly from this Git repository** (for standalone CLI
81
- use in any environment). PyPI distribution is deferred to a future release.
82
+ workflows), **from PyPI** as the distribution `yt-tools-cli`, or **directly from
83
+ this Git repository** (a checkout, or anyone tracking `master`).
82
84
 
83
85
  ### As a Claude Code plugin (recommended for agent workflows)
84
86
 
85
87
  ```text
86
88
  /plugin marketplace add kzntsv-dev/claude-plugins
87
- /plugin install yt-tools@opeitcloc03-claude-plugins
89
+ /plugin install yt-tools@kzntsv-dev-claude-plugins
88
90
  ```
89
91
 
90
92
  The plugin's `SessionStart` hook installs the package from its own clone —
@@ -137,14 +139,23 @@ every console command stay `yt-tools`, and the import name stays `yt_tools`.
137
139
  python -m pip install --user pipx
138
140
  python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
139
141
 
140
- # 2. install from PyPI — core + [frames] + [audio], i.e. every flow except OCR
142
+ # 2. install from PyPI — [full] = core + [frames] + [audio]
143
+ # NOTE: [full] does NOT include OCR (see the next command)
141
144
  pipx install "yt-tools-cli[full]"
142
145
 
143
146
  # reproducible: pin the exact version
144
- pipx install "yt-tools-cli[full]==0.23.1"
145
-
146
- # [ocr] is always separate and explicit (its ONNX model is ~10 MB, fetched lazily)
147
- pipx inject yt-tools-cli rapidocr onnxruntime
147
+ pipx install "yt-tools-cli[full]==0.24.2"
148
+
149
+ # OCR is always an explicit extra (its ONNX model is ~10 MB, fetched lazily).
150
+ # Preferred: name it at install time, so pip resolves it from the metadata —
151
+ # bounds included — instead of from PyPI's "latest":
152
+ pipx install "yt-tools-cli[full,ocr]"
153
+
154
+ # Already installed? Widen the venv in place. Quote the specs: `>` and `<` are
155
+ # redirections in bash, cmd.exe and PowerShell. `rapidocr` is capped below 3.9
156
+ # because 3.9 moved the bundled defaults to PP-OCRv6, where the PP-OCRv5 params
157
+ # yt-ocr builds no longer resolve (see the OCR section).
158
+ pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
148
159
  ```
149
160
 
150
161
  `uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
@@ -157,6 +168,11 @@ pipx inject yt-tools-cli rapidocr onnxruntime
157
168
  pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
158
169
  ```
159
170
 
171
+ > **Which Python.** The ceiling is real: `requires-python` is `>=3.10,<3.13`, so
172
+ > on a machine whose only interpreter is 3.13 the install is refused by
173
+ > `pip`/`pipx` (`librosa` 3.13 friction holds the ceiling — see Requirements).
174
+ > Install a 3.12 interpreter, or point pipx at one: `pipx install --python 3.12 …`.
175
+
160
176
  > **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
161
177
  > first, then install the new extras set. `pipx install --force` on an existing
162
178
  > venv can leave the new extras half-installed and resolve against the old pin
@@ -176,12 +192,20 @@ matplotlib. Heavier stacks are opt-in, per flow.
176
192
  | core (no extras) | `youtube-transcript-api`, `yt-dlp` | `yt-transcript`, `yt-meta`, `yt-comments`, `yt-search`, `yt-tools cache`; `yt-frames --timestamps` / `--mode interval` (without near-duplicate dedup) |
177
193
  | `[frames]` | `scenedetect[opencv]` (OpenCV) | `yt-frames --mode scene`, `yt-watch`, and dedup in the other frame modes |
178
194
  | `[audio]` | `librosa`, `matplotlib` | `yt-listen` (spectral features, spectrogram, BPM/key) |
179
- | `[ocr]` | `rapidocr`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
180
- | `[full]` | core + `[frames]` + `[audio]` | everything above except `yt-ocr` — this is what the plugin hook installs |
195
+ | `[ocr]` | `rapidocr>=3.8,<3.9`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
196
+ | `[full]` | core + `[frames]` + `[audio]` | the plugin default: every flow above **except `yt-ocr`** |
197
+
198
+ `[full]` is a *flow* default, not "everything". Two things stay outside it on
199
+ purpose: `[ocr]` (the ~10 MB ONNX model is always an explicit opt-in) and
200
+ `bpm-detector` (not on PyPI — see below). If you want one install that runs every
201
+ CLI in this README, name both: `pipx install "yt-tools-cli[full,ocr]"` plus the
202
+ inject below.
181
203
 
182
204
  #### Installing bpm-detector
183
205
 
184
- `[full]` deliberately stops at `[frames]` + `[audio]`. The optional
206
+ `[full]` deliberately stops at `[frames]` + `[audio]`: it carries **no VCS
207
+ dependency**, so nothing a user installs from PyPI hits a Git remote (the plugin
208
+ hook injects this one separately, best-effort). The optional
185
209
  [`bpm-detector`](https://github.com/libraz/bpm-detector) enrichment (chord
186
210
  progression, structural segments, refined BPM, a confidence-scored key per
187
211
  timestamp) is not on PyPI, so it can only be a PEP 508 *direct reference* — and
@@ -444,11 +468,17 @@ models via `onnxruntime`).
444
468
  ```bash
445
469
  # 1) extract frames first (lower scene threshold catches overlay fades
446
470
  # within the same shot; or use --mode interval for a denser sample)
471
+ # yt-frames needs a mode: it will not guess between interval and scene.
472
+ # (--mode scene needs the [frames] extra; --mode interval does not.)
447
473
  yt-frames URL --mode scene --scene-threshold 12
448
474
  # or:
449
475
  yt-frames URL --mode interval --interval 15s
476
+ # or, for named moments — no mode flag, no [frames] extra:
477
+ yt-frames URL --timestamps 1:30,2:45
450
478
 
451
479
  # 2) OCR every cached frame → markdown with [mm:ss] anchors
480
+ # yt-ocr reads ./yt-cache/<vid>/frames/ — it extracts nothing on its own
481
+ # unless you give it --timestamps (which calls the yt-frames helper for you).
452
482
  yt-ocr URL
453
483
  # → ./yt-cache/<vid>/ocr.md
454
484
 
@@ -467,16 +497,26 @@ per detected text region, an explicit `_(no text detected)_` marker for
467
497
  frames where the engine returned nothing (so an agent can tell the frame
468
498
  was checked vs. silently omitted).
469
499
 
470
- > **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not**
471
- > in the core install. If `yt-ocr` exits with the missing-extra hint,
472
- > run:
500
+ > **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not** in the
501
+ > core install — and **not** in `[full]` either. If `yt-ocr` exits with the
502
+ > missing-extra hint, run:
473
503
  > ```bash
474
- > pipx inject yt-tools-cli rapidocr onnxruntime
504
+ > pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
475
505
  > # or for non-pipx setups:
476
506
  > pip install 'yt-tools-cli[ocr]'
477
507
  > ```
478
- > First run with a given `--language` lazy-downloads a ~10 MB PP-OCRv5
479
- > ONNX model into `~/.cache/rapidocr/`.
508
+ > First run with a given `--language` lazy-downloads the PP-OCRv5 ONNX models
509
+ > (~8 MB for the english recognizer, plus the shared detector). In RapidOCR 3.8.x
510
+ > they land in the installed package's own `rapidocr/models/` directory, not in
511
+ > `~/.cache/` — verified on a clean venv.
512
+ >
513
+ > The `rapidocr` bound is not decoration: 3.9 moved the bundled defaults to
514
+ > PP-OCRv6 with `model_type=small`, and PP-OCRv5 ships `mobile`/`server` only —
515
+ > the v5 detector the CLI asks for resolves to nothing there, so a 3.9 install
516
+ > fails at engine construction with `error: Invalid OCR configuration`. The
517
+ > `[ocr]` extra carries the bound, and so does the `pipx inject` line above: an
518
+ > inject resolves from PyPI directly and would otherwise install the version the
519
+ > extra exists to exclude.
480
520
 
481
521
  ### Cache hygiene
482
522
 
@@ -552,8 +592,10 @@ scripts trivial.
552
592
  - **Scene threshold:** 27 (PySceneDetect `ContentDetector` default; lower =
553
593
  more sensitive).
554
594
  - **`yt-listen` defaults:** 8-second clip per timestamp, mel-spectrogram at
555
- default `librosa` settings, full feature set when `[full]` extra is
556
- installed.
595
+ default `librosa` settings; BPM/key/spectral features when the `[audio]` extra
596
+ is installed, chord progression and structural segments only when
597
+ `bpm-detector` has been injected as well — on a plain `[full]` install those
598
+ rows are `n/a`, not silently absent.
557
599
 
558
600
  ## Requirements
559
601
 
@@ -27,7 +27,9 @@ has to guess where the artefact landed:
27
27
  text (schematic labels, chord matrices, parameter walkthroughs). Needs
28
28
  the `[ocr]` extra.
29
29
  - `yt-listen` — FFT audio analysis: per-timestamp clip + mel-spectrogram PNG +
30
- features `.md` with BPM, key, chord progression, and spectral statistics.
30
+ features `.md` with BPM, key, and spectral statistics (chord progression and
31
+ structural segments come from the optional `bpm-detector` enrichment —
32
+ [see below](#installing-bpm-detector); without it those rows read `n/a`).
31
33
  - `yt-watch` — combined transcript + scene-frames in one `.md` with
32
34
  `![](frames/...)` sidecar embeds.
33
35
  - `yt-tools cache list | prune` — manage the source-mp4 cache.
@@ -35,14 +37,14 @@ has to guess where the artefact landed:
35
37
  ## Installation
36
38
 
37
39
  `yt-tools` v1 ships **via a Claude Code plugin** (recommended for agent
38
- workflows) or **directly from this Git repository** (for standalone CLI
39
- use in any environment). PyPI distribution is deferred to a future release.
40
+ workflows), **from PyPI** as the distribution `yt-tools-cli`, or **directly from
41
+ this Git repository** (a checkout, or anyone tracking `master`).
40
42
 
41
43
  ### As a Claude Code plugin (recommended for agent workflows)
42
44
 
43
45
  ```text
44
46
  /plugin marketplace add kzntsv-dev/claude-plugins
45
- /plugin install yt-tools@opeitcloc03-claude-plugins
47
+ /plugin install yt-tools@kzntsv-dev-claude-plugins
46
48
  ```
47
49
 
48
50
  The plugin's `SessionStart` hook installs the package from its own clone —
@@ -95,14 +97,23 @@ every console command stay `yt-tools`, and the import name stays `yt_tools`.
95
97
  python -m pip install --user pipx
96
98
  python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
97
99
 
98
- # 2. install from PyPI — core + [frames] + [audio], i.e. every flow except OCR
100
+ # 2. install from PyPI — [full] = core + [frames] + [audio]
101
+ # NOTE: [full] does NOT include OCR (see the next command)
99
102
  pipx install "yt-tools-cli[full]"
100
103
 
101
104
  # reproducible: pin the exact version
102
- pipx install "yt-tools-cli[full]==0.23.1"
103
-
104
- # [ocr] is always separate and explicit (its ONNX model is ~10 MB, fetched lazily)
105
- pipx inject yt-tools-cli rapidocr onnxruntime
105
+ pipx install "yt-tools-cli[full]==0.24.2"
106
+
107
+ # OCR is always an explicit extra (its ONNX model is ~10 MB, fetched lazily).
108
+ # Preferred: name it at install time, so pip resolves it from the metadata —
109
+ # bounds included — instead of from PyPI's "latest":
110
+ pipx install "yt-tools-cli[full,ocr]"
111
+
112
+ # Already installed? Widen the venv in place. Quote the specs: `>` and `<` are
113
+ # redirections in bash, cmd.exe and PowerShell. `rapidocr` is capped below 3.9
114
+ # because 3.9 moved the bundled defaults to PP-OCRv6, where the PP-OCRv5 params
115
+ # yt-ocr builds no longer resolve (see the OCR section).
116
+ pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
106
117
  ```
107
118
 
108
119
  `uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
@@ -115,6 +126,11 @@ pipx inject yt-tools-cli rapidocr onnxruntime
115
126
  pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
116
127
  ```
117
128
 
129
+ > **Which Python.** The ceiling is real: `requires-python` is `>=3.10,<3.13`, so
130
+ > on a machine whose only interpreter is 3.13 the install is refused by
131
+ > `pip`/`pipx` (`librosa` 3.13 friction holds the ceiling — see Requirements).
132
+ > Install a 3.12 interpreter, or point pipx at one: `pipx install --python 3.12 …`.
133
+
118
134
  > **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
119
135
  > first, then install the new extras set. `pipx install --force` on an existing
120
136
  > venv can leave the new extras half-installed and resolve against the old pin
@@ -134,12 +150,20 @@ matplotlib. Heavier stacks are opt-in, per flow.
134
150
  | core (no extras) | `youtube-transcript-api`, `yt-dlp` | `yt-transcript`, `yt-meta`, `yt-comments`, `yt-search`, `yt-tools cache`; `yt-frames --timestamps` / `--mode interval` (without near-duplicate dedup) |
135
151
  | `[frames]` | `scenedetect[opencv]` (OpenCV) | `yt-frames --mode scene`, `yt-watch`, and dedup in the other frame modes |
136
152
  | `[audio]` | `librosa`, `matplotlib` | `yt-listen` (spectral features, spectrogram, BPM/key) |
137
- | `[ocr]` | `rapidocr`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
138
- | `[full]` | core + `[frames]` + `[audio]` | everything above except `yt-ocr` — this is what the plugin hook installs |
153
+ | `[ocr]` | `rapidocr>=3.8,<3.9`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
154
+ | `[full]` | core + `[frames]` + `[audio]` | the plugin default: every flow above **except `yt-ocr`** |
155
+
156
+ `[full]` is a *flow* default, not "everything". Two things stay outside it on
157
+ purpose: `[ocr]` (the ~10 MB ONNX model is always an explicit opt-in) and
158
+ `bpm-detector` (not on PyPI — see below). If you want one install that runs every
159
+ CLI in this README, name both: `pipx install "yt-tools-cli[full,ocr]"` plus the
160
+ inject below.
139
161
 
140
162
  #### Installing bpm-detector
141
163
 
142
- `[full]` deliberately stops at `[frames]` + `[audio]`. The optional
164
+ `[full]` deliberately stops at `[frames]` + `[audio]`: it carries **no VCS
165
+ dependency**, so nothing a user installs from PyPI hits a Git remote (the plugin
166
+ hook injects this one separately, best-effort). The optional
143
167
  [`bpm-detector`](https://github.com/libraz/bpm-detector) enrichment (chord
144
168
  progression, structural segments, refined BPM, a confidence-scored key per
145
169
  timestamp) is not on PyPI, so it can only be a PEP 508 *direct reference* — and
@@ -402,11 +426,17 @@ models via `onnxruntime`).
402
426
  ```bash
403
427
  # 1) extract frames first (lower scene threshold catches overlay fades
404
428
  # within the same shot; or use --mode interval for a denser sample)
429
+ # yt-frames needs a mode: it will not guess between interval and scene.
430
+ # (--mode scene needs the [frames] extra; --mode interval does not.)
405
431
  yt-frames URL --mode scene --scene-threshold 12
406
432
  # or:
407
433
  yt-frames URL --mode interval --interval 15s
434
+ # or, for named moments — no mode flag, no [frames] extra:
435
+ yt-frames URL --timestamps 1:30,2:45
408
436
 
409
437
  # 2) OCR every cached frame → markdown with [mm:ss] anchors
438
+ # yt-ocr reads ./yt-cache/<vid>/frames/ — it extracts nothing on its own
439
+ # unless you give it --timestamps (which calls the yt-frames helper for you).
410
440
  yt-ocr URL
411
441
  # → ./yt-cache/<vid>/ocr.md
412
442
 
@@ -425,16 +455,26 @@ per detected text region, an explicit `_(no text detected)_` marker for
425
455
  frames where the engine returned nothing (so an agent can tell the frame
426
456
  was checked vs. silently omitted).
427
457
 
428
- > **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not**
429
- > in the core install. If `yt-ocr` exits with the missing-extra hint,
430
- > run:
458
+ > **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not** in the
459
+ > core install — and **not** in `[full]` either. If `yt-ocr` exits with the
460
+ > missing-extra hint, run:
431
461
  > ```bash
432
- > pipx inject yt-tools-cli rapidocr onnxruntime
462
+ > pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
433
463
  > # or for non-pipx setups:
434
464
  > pip install 'yt-tools-cli[ocr]'
435
465
  > ```
436
- > First run with a given `--language` lazy-downloads a ~10 MB PP-OCRv5
437
- > ONNX model into `~/.cache/rapidocr/`.
466
+ > First run with a given `--language` lazy-downloads the PP-OCRv5 ONNX models
467
+ > (~8 MB for the english recognizer, plus the shared detector). In RapidOCR 3.8.x
468
+ > they land in the installed package's own `rapidocr/models/` directory, not in
469
+ > `~/.cache/` — verified on a clean venv.
470
+ >
471
+ > The `rapidocr` bound is not decoration: 3.9 moved the bundled defaults to
472
+ > PP-OCRv6 with `model_type=small`, and PP-OCRv5 ships `mobile`/`server` only —
473
+ > the v5 detector the CLI asks for resolves to nothing there, so a 3.9 install
474
+ > fails at engine construction with `error: Invalid OCR configuration`. The
475
+ > `[ocr]` extra carries the bound, and so does the `pipx inject` line above: an
476
+ > inject resolves from PyPI directly and would otherwise install the version the
477
+ > extra exists to exclude.
438
478
 
439
479
  ### Cache hygiene
440
480
 
@@ -510,8 +550,10 @@ scripts trivial.
510
550
  - **Scene threshold:** 27 (PySceneDetect `ContentDetector` default; lower =
511
551
  more sensitive).
512
552
  - **`yt-listen` defaults:** 8-second clip per timestamp, mel-spectrogram at
513
- default `librosa` settings, full feature set when `[full]` extra is
514
- installed.
553
+ default `librosa` settings; BPM/key/spectral features when the `[audio]` extra
554
+ is installed, chord progression and structural segments only when
555
+ `bpm-detector` has been injected as well — on a plain `[full]` install those
556
+ rows are `n/a`, not silently absent.
515
557
 
516
558
  ## Requirements
517
559
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "yt-tools-cli"
7
- version = "0.24.0"
7
+ version = "0.24.2"
8
8
  description = "CLI suite for iterative YouTube watching by LLM agents: transcript with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), and OCR over cached frames for silent-with-text videos."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.13"
@@ -79,7 +79,18 @@ full = [
79
79
  "matplotlib>=3.8",
80
80
  ]
81
81
  ocr = [
82
- "rapidocr>=3.8",
82
+ # Capped below 3.9 on purpose ([[task:2831]], [[issue:77]]). 3.9 moved the
83
+ # bundled defaults to ``ocr_version=PP-OCRv6, model_type=small``; ocr.py
84
+ # overrides the ocr_version to PP-OCRv5 but leaves model_type at that
85
+ # default, and PP-OCRv5 ships ``mobile``/``server`` only — so "PP-OCRv5 small"
86
+ # resolves to nothing (the lenient prefix fallback finds no candidate either)
87
+ # and RapidOCR raises ``error: Invalid OCR configuration`` at construction.
88
+ # A clean ``pipx install "yt-tools-cli[ocr]"`` therefore installed 3.9.2 and
89
+ # every ``yt-ocr`` run died. Lift the cap only in the change that teaches
90
+ # ocr.py the 3.9 model matrix (pin ``model_type`` explicitly, then re-check
91
+ # every ``--language``) — tests/test_ocr_engine.py drives the real engine and
92
+ # fails if the pin comes off alone.
93
+ "rapidocr>=3.8,<3.9",
83
94
  "onnxruntime>=1.18",
84
95
  ]
85
96
  test = ["pytest>=7.0"]
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: using-yt-tools
3
- version: 0.24.0
4
- description: Seven flows for YouTube content. **Discovery-search** (`yt-search`) — query → markdown list of candidate videos via yt-dlp's native ytsearch extractor; the entry point when the user hasn't named a URL yet. **Iterative-watch** (summary / exploration) — transcript with [mm:ss] anchors → pick moments → extract frames. **Targeted-frames** (specific timestamps) — extract frames directly, no transcript. **Audio-analysis** (music FFT) — per timestamp spectrogram + numeric digest (BPM, key, chord progression, harmonic content) via `yt-listen`. **Metadata** (`yt-meta`, free — rides the same yt-dlp call) — description, chapters, most-replayed heatmap, view/like/comment counts, tags, subtitle languages as markdown. **Comments** (`yt-comments`, separate paginated scrape) — top comments + nested replies; capped top-50 by default, costly on viral videos, only on explicit request. **OCR** (`yt-ocr`, silent-with-text fallback) — RapidOCR PP-OCRv5 over cached frames → `[mm:ss]` blocks as a transcript substitute when `yt-transcript` is empty or the video is silent-instructional (burned-in captions, schematic labels, chord matrices). Triggers (mixed RU/EN — same skill serves both audiences) — "find a video about X", "search youtube for X", "find tutorial about X", "найди видео про X", "поищи туториал", "обзор на X youtube", "what's in this video", "video summary", "youtube transcript", "что в ролике", "о чём видео", "show frame at N", "покажи кадр на N", "listen to fragment at N", "послушай момент N", "what's the BPM", "BPM/тональность видео", "analyze audio", "спектрограмма", "video description", "show chapters", "most replayed", "video stats", "что в описании", "покажи главы", "самые пересматриваемые", "top comments", "what are people saying", "комменты под роликом", "топ комментариев", "read text from frames", "OCR this video", "what does the caption say", "extract overlay text", "captions burned in", "прочти текст с кадров", "OCR этого видео", "silent video с текстом", or any youtube.com URL. CLI installed once per machine with pipx (`pipx install 'yt-tools-cli[full]'` adds the audio/chord stack); `yt-tools doctor` is the read-only preflight that names whatever is missing. YouTube-only — for Vimeo / Twitch / local files use other tools.
3
+ version: 0.24.2
4
+ description: Seven YouTube flows, one skill. Discovery `yt-search` ("find a video about X", «найди видео про X», «поищи туториал»). Summary / exploration `yt-transcript` — [mm:ss] anchors, then pick moments and run `yt-frames` ("what's in this video", «о чём видео»). Targeted frames directly by timestamp ("show frame at N", «покажи кадр на N»). Audio FFT `yt-listen` — BPM, key, chords, structure ("what's the BPM", «спектрограмма», «тональность видео»). Metadata `yt-meta` — description, chapters, most-replayed ("video description", «что в описании», «покажи главы»). Comments `yt-comments` ("top comments", «комменты под роликом»). OCR over cached frames `yt-ocr` — silent-with-text fallback ("read text from frames", "captions burned in", «прочти текст с кадров»). Any youtube.com URL. Install once per machine with pipx (`pipx install 'yt-tools-cli[full]'`); `yt-tools doctor` is the read-only preflight that names what is missing. YouTube-only — Vimeo / Twitch / local files need other tools.
5
5
  ---
6
6
 
7
7
  # using-yt-tools
@@ -106,13 +106,15 @@ Seven distinct flows, picked by user intent:
106
106
  → Read `<vid>/ocr.md` as a transcript substitute (per-timestamp
107
107
  `[mm:ss]` blocks, same paradigm) → continue into Flow B at the
108
108
  interesting timestamps if you need the original frames.
109
- - **Requires the `[ocr]` extra** — `rapidocr` + `onnxruntime`. If
110
- missing on the running yt-tools install, `yt-ocr` exits nonzero with
111
- both install hints (`pipx inject yt-tools-cli rapidocr onnxruntime` /
112
- `pip install 'yt-tools-cli[ocr]'`). The plugin's SessionStart hook
113
- installs `[full]` (core + `[frames]` + `[audio]`, plus a
114
- best-effort `bpm-detector` inject) only, **not** `[ocr]` — first run
115
- of Flow F on a machine may need the inject step.
109
+ - **Requires the `[ocr]` extra** — `rapidocr` + `onnxruntime`, **not** part of
110
+ `[full]`. If missing on the running yt-tools install, `yt-ocr` exits nonzero
111
+ with both install hints (`pipx inject yt-tools-cli "rapidocr>=3.8,<3.9"
112
+ "onnxruntime>=1.18"` / `pip install 'yt-tools-cli[ocr]'`). The plugin's
113
+ SessionStart hook installs `[full]` (core + `[frames]` + `[audio]`, plus a
114
+ best-effort `bpm-detector` inject) only, **not** `[ocr]` — first run of Flow F
115
+ on a machine may need the inject step. `yt-ocr` reads
116
+ `./yt-cache/<vid>/frames/`, so either run `yt-frames` first or pass
117
+ `--timestamps` (which extracts for you).
116
118
  - Trigger phrases: "read text from frames", "OCR this video", "what
117
119
  does the caption say", "extract overlay text", "captions burned in",
118
120
  "silent video with text on screen", «прочти текст с кадров», «OCR
@@ -138,14 +140,22 @@ Install once per machine — one command, the same in every harness. The
138
140
  commands stay `yt-tools`; the import name is `yt_tools`):
139
141
 
140
142
  ```bash
141
- # from PyPI — core + [frames] + [audio], i.e. every flow except OCR
143
+ # from PyPI — [full] = core + [frames] + [audio]. NOT OCR (see below).
142
144
  pipx install "yt-tools-cli[full]"
143
145
 
144
146
  # core only (transcript / meta / comments / search / cache)
145
147
  pipx install yt-tools-cli
146
148
 
147
149
  # reproducible: pin the exact version
148
- pipx install "yt-tools-cli[full]==0.23.1"
150
+ pipx install "yt-tools-cli[full]==0.24.2"
151
+
152
+ # OCR (explicit extra, ~10 MB model fetched on first run). Naming it in the
153
+ # install lets pip resolve it from the metadata, bounds included:
154
+ pipx install "yt-tools-cli[full,ocr]"
155
+ # already installed? widen in place — `rapidocr` is capped below 3.9, where the
156
+ # PP-OCRv5 params yt-ocr builds no longer resolve (quote the specs: `>`/`<` are
157
+ # redirections in bash, cmd.exe and PowerShell):
158
+ pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
149
159
  ```
150
160
 
151
161
  From a checkout instead of PyPI (the plugin path, or tracking `master`):
@@ -154,7 +164,8 @@ From a checkout instead of PyPI (the plugin path, or tracking `master`):
154
164
  pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
155
165
  ```
156
166
 
157
- The `[full]` extra is core + `[frames]` + `[audio]`. Chord progression,
167
+ The `[full]` extra is core + `[frames]` + `[audio]` — a flow default, not
168
+ "everything": OCR and `bpm-detector` are both outside it. Chord progression,
158
169
  structural segments and the refined BPM/key in `yt-listen` come from
159
170
  `bpm-detector`, which is **not on PyPI** and therefore cannot live in the
160
171
  published metadata — the Python package index rejects direct VCS references.
@@ -165,8 +176,7 @@ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detec
165
176
  ```
166
177
 
167
178
  Without it `yt-listen` still runs (librosa-only BPM/key, unfilled sections marked
168
- `n/a`). `[ocr]` is always an explicit extra: `pipx inject yt-tools-cli rapidocr
169
- onnxruntime`.
179
+ `n/a`).
170
180
 
171
181
  Some harnesses do this for you: if your host ships the bundled yt-tools plugin
172
182
  (Claude Code does), its `SessionStart` hook pipx-installs the `[full]` set from
@@ -239,7 +249,7 @@ the optional `[ocr]` extra):
239
249
  pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
240
250
  ```
241
251
  If your host ships the bundled plugin (Claude Code users: install
242
- `yt-tools@opeitcloc03-claude-plugins`), prefer it — its SessionStart hook
252
+ `yt-tools@kzntsv-dev-claude-plugins`), prefer it — its SessionStart hook
243
253
  does the pipx install from the plugin's local clone automatically.
244
254
 
245
255
  **ffmpeg**:
@@ -522,9 +532,9 @@ All failures abort cleanly; never leave a half-finished state.
522
532
  | `Subtitles disabled` / `no captions in the requested language(s)` / `the transcript API returned nothing usable (ParseError: …)` from `yt-transcript` | Captions are absent (channel-disabled, wrong `--lang`, or YouTube answered with an empty body — the last one used to surface as a bare `no element found: line 1, column 0`). The message names the reason and both fallbacks. | Fatal for Flow A — tell the user the reason, and switch: Flow C (`yt-listen`) for audio, Flow F (`yt-ocr`) for text burned into the frames. Try another `--lang` first only when the reason says the *requested language* is missing. `yt-watch` on the same video does **not** fail — it writes a frames-only `watch.md` with the reason in its header, so read the frames and say that the text layer is absent. |
523
533
  | Flow B/D — `yt-frames --mode scene requires the [frames] extra` / `yt-watch requires the [frames] extra` | `scenedetect` / `opencv-python` not in the active pipx venv | Exit code 1, nothing was downloaded. Tell the user the command the CLI printed (`pipx inject yt-tools-cli scenedetect opencv-python` or `pip install 'yt-tools-cli[frames]'`). Fallbacks that need no install: Flow D with an explicit `--timestamps` list, or `--mode interval` (which still runs and announces on stderr that near-duplicate dedup was skipped). |
524
534
  | Flow C — `yt-listen requires the [audio] extra` | `librosa` / `matplotlib` not in the active pipx venv | Exit code 1. Same shape: print the command the CLI named. Until it is installed, Flow A (transcript) and Flow F (`yt-ocr`) are the alternatives for this video. |
525
- | Flow F — `yt-ocr requires the [ocr] extra` | `rapidocr` / `onnxruntime` not in the active pipx venv | Run the inject command the CLI printed: `pipx inject yt-tools-cli rapidocr onnxruntime` (or `pip install 'yt-tools-cli[ocr]'` in non-pipx setups). The plugin's SessionStart hook installs `[full]`, not `[ocr]`, so first run typically needs this. |
535
+ | Flow F — `yt-ocr requires the [ocr] extra` | `rapidocr` / `onnxruntime` not in the active pipx venv | Run the inject command the CLI printed: `pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"` (or `pip install 'yt-tools-cli[ocr]'` in non-pipx setups). The bound is load-bearing: an inject resolves from PyPI directly, and 3.9 fails at engine construction. The plugin's SessionStart hook installs `[full]`, not `[ocr]`, so first run typically needs this. |
526
536
  | Flow F — `no cached frames in …/frames` | Batch-on-cache mode but `yt-frames` wasn't run yet (or wrote elsewhere) | Either run `yt-frames URL --mode scene --scene-threshold 12` (or `--mode interval --interval 15s`) first, or re-invoke `yt-ocr URL --timestamps T1,T2,…` to let it do extraction inline. |
527
- | Flow F — RapidOCR model download fails (offline / firewall) | First-run lazy-download of PP-OCRv5 ONNX weights to `~/.cache/rapidocr/` blocked | Print the captured error; let the user re-run with network access. Don't retry. |
537
+ | Flow F — RapidOCR model download fails (offline / firewall) | First-run lazy-download of PP-OCRv5 ONNX weights blocked | Print the captured error; let the user re-run with network access. Don't retry. |
528
538
  | User passed a non-YouTube URL (Vimeo / Twitch / local mp4) | Out of scope | Stop; say the skill is YouTube-only. |
529
539
 
530
540
  ## Side effects
@@ -538,9 +548,11 @@ All failures abort cleanly; never leave a half-finished state.
538
548
  - `<video-id>/source.mp4` (≤ 720p; produced by Flows A/B/C unless `--no-cache-source`; **not** by 0/D/E; produced by Flow F **only** when `--timestamps` triggers extraction)
539
549
  - `<video-id>/frames/frame_<mmss>.jpg` per extracted frame (also re-read by Flow F); a sub-second moment carries its milliseconds (`frame_0130_250.jpg` = 90.25 s), so two frames in one second never overwrite each other and stdout's `Wrote:` count equals the files on disk
540
550
  - `<video-id>/audio/{clip,spectrum,features}_<mmss>.{wav,png,md}` (Flow C)
541
- - Flow F also lazy-downloads RapidOCR PP-OCRv5 ONNX weights to
542
- `~/.cache/rapidocr/` on first invocation with a given `--language`
543
- (~10 MB per language model).
551
+ - Flow F also lazy-downloads RapidOCR PP-OCRv5 ONNX weights on first
552
+ invocation with a given `--language` (~8 MB for the english recognizer plus the
553
+ shared detector). In RapidOCR 3.8.x they land in the installed package's own
554
+ `rapidocr/models/` directory — not in `~/.cache/`, which is where this line
555
+ claimed they went until a clean-venv check (2026-09-13).
544
556
  - Network: `yt-dlp` pulls metadata + optionally `source.mp4`;
545
557
  `youtube-transcript-api` pulls subs; Flow E additionally paginates the
546
558
  comment feed (the one heavy network path — minutes on viral videos).
@@ -85,11 +85,17 @@ def test_extras_that_are_installed_are_ok(monkeypatch, tmp_path):
85
85
  assert report.can_proceed is True
86
86
 
87
87
 
88
+ # [test-modify: test_extra_check_reuses_the_shared_install_command: was
89
+ # `assert fix == "pipx inject yt-tools-cli scenedetect opencv-python"`; is the
90
+ # same via `extras.inject_command_for`; reason: task:2831 — doctor's one command
91
+ # is now formatted by the shared helper (specs double-quoted, bounded specs like
92
+ # the OCR pin safe to paste), so the literal here must move with it.]
88
93
  def test_extra_check_reuses_the_shared_install_command(monkeypatch, tmp_path):
89
94
  _only_importable(monkeypatch)
90
95
  fix = _by_name(doctor.collect(tmp_path, which=_which(BOTH_BINS)))["extra:frames"].fix
91
96
  assert fix == extras.inject_command("frames")
92
- assert fix == "pipx inject yt-tools-cli scenedetect opencv-python"
97
+ assert fix == extras.inject_command_for(["frames"])
98
+ assert fix == 'pipx inject yt-tools-cli "scenedetect" "opencv-python"'
93
99
 
94
100
 
95
101
  # ---- AC4: a missing binary is the blocker -----------------------------------
@@ -49,10 +49,16 @@ def _explode(*args, **kwargs):
49
49
  # ---- one wording, one source of truth ---------------------------------------
50
50
 
51
51
 
52
+ # [test-modify: test_frames_refusal_wording_is_exact: was
53
+ # ' pipx inject yt-tools-cli scenedetect opencv-python'; is the same line with
54
+ # each spec double-quoted; reason: task:2831 — the hint now formats specs
55
+ # through one helper (extras.inject_command_for) so a bounded spec like the OCR
56
+ # pin cannot be re-joined unquoted by a caller; `"` is required for those
57
+ # bounds, and mixed quoting per extra would be worse than quoting all.]
52
58
  def test_frames_refusal_wording_is_exact():
53
59
  assert extras.format_missing_extra("yt-frames --mode scene", "frames") == (
54
60
  "yt-frames --mode scene requires the [frames] extra:\n"
55
- " pipx inject yt-tools-cli scenedetect opencv-python\n"
61
+ ' pipx inject yt-tools-cli "scenedetect" "opencv-python"\n'
56
62
  " # or\n"
57
63
  " pip install 'yt-tools-cli[frames]'"
58
64
  )
@@ -61,16 +67,22 @@ def test_frames_refusal_wording_is_exact():
61
67
  def test_audio_refusal_wording_is_exact():
62
68
  assert extras.format_missing_extra("yt-listen", "audio") == (
63
69
  "yt-listen requires the [audio] extra:\n"
64
- " pipx inject yt-tools-cli librosa matplotlib\n"
70
+ ' pipx inject yt-tools-cli "librosa" "matplotlib"\n'
65
71
  " # or\n"
66
72
  " pip install 'yt-tools-cli[audio]'"
67
73
  )
68
74
 
69
75
 
76
+ # [test-modify: test_ocr_refusal_wording_is_exact: was
77
+ # ' pipx inject yt-tools-cli rapidocr onnxruntime'; is the same line with the
78
+ # bound specs, each double-quoted; reason: task:2831 — the injected spec now
79
+ # carries the `>=3.8,<3.9` pin (an inject bypasses the extra's metadata, so an
80
+ # unbounded `rapidocr` there installed the broken 3.9.x), and quotes are
81
+ # required because `>`/`<` are redirections in bash, cmd.exe and PowerShell.]
70
82
  def test_ocr_refusal_wording_is_exact():
71
83
  assert extras.format_missing_extra("yt-ocr", "ocr") == (
72
84
  "yt-ocr requires the [ocr] extra:\n"
73
- " pipx inject yt-tools-cli rapidocr onnxruntime\n"
85
+ ' pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"\n'
74
86
  " # or\n"
75
87
  " pip install 'yt-tools-cli[ocr]'"
76
88
  )
@@ -91,9 +103,19 @@ def test_ocr_error_message_comes_from_the_shared_formatter(monkeypatch):
91
103
  assert str(exc_info.value) == extras.format_missing_extra("yt-ocr", "ocr")
92
104
 
93
105
 
106
+ # [test-modify: test_ocr_refuses_when_only_onnxruntime_is_missing: was
107
+ # `_only_importable(monkeypatch, "rapidocr")`; is `_poison(monkeypatch,
108
+ # "onnxruntime")`; reason: task:2831 — `_only_importable` patches
109
+ # `extras.import_module`, which `_load_engine` never calls, so the old form only
110
+ # passed because rapidocr itself was absent from the venv and the *first*
111
+ # import raised. With `[ocr]` installed (now the case in CI, and the only way
112
+ # tests/test_ocr_engine.py can run) the real engine got built here — a model
113
+ # download inside a unit test, and on rapidocr 3.9 a ValueError instead of the
114
+ # named refusal. Poisoning the second half of the pair tests what the test
115
+ # says it tests.]
94
116
  def test_ocr_refuses_when_only_onnxruntime_is_missing(monkeypatch):
95
117
  """The [ocr] extra is a pair: a half-installed one must not fail later, unnamed."""
96
- _only_importable(monkeypatch, "rapidocr")
118
+ _poison(monkeypatch, "onnxruntime")
97
119
  with pytest.raises(ocr_mod.OcrError) as exc_info:
98
120
  ocr_mod._load_engine("en")
99
121
  assert str(exc_info.value) == extras.format_missing_extra("yt-ocr", "ocr")
@@ -159,7 +181,7 @@ def test_degradation_note_for_audio_keeps_the_shared_wording(monkeypatch, capsys
159
181
  err = capsys.readouterr().err
160
182
  assert err.startswith("note: harmonic analysis skipped - the [audio] extra is not installed")
161
183
  assert "(continuing without it)." in err
162
- assert "pipx inject yt-tools-cli librosa matplotlib" in err
184
+ assert 'pipx inject yt-tools-cli "librosa" "matplotlib"' in err
163
185
  assert "pip install 'yt-tools-cli[audio]'" in err
164
186
  assert "requires the [audio] extra" not in err, "a degradation must not read as a blocker"
165
187
 
@@ -187,7 +209,7 @@ def test_yt_frames_scene_refuses_without_frames_extra(tmp_path, monkeypatch, cap
187
209
  err = capsys.readouterr().err
188
210
  assert rc != 0, "a missing extra must be a non-zero exit"
189
211
  assert "requires the [frames] extra" in err
190
- assert "pipx inject yt-tools-cli scenedetect opencv-python" in err
212
+ assert 'pipx inject yt-tools-cli "scenedetect" "opencv-python"' in err
191
213
  assert "pip install 'yt-tools-cli[frames]'" in err
192
214
  assert "Traceback" not in err
193
215
 
@@ -229,7 +251,7 @@ def test_yt_listen_refuses_without_audio_extra(tmp_path, monkeypatch, capsys):
229
251
  err = capsys.readouterr().err
230
252
  assert rc != 0
231
253
  assert "requires the [audio] extra" in err
232
- assert "pipx inject yt-tools-cli librosa matplotlib" in err
254
+ assert 'pipx inject yt-tools-cli "librosa" "matplotlib"' in err
233
255
  assert "pip install 'yt-tools-cli[audio]'" in err
234
256
  assert "Traceback" not in err
235
257
 
@@ -183,6 +183,11 @@ def test_render_zero_frames_still_emits_header():
183
183
  # --- _load_engine: friendly missing-extra hint -------------------------------
184
184
 
185
185
 
186
+ # [test-modify: test_load_engine_friendly_hint_when_rapidocr_missing: was
187
+ # `assert "pipx inject yt-tools-cli rapidocr onnxruntime" in msg`; is the same
188
+ # with the quoted, bounded specs; reason: task:2831 — the hint carries the
189
+ # rapidocr pin (an inject bypasses the extra metadata) and quotes every spec so
190
+ # the pasted command is safe in bash/cmd/PowerShell.]
186
191
  def test_load_engine_friendly_hint_when_rapidocr_missing(monkeypatch):
187
192
  """Stick None into sys.modules['rapidocr'] so `from rapidocr import ...`
188
193
  raises ImportError; verify the friendly hint is built into the error."""
@@ -190,7 +195,7 @@ def test_load_engine_friendly_hint_when_rapidocr_missing(monkeypatch):
190
195
  with pytest.raises(OcrError) as exc_info:
191
196
  ocr_mod._load_engine("en")
192
197
  msg = str(exc_info.value)
193
- assert "pipx inject yt-tools-cli rapidocr onnxruntime" in msg
198
+ assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"' in msg
194
199
  assert "pip install 'yt-tools-cli[ocr]'" in msg
195
200
 
196
201
 
@@ -289,7 +294,7 @@ def test_cli_missing_extra_prints_friendly_hint(tmp_path, monkeypatch, capsys):
289
294
  def _raise(_lang):
290
295
  raise OcrError(
291
296
  "yt-ocr requires the [ocr] extra:\n"
292
- " pipx inject yt-tools-cli rapidocr onnxruntime\n"
297
+ ' pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"\n'
293
298
  " # or\n"
294
299
  " pip install 'yt-tools-cli[ocr]'"
295
300
  )
@@ -299,7 +304,7 @@ def test_cli_missing_extra_prints_friendly_hint(tmp_path, monkeypatch, capsys):
299
304
 
300
305
  assert rc == 1
301
306
  err = capsys.readouterr().err
302
- assert "pipx inject yt-tools-cli rapidocr onnxruntime" in err
307
+ assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"' in err
303
308
  assert "pip install 'yt-tools-cli[ocr]'" in err
304
309
 
305
310
 
@@ -0,0 +1,121 @@
1
+ """Real RapidOCR engine guard — the seam every other yt-ocr test mocks (task:2831).
2
+
3
+ ``tests/test_ocr.py`` patches ``_load_engine`` in every integration case, so the
4
+ whole suite stayed green while the *published* ``[ocr]`` extra resolved to
5
+ rapidocr 3.9.x, whose ``params`` schema ``yt_tools/ocr.py`` does not speak. On a
6
+ clean ``pipx install "yt-tools-cli[ocr]"`` (0.24.0/0.24.1) the first
7
+ ``yt-ocr`` run died with::
8
+
9
+ error: Invalid OCR configuration.
10
+ Example valid usage:
11
+ from rapidocr import LangRec, OCRVersion, RapidOCR
12
+ engine = RapidOCR(params={'Rec.ocr_version': OCRVersion.PPOCRV5, ...})
13
+
14
+ The mocked seam is exactly the seam that broke, so this module drives it for
15
+ real: build the engine through ``_load_engine``, OCR a frame the test renders
16
+ itself, and require text back. Detection is what makes it the guard it is —
17
+ ``_load_engine`` is where rapidocr 3.9 rejects the config, so the failure lands
18
+ here as an ``OcrError``/``rapidocr`` error rather than in a consumer session.
19
+
20
+ Skipped — never failed — when rapidocr is absent: the light-core jobs install no
21
+ extras, and the ``pytest (full extras)`` CI job installs ``[ocr]`` (see
22
+ ``.github/workflows/ci.yml``), which is where this contract is enforced. The
23
+ first construction downloads the ~10 MB PP-OCRv5 ONNX model, so the network is a
24
+ prerequisite of that job, not of a plain ``pytest`` run.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+ from yt_tools import ocr as ocr_mod
34
+
35
+ pytest.importorskip(
36
+ "rapidocr",
37
+ reason="[ocr] extra not installed (run: pip install 'yt-tools-cli[ocr]')",
38
+ )
39
+
40
+ _VID = "abcDEF12345"
41
+ _URL = f"https://youtu.be/{_VID}"
42
+
43
+ # Rendered big and blocky on purpose: the guard must fail on a *configuration*
44
+ # mismatch, never because a serif glyph was misread at 12 px.
45
+ _RENDERED_TEXT = "OCR TEST"
46
+ _COLS = 720
47
+ _ROWS = 220
48
+
49
+
50
+ def _render_text_frame(path: Path, text: str = _RENDERED_TEXT) -> Path:
51
+ """Draw ``text`` on a white canvas — a frame real OCR can read.
52
+
53
+ ``cv2`` is not an optional nicety here: rapidocr depends on it (PP-OCRv5
54
+ preprocessing), so using it adds no dependency the extra does not already
55
+ carry. The Hershey font is built into OpenCV, so no font file is needed and
56
+ the fixture stays a file the test writes, not a binary in git.
57
+ """
58
+ import cv2 # noqa: PLC0415 — heavy import, only reachable behind the [ocr] skip
59
+ import numpy as np # noqa: PLC0415
60
+
61
+ canvas = np.full((_ROWS, _COLS, 3), 255, dtype=np.uint8)
62
+ cv2.putText(
63
+ canvas,
64
+ text,
65
+ (40, 150),
66
+ cv2.FONT_HERSHEY_SIMPLEX,
67
+ 3.0,
68
+ (0, 0, 0),
69
+ 8,
70
+ cv2.LINE_AA,
71
+ )
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ assert cv2.imwrite(str(path), canvas), f"could not write the rendered frame to {path}"
74
+ return path
75
+
76
+
77
+ @pytest.fixture(scope="module")
78
+ def text_frame(tmp_path_factory: pytest.TempPathFactory) -> Path:
79
+ return _render_text_frame(tmp_path_factory.mktemp("frames") / "frame_0023.jpg")
80
+
81
+
82
+ @pytest.fixture(scope="module")
83
+ def engine():
84
+ """One engine per module: construction downloads the model, so it is not free."""
85
+ return ocr_mod._load_engine("en")
86
+
87
+
88
+ def test_load_engine_constructs_the_declared_params(engine):
89
+ """The 3.9.x failure mode: RapidOCR refuses the 3.8.x-shaped ``params`` dict."""
90
+ assert engine is not None
91
+ # Not a mock assertion — `_load_engine` returns whatever RapidOCR built. If
92
+ # the params schema drifts again, construction raises before we get here.
93
+ assert hasattr(engine, "__call__"), "the engine handle must stay callable (engine(path))"
94
+
95
+
96
+ def test_engine_reads_text_from_a_rendered_frame(engine, text_frame):
97
+ texts = ocr_mod._ocr_one(engine, text_frame)
98
+ assert texts, (
99
+ "a frame with rendered text produced no detections — the engine is not "
100
+ "actually OCR-ing (model missing, or params silently ignored)"
101
+ )
102
+ joined = " ".join(texts).upper()
103
+ assert "OCR" in joined, f"expected {_RENDERED_TEXT!r} to be read back, got {texts!r}"
104
+
105
+
106
+ def test_cli_real_engine_writes_nonempty_markdown(tmp_path, monkeypatch, text_frame):
107
+ """The acceptance path end to end, minus YouTube: cached frame → ocr.md with text."""
108
+ monkeypatch.chdir(tmp_path)
109
+ cache_root = tmp_path / "yt-cache" / _VID
110
+ frames_dir = cache_root / "frames"
111
+ frames_dir.mkdir(parents=True)
112
+ (frames_dir / text_frame.name).write_bytes(text_frame.read_bytes())
113
+
114
+ out = tmp_path / "ocr.md"
115
+ rc = ocr_mod.main([_URL, "--out", str(out)])
116
+
117
+ assert rc == 0
118
+ body = out.read_text(encoding="utf-8")
119
+ assert body.strip(), "ocr.md must not be empty"
120
+ assert "## [0:23]" in body
121
+ assert "OCR" in body.upper()
@@ -214,6 +214,80 @@ def test_ocr_extra_is_unchanged():
214
214
  assert ocr == {"rapidocr", "onnxruntime"}
215
215
 
216
216
 
217
+ def test_ocr_extra_pins_rapidocr_below_3_9():
218
+ """`yt_tools/ocr.py` builds the RapidOCR **3.8.x** params schema (task:2831).
219
+
220
+ ``params["Det.ocr_version"] = OCRVersion.PPOCRV5`` (+ Cls/Rec) and the
221
+ ``LangRec`` overrides are 3.8.x-shaped. 3.9.x changed the schema and rejects
222
+ the dict at construction — ``error: Invalid OCR configuration`` — which is
223
+ what a clean ``pipx install "yt-tools-cli[ocr]"`` hit: 0.24.0/0.24.1 declared
224
+ ``rapidocr>=3.8``, the resolver picked 3.9.2, and every ``yt-ocr`` run died
225
+ on a machine that had installed exactly what the metadata asked for.
226
+
227
+ Same shape as the librosa cap above: an upper bound that encodes a
228
+ compatibility fact, removed only together with the code that depends on it.
229
+ The behavioural half is ``tests/test_ocr_engine.py``, which drives the real
230
+ engine — this one keeps the boundary from drifting back out of the metadata
231
+ where nothing local would notice.
232
+ """
233
+ specs = _pyproject()["project"]["optional-dependencies"]["ocr"]
234
+ req = Requirement(next(spec for spec in specs if _dist_name(spec) == "rapidocr"))
235
+
236
+ assert req.specifier.contains("3.8.4"), (
237
+ "the RapidOCR release yt-ocr is written against must stay installable"
238
+ )
239
+ assert not req.specifier.contains("3.9.0"), (
240
+ "rapidocr 3.9 changed the params schema yt_tools/ocr.py builds — lift this "
241
+ "cap only in the same change that adapts the code"
242
+ )
243
+
244
+
245
+ def test_ocr_inject_hint_carries_the_same_bound_as_the_extra():
246
+ """Two install routes, one bound — the `pipx inject` hint has no metadata to inherit.
247
+
248
+ ``yt-ocr`` refusing with `pipx inject yt-tools-cli rapidocr onnxruntime` told
249
+ the user to install exactly the pair the extra caps below 3.9 — an inject
250
+ resolves from PyPI directly, so the refusal message would have reproduced
251
+ [[issue:77]] on a machine that followed it to the letter. ``doctor``'s
252
+ ``next_step`` and the README print the same line, so the assertion is on the
253
+ constant all three read from, not on one caller's wording.
254
+ """
255
+ from yt_tools import extras # noqa: PLC0415 — local import keeps the packaging tests import-light
256
+
257
+ specs = dict(
258
+ zip(
259
+ (Requirement(spec).name for spec in extras.EXTRA_PACKAGES["ocr"]),
260
+ extras.EXTRA_PACKAGES["ocr"],
261
+ )
262
+ )
263
+ declared = {
264
+ Requirement(spec).name: spec
265
+ for spec in _pyproject()["project"]["optional-dependencies"]["ocr"]
266
+ }
267
+ assert specs == declared, (
268
+ "the [ocr] extra and the inject hint must name the same requirements — "
269
+ "the hint is what a user pastes when the extra is missing"
270
+ )
271
+ assert '"rapidocr>=3.8,<3.9"' in extras.inject_command("ocr"), (
272
+ "a bounded spec must be quoted: `>` and `<` are redirections in bash, cmd.exe and PowerShell"
273
+ )
274
+
275
+
276
+ def test_ci_installs_the_ocr_extra_so_the_engine_test_actually_runs():
277
+ """A guard test that is skipped in CI is worse than no guard.
278
+
279
+ ``tests/test_ocr_engine.py`` skips when rapidocr is absent (the light-core
280
+ jobs install nothing), which is the right shape for a local `pytest` run and
281
+ a silent hole in the pipeline: the suite would stay green while nothing
282
+ exercised the engine — the exact state that let [[issue:77]] ship. So the CI
283
+ install line is part of the contract.
284
+ """
285
+ workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
286
+ assert 'pip install -e ".[full,ocr,test]"' in workflow, (
287
+ "the pytest job must install [ocr], or tests/test_ocr_engine.py skips there too"
288
+ )
289
+
290
+
217
291
  @pytest.mark.parametrize("extra", ["audio", "full"])
218
292
  def test_librosa_1x_is_excluded(extra):
219
293
  """`bpm-detector` 1.1.0 calls ``librosa.beat.tempo``, removed in librosa 1.0.
@@ -73,6 +73,37 @@ def test_frontmatter_description_is_install_neutral():
73
73
  assert "pipx install" in description, "the trigger text should name the neutral install path"
74
74
 
75
75
 
76
+ #: The host's cap on a skill description. Measured, not chosen: a harness that
77
+ #: loads this skill reported `description exceeds 1024 characters (2261)` — and
78
+ #: nothing in this repo objected, because every other test here reads the
79
+ #: frontmatter as free-form text. This test measures the raw YAML scalar, which
80
+ #: reads a little longer (2511 before the trim); every measure of the old text
81
+ #: was over the cap, which is the only part that matters.
82
+ DESCRIPTION_MAX_CHARS = 1024
83
+
84
+
85
+ def test_frontmatter_description_fits_the_host_limit():
86
+ """A description that does not fit is not installed — it is reported.
87
+
88
+ The description is the *only* part of the skill the host matches triggers
89
+ against, so overflowing it does not degrade the skill gracefully: the host
90
+ either refuses it or truncates the trigger list, and `yt-ocr` / `yt-listen`
91
+ become unreachable flows with a green suite ([[task:2831]] run, 2026-09-13).
92
+ Keep the trim honest by cutting prose, never trigger phrases — a shorter
93
+ description that no longer names a flow is a different bug.
94
+ """
95
+ description = re.search(r"^description:\s*(.+)$", _frontmatter(_text()), re.MULTILINE)
96
+ assert description, "the frontmatter has no description: line"
97
+ description = description.group(1).strip()
98
+ assert len(description) <= DESCRIPTION_MAX_CHARS, (
99
+ f"the description is {len(description)} chars, over the {DESCRIPTION_MAX_CHARS} "
100
+ "the host allows — trim prose, keep the trigger phrases"
101
+ )
102
+ # ...and the trim must not have traded triggers away for room.
103
+ for command in ("yt-search", "yt-transcript", "yt-frames", "yt-listen", "yt-meta", "yt-comments", "yt-ocr"):
104
+ assert command in description, f"the description stopped naming {command}"
105
+
106
+
76
107
  # ---- a named harness is always an option, never the flow --------------------
77
108
 
78
109
 
@@ -127,11 +127,12 @@ class Report:
127
127
  if self.blockers:
128
128
  return None
129
129
  if self.missing_extras:
130
- packages = " ".join(p for e in self.missing_extras for p in extras.EXTRA_PACKAGES[e])
131
130
  # Built from the same constant the refusals use: a rename that missed
132
131
  # this copy would make the report recommend a package that does not
133
- # exist ([[wiki:3589]]).
134
- return f"pipx inject {extras.DISTRIBUTION} {packages}"
132
+ # exist ([[wiki:3589]]). Bound specs (the OCR pin) mean the one
133
+ # command must be formatted by the same helper too, not re-joined
134
+ # here — an unquoted ``>=`` is a redirect ([[task:2831]]).
135
+ return extras.inject_command_for(self.missing_extras)
135
136
  for check in self.checks:
136
137
  if check.status == WARN and check.fix:
137
138
  return check.fix
@@ -34,11 +34,17 @@ EXTRA_MODULES: dict[str, tuple[str, ...]] = {
34
34
  "ocr": ("rapidocr", "onnxruntime"),
35
35
  }
36
36
 
37
- # extra → distribution names for the ``pipx inject`` hint.
37
+ # extra → install specs for the ``pipx inject`` hint. These are what the user
38
+ # actually pastes, and an inject resolves them *outside* the extra's metadata —
39
+ # so a bound that matters has to be repeated here or the hint contradicts the
40
+ # extra it is advertising: `pipx inject yt-tools-cli rapidocr` used to resolve
41
+ # 3.9.x while `[ocr]` said <3.9, i.e. the refusal message itself installed the
42
+ # broken pair ([[task:2831]]). tests/test_packaging.py pins the OCR entries to
43
+ # the requirement strings in pyproject.toml so the two routes cannot drift.
38
44
  EXTRA_PACKAGES: dict[str, tuple[str, ...]] = {
39
45
  "frames": ("scenedetect", "opencv-python"),
40
46
  "audio": ("librosa", "matplotlib"),
41
- "ocr": ("rapidocr", "onnxruntime"),
47
+ "ocr": ("rapidocr>=3.8,<3.9", "onnxruntime>=1.18"),
42
48
  }
43
49
 
44
50
  #: The enrichment that cannot be a package extra. ``bpm-detector`` gives
@@ -92,8 +98,24 @@ def inject_command(extra: str) -> str:
92
98
  refusal and the recommendation must not drift apart.
93
99
  """
94
100
  _check_extra(extra)
95
- packages = " ".join(EXTRA_PACKAGES[extra])
96
- return f"pipx inject {DISTRIBUTION} {packages}"
101
+ return inject_command_for([extra])
102
+
103
+
104
+ def inject_command_for(extra_names: list[str] | tuple[str, ...]) -> str:
105
+ """One command adding several extras — what ``doctor``'s ``next_step`` prints.
106
+
107
+ Every spec is double-quoted: an injected spec can carry version bounds
108
+ (``rapidocr>=3.8,<3.9``), and an unquoted ``>``/``<`` is a redirection in
109
+ bash, cmd.exe and PowerShell alike — the printed command has to be
110
+ pasteable, since being pasteable is the whole point of it.
111
+ """
112
+ specs = [spec for name in extra_names for spec in _specs(name)]
113
+ return f"pipx inject {DISTRIBUTION} " + " ".join(f'"{spec}"' for spec in specs)
114
+
115
+
116
+ def _specs(extra: str) -> tuple[str, ...]:
117
+ _check_extra(extra)
118
+ return EXTRA_PACKAGES[extra]
97
119
 
98
120
 
99
121
  def module_missing(name: str) -> bool:
File without changes
File without changes