yt-tools-cli 0.23.2__tar.gz → 0.24.0__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 (60) hide show
  1. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/.claude-plugin/plugin.json +1 -1
  2. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/CHANGELOG.md +25 -0
  3. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/PKG-INFO +1 -1
  4. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/pyproject.toml +1 -1
  5. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/ensure-install.ps1 +33 -4
  6. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/ensure-install.sh +30 -3
  7. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/skills/using-yt-tools/SKILL.md +1 -1
  8. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_doctor.py +52 -2
  9. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_packaging.py +72 -0
  10. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/doctor.py +25 -0
  11. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/extras.py +11 -0
  12. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/.github/workflows/ci.yml +0 -0
  13. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/.github/workflows/release.yml +0 -0
  14. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/.gitignore +0 -0
  15. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/CONTRIBUTING.md +0 -0
  16. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/LICENSE +0 -0
  17. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/README.md +0 -0
  18. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/hooks/hooks.json +0 -0
  19. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/check-core-install.sh +0 -0
  20. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/check-metadata.py +0 -0
  21. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/check-pypi-name.py +0 -0
  22. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/ensure-install.py +0 -0
  23. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/publish-check.py +0 -0
  24. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/scripts/sync-version.py +0 -0
  25. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/__init__.py +0 -0
  26. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_cache.py +0 -0
  27. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_check_metadata.py +0 -0
  28. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_cli_smoke.py +0 -0
  29. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_comments.py +0 -0
  30. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_core.py +0 -0
  31. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_distribution_name.py +0 -0
  32. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_failure_modes.py +0 -0
  33. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_frames.py +0 -0
  34. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_listen.py +0 -0
  35. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_markdown.py +0 -0
  36. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_meta.py +0 -0
  37. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_missing_extra.py +0 -0
  38. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_ocr.py +0 -0
  39. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_parse_interval.py +0 -0
  40. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_publish_check.py +0 -0
  41. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_pypi_name.py +0 -0
  42. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_search.py +0 -0
  43. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_skill_portable.py +0 -0
  44. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_transcript.py +0 -0
  45. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_version.py +0 -0
  46. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/tests/test_watch.py +0 -0
  47. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/__init__.py +0 -0
  48. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/_metadata.py +0 -0
  49. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/cache.py +0 -0
  50. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/cli.py +0 -0
  51. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/comments.py +0 -0
  52. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/core.py +0 -0
  53. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/frames.py +0 -0
  54. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/listen.py +0 -0
  55. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/markdown.py +0 -0
  56. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/meta.py +0 -0
  57. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/ocr.py +0 -0
  58. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/search.py +0 -0
  59. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/yt_tools/transcript.py +0 -0
  60. {yt_tools_cli-0.23.2 → yt_tools_cli-0.24.0}/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.23.2",
4
+ "version": "0.24.0",
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",
@@ -11,6 +11,31 @@ Releases before 0.8.0 are summarized only in the git log.
11
11
 
12
12
  _(nothing yet)_
13
13
 
14
+ ## [0.24.0] — 2026-09-13
15
+
16
+ ### Added
17
+
18
+ - `yt-tools doctor` now names the `bpm-detector` enrichment: a `warn` (never a
19
+ blocker) with the exact `pipx inject` command. It is not an extra and cannot be
20
+ one, so nothing else in the report was ever going to mention it — a PyPI
21
+ install had no way to learn the enrichment existed at all.
22
+ - The plugin hook tops the enrichment up on the "version already matches" path.
23
+ Both hooks ask the CLI (`doctor`) and inject only what it reports missing, so
24
+ the hook and the report cannot drift into disagreeing about the environment.
25
+ - A syntax guard for the hook scripts: `bash -n` for the POSIX hook, the
26
+ PowerShell parser for the Windows one. The content guards assert strings, not
27
+ grammar — a stray `fi` left behind by a block restructure passed all of them
28
+ and was caught only by running the hook by hand.
29
+
30
+ ### Fixed
31
+
32
+ - Moving `bpm-detector` out of `[full]` (0.23.1) left it guaranteed by nothing:
33
+ the hook injected it only right after a reinstall, so a machine whose VCS fetch
34
+ had once been blocked by a proxy — or one installed before 0.23.1 — stayed on
35
+ the librosa-only path for good, and silently. Now every session start checks
36
+ and repairs it, best-effort: a blocked fetch warns and leaves the install
37
+ working.
38
+
14
39
  ## [0.23.2] — 2026-09-13
15
40
 
16
41
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: yt-tools-cli
3
- Version: 0.23.2
3
+ Version: 0.24.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "yt-tools-cli"
7
- version = "0.23.2"
7
+ version = "0.24.0"
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"
@@ -24,6 +24,28 @@ function Write-PluginLog {
24
24
  # exactly that). It is injected on top of the install instead, best-effort.
25
25
  $BpmDetectorSpec = 'bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0'
26
26
 
27
+ function Inject-BpmDetector {
28
+ Invoke-Pipx inject yt-tools-cli $BpmDetectorSpec 2>&1 | ForEach-Object { Write-PluginLog $_ }
29
+ if ($LASTEXITCODE -ne 0) {
30
+ Write-PluginLog 'WARN: bpm-detector inject failed (VCS fetch blocked?); yt-listen runs the librosa-only path.'
31
+ }
32
+ }
33
+
34
+ # Is the enrichment missing from an otherwise healthy install? Ask the CLI -
35
+ # `doctor` owns the check name and the fix command, so the hook cannot drift
36
+ # into disagreeing with what the agent is told. No CLI found (or an older
37
+ # install): stay quiet and do nothing.
38
+ function Test-BpmDetectorMissing {
39
+ $ytBin = (Get-Command yt-tools -ErrorAction SilentlyContinue).Source
40
+ if (-not $ytBin) {
41
+ $candidate = Join-Path $HOME '.local/bin/yt-tools'
42
+ if (Test-Path $candidate) { $ytBin = $candidate }
43
+ }
44
+ if (-not $ytBin) { return $false }
45
+ $report = & $ytBin doctor 2>$null
46
+ return [bool]($report | Select-String -Pattern '^\s*warn\s+enrichment:bpm-detector')
47
+ }
48
+
27
49
  # 0. Resolve plugin root ───────────────────────────────────────────────────
28
50
  $PluginRoot = $env:CLAUDE_PLUGIN_ROOT
29
51
  if (-not $PluginRoot) {
@@ -171,13 +193,20 @@ if ($needsInstall) {
171
193
  # enrichment is chord/structure/BPM refinement, not a flow of its own, and
172
194
  # yt-listen already renders the librosa-only path with explicit n/a markers.
173
195
  if ($installOk) {
174
- Invoke-Pipx inject yt-tools-cli $BpmDetectorSpec 2>&1 | ForEach-Object { Write-PluginLog $_ }
175
- if ($LASTEXITCODE -ne 0) {
176
- Write-PluginLog 'WARN: bpm-detector inject failed (VCS fetch blocked?); yt-listen runs the librosa-only path.'
177
- }
196
+ Inject-BpmDetector
178
197
  }
179
198
  }
180
199
 
200
+ # 2b. bpm-detector on an already-correct install -----------------------------
201
+ # The enrichment lives outside the install spec, so a version match - the common
202
+ # case on every later session start - never enters the block above, and a machine
203
+ # that once lost the VCS fetch would stay on the librosa-only path forever,
204
+ # silently. Probe and top up instead.
205
+ if (-not $needsInstall -and (Test-BpmDetectorMissing)) {
206
+ Write-PluginLog 'bpm-detector is missing from an up-to-date install; injecting it.'
207
+ Inject-BpmDetector
208
+ }
209
+
181
210
  # 3. Probe ffmpeg ──────────────────────────────────────────────────────────
182
211
  if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
183
212
  Write-PluginLog 'ffmpeg not on PATH. Install via winget:'
@@ -23,6 +23,25 @@ log() { printf '[yt-tools] %s\n' "$*" >&2; }
23
23
  # exactly that). It is injected on top of the install instead, best-effort.
24
24
  BPM_DETECTOR_SPEC="bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0"
25
25
 
26
+ inject_bpm_detector() {
27
+ if ! pipx_run inject yt-tools-cli "${BPM_DETECTOR_SPEC}" >&2; then
28
+ log "WARN: bpm-detector inject failed (VCS fetch blocked?); yt-listen runs the librosa-only path."
29
+ fi
30
+ }
31
+
32
+ # Is the enrichment missing from an otherwise healthy install? Ask the CLI —
33
+ # `doctor` owns the check name and the fix command, so the hook cannot drift
34
+ # into disagreeing with what the agent is told. No CLI on PATH (or an older
35
+ # install): stay quiet and do nothing.
36
+ needs_bpm_detector() {
37
+ yt_bin="$(command -v yt-tools 2>/dev/null || true)"
38
+ if [ -z "$yt_bin" ] && [ -x "$HOME/.local/bin/yt-tools" ]; then
39
+ yt_bin="$HOME/.local/bin/yt-tools"
40
+ fi
41
+ [ -n "$yt_bin" ] || return 1
42
+ "$yt_bin" doctor 2>/dev/null | grep -Eq '^[[:space:]]*warn[[:space:]]+enrichment:bpm-detector'
43
+ }
44
+
26
45
  # 0. Resolve plugin root ───────────────────────────────────────────────────
27
46
  PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
28
47
  if [ -z "$PLUGIN_ROOT" ]; then
@@ -135,12 +154,20 @@ if [ "$needs_install" = "true" ]; then
135
154
  # enrichment is chord/structure/BPM refinement, not a flow of its own, and
136
155
  # yt-listen already renders the librosa-only path with explicit n/a markers.
137
156
  if [ "$install_ok" = "true" ]; then
138
- if ! pipx_run inject yt-tools-cli "${BPM_DETECTOR_SPEC}" >&2; then
139
- log "WARN: bpm-detector inject failed (VCS fetch blocked?); yt-listen runs the librosa-only path."
140
- fi
157
+ inject_bpm_detector
141
158
  fi
142
159
  fi
143
160
 
161
+ # 2b. bpm-detector on an already-correct install ──────────────────────────
162
+ # The enrichment lives outside the install spec, so a version match — the common
163
+ # case on every later session start — never enters the block above, and a machine
164
+ # that once lost the VCS fetch would stay on the librosa-only path forever,
165
+ # silently. Probe and top up instead.
166
+ if [ "$needs_install" = "false" ] && needs_bpm_detector; then
167
+ log "bpm-detector is missing from an up-to-date install; injecting it."
168
+ inject_bpm_detector
169
+ fi
170
+
144
171
  # 3. Probe ffmpeg ──────────────────────────────────────────────────────────
145
172
  if ! command -v ffmpeg >/dev/null 2>&1; then
146
173
  case "$(uname -s 2>/dev/null || echo unknown)" in
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: using-yt-tools
3
- version: 0.23.2
3
+ version: 0.24.0
4
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.
5
5
  ---
6
6
 
@@ -24,7 +24,13 @@ from yt_tools import cache as cache_mod
24
24
  from yt_tools import cli as umbrella_cli
25
25
  from yt_tools import doctor, extras
26
26
 
27
- FULL_ENV = ("scenedetect", "cv2", "librosa", "matplotlib", "rapidocr", "onnxruntime")
27
+ FULL_ENV = ("scenedetect", "cv2", "librosa", "matplotlib", "rapidocr", "onnxruntime", "bpm_detector")
28
+
29
+ #: Everything a bare core install has *not* got — the extras plus the one
30
+ #: enrichment that cannot be an extra (not on PyPI, and PyPI rejects direct
31
+ #: references in metadata: release 0.23.0 died on that).
32
+ ENRICHMENT_MODULE = "bpm_detector"
33
+ NO_ENRICHMENT_ENV = tuple(m for m in FULL_ENV if m != ENRICHMENT_MODULE)
28
34
  BOTH_BINS = ("yt-dlp", "ffmpeg")
29
35
 
30
36
 
@@ -695,6 +701,50 @@ def test_ok_report_has_no_next_step(monkeypatch, tmp_path, capsys):
695
701
  assert rc == 0
696
702
 
697
703
 
704
+ # ---- the enrichment that cannot be a package extra --------------------------
705
+ #
706
+ # `bpm-detector` is the difference between librosa-only BPM/key and full chord
707
+ # progression + structure in `yt-listen`, but it is not on PyPI and PyPI rejects
708
+ # PEP 508 direct references in `Requires-Dist` — so it cannot ride `[full]`
709
+ # (release 0.23.0 died on exactly that, [[task:2824]]) and is installed with
710
+ # `pipx inject` instead. Nothing in the install spec guarantees it, so doctor has
711
+ # to name it: without this line a PyPI install has no way to learn the enrichment
712
+ # exists at all. Absent is a `warn` — a feature is lost, nothing is blocked (D6).
713
+
714
+
715
+ def test_missing_bpm_detector_is_a_warn_with_the_inject_command(monkeypatch, tmp_path):
716
+ _only_importable(monkeypatch, *NO_ENRICHMENT_ENV)
717
+ report = doctor.collect(tmp_path, which=_which(BOTH_BINS), version_info=(3, 12, 1))
718
+
719
+ check = _by_name(report)["enrichment:bpm-detector"]
720
+ assert check.status == "warn"
721
+ assert check.required is False
722
+ assert check.fix == extras.BPM_DETECTOR_FIX
723
+ assert "git+https://github.com/libraz/bpm-detector" in check.fix
724
+ assert report.can_proceed is True
725
+
726
+
727
+ def test_a_present_bpm_detector_is_reported_ok(monkeypatch, tmp_path):
728
+ _only_importable(monkeypatch, *FULL_ENV)
729
+ report = doctor.collect(tmp_path, which=_which(BOTH_BINS), version_info=(3, 12, 1))
730
+ assert _by_name(report)["enrichment:bpm-detector"].status == "ok"
731
+
732
+
733
+ def test_the_enrichment_alone_becomes_the_next_step(monkeypatch, tmp_path):
734
+ """Nothing else wrong: the one command worth running is the enrichment."""
735
+ _only_importable(monkeypatch, *NO_ENRICHMENT_ENV)
736
+ report = doctor.collect(tmp_path, which=_which(BOTH_BINS), version_info=(3, 12, 1))
737
+ assert report.next_step == extras.BPM_DETECTOR_FIX
738
+
739
+
740
+ def test_a_missing_extra_outranks_the_enrichment(monkeypatch, tmp_path):
741
+ """Priority stays: extras first (they unlock whole flows), then the warn."""
742
+ _only_importable(monkeypatch)
743
+ report = doctor.collect(tmp_path, which=_which(BOTH_BINS), version_info=(3, 12, 1))
744
+ assert report.next_step.startswith("pipx inject yt-tools-cli ")
745
+ assert "git+https" not in report.next_step
746
+
747
+
698
748
  # ---- human output is the default --------------------------------------------
699
749
 
700
750
 
@@ -707,7 +757,7 @@ def test_human_output_names_every_check_and_the_next_step(monkeypatch, tmp_path,
707
757
  out = capsys.readouterr().out
708
758
  assert "missing" in out and "can_proceed: no" in out
709
759
  assert "next_step: sudo apt install ffmpeg" in out
710
- for name in ("python", "yt-dlp", "ffmpeg", "extra:frames", "extra:audio", "extra:ocr", "cache", "version"):
760
+ for name in ("python", "yt-dlp", "ffmpeg", "extra:frames", "extra:audio", "extra:ocr", "enrichment:bpm-detector", "cache", "version"):
711
761
  assert name in out
712
762
  assert rc == 1
713
763
 
@@ -17,6 +17,7 @@ import ast
17
17
  import json
18
18
  import os
19
19
  import re
20
+ import shutil
20
21
  import subprocess
21
22
  import sys
22
23
  from pathlib import Path
@@ -95,6 +96,22 @@ HOOK_BPM_INJECT: dict[str, tuple[str, ...]] = {
95
96
  ),
96
97
  }
97
98
 
99
+ # The self-heal half: the enrichment lives *outside* the install spec, so a
100
+ # version match — the common case on every later session start — skips the
101
+ # install branch entirely and nothing would ever put it back. The hook asks the
102
+ # CLI itself (`doctor`), whose report carries the same check name and the same
103
+ # fix command, so the two cannot drift into disagreeing about what is installed.
104
+ HOOK_BPM_SELFHEAL: dict[str, tuple[str, ...]] = {
105
+ "ensure-install.sh": (
106
+ r'command\s+-v\s+yt-tools',
107
+ r'enrichment:bpm-detector',
108
+ ),
109
+ "ensure-install.ps1": (
110
+ r'Get-Command\s+yt-tools',
111
+ r'enrichment:bpm-detector',
112
+ ),
113
+ }
114
+
98
115
  # The extras a hook script must pass to its *actual* pipx call. Patterns, not
99
116
  # substrings: the log lines around these commands also mention `[full]` and
100
117
  # `[frames,audio]`, so a `in text` check stays green with the command deleted
@@ -483,6 +500,25 @@ def test_hook_injects_bpm_detector_best_effort(script_name):
483
500
  )
484
501
 
485
502
 
503
+ @pytest.mark.parametrize("script_name", HOOK_SCRIPTS)
504
+ def test_hook_tops_up_bpm_detector_when_the_version_already_matches(script_name):
505
+ """The gap the inject alone leaves open.
506
+
507
+ `bpm-detector` cannot be part of `[full]` (it is not on PyPI and PyPI rejects
508
+ direct references in metadata), so nothing in the install spec guarantees it.
509
+ The inject above only runs after a reinstall — which happens once, on a
510
+ version change. Every later session start takes the "version matches" path
511
+ and would leave a machine that once lost the VCS fetch (corporate proxy) on
512
+ the librosa-only path forever, quietly. So the hook asks the CLI.
513
+ """
514
+ code = _code_lines((ROOT / "scripts" / script_name).read_text(encoding="utf-8"))
515
+ for pattern in HOOK_BPM_SELFHEAL[script_name]:
516
+ assert re.search(pattern, code), (
517
+ f"{script_name}: no self-heal probe for the enrichment (no match for {pattern!r}); "
518
+ "a version match skips the install branch and the inject with it"
519
+ )
520
+
521
+
486
522
  @pytest.mark.parametrize("script_name", HOOK_SCRIPTS)
487
523
  def test_hook_pins_bpm_detector_to_the_url_requirement_46_records(script_name):
488
524
  """The spec lives in the hook now, so the URL is asserted there — verbatim."""
@@ -492,6 +528,42 @@ def test_hook_pins_bpm_detector_to_the_url_requirement_46_records(script_name):
492
528
  )
493
529
 
494
530
 
531
+ def test_hook_shell_script_parses():
532
+ """Content guards do not see syntax.
533
+
534
+ A stray `fi` (from a restructure that moved a block out of an `if`) passed
535
+ every regex above and every unit test — the packaging suite asserts strings,
536
+ not grammar. It surfaced only when the hook was run by hand. `bash -n` is the
537
+ cheap check that sees it, and it costs milliseconds on every push.
538
+ """
539
+ bash = shutil.which("bash")
540
+ if bash is None: # pragma: no cover - Windows without Git Bash
541
+ pytest.skip("no bash on PATH")
542
+ proc = subprocess.run(
543
+ [bash, "-n", str(ROOT / "scripts" / "ensure-install.sh")],
544
+ capture_output=True,
545
+ text=True,
546
+ )
547
+ assert proc.returncode == 0, f"ensure-install.sh does not parse:\n{proc.stderr}"
548
+
549
+
550
+ def test_hook_powershell_script_parses():
551
+ """Same guard for the Windows hook (skipped where no PowerShell exists)."""
552
+ shell = shutil.which("pwsh") or shutil.which("powershell")
553
+ if shell is None: # pragma: no cover - Linux/macOS CI
554
+ pytest.skip("no PowerShell on PATH")
555
+ script = str(ROOT / "scripts" / "ensure-install.ps1")
556
+ parse = (
557
+ f"$errors = $null; "
558
+ f"[void][System.Management.Automation.Language.Parser]::ParseFile('{script}', [ref]$null, [ref]$errors); "
559
+ f"if ($errors.Count) {{ $errors | ForEach-Object {{ Write-Error $_.Message }}; exit 1 }}"
560
+ )
561
+ proc = subprocess.run(
562
+ [shell, "-NoProfile", "-Command", parse], capture_output=True, text=True
563
+ )
564
+ assert proc.returncode == 0, f"ensure-install.ps1 does not parse:\n{proc.stderr}"
565
+
566
+
495
567
  # ---- documentation ----------------------------------------------------------
496
568
 
497
569
 
@@ -213,6 +213,30 @@ def ffmpeg_fix(platform_name: str) -> str:
213
213
  return FFMPEG_FIX.get(platform_name, FFMPEG_FIX_FALLBACK)
214
214
 
215
215
 
216
+ def check_bpm_detector() -> Check:
217
+ """The one enrichment that cannot be an extra — named anyway.
218
+
219
+ It does not go through ``check_extra`` because there is no ``[extra]`` to
220
+ install: the package is not on PyPI, so its absence is only fixable by
221
+ ``pipx inject``. For the caller it is the same kind of absence as a missing
222
+ extra — a feature is lost, nothing is blocked — so it is reported the same
223
+ way, with the exact command (D6). Without this line a PyPI install has no
224
+ way to learn the enrichment exists at all.
225
+ """
226
+ if extras.module_missing(extras.BPM_DETECTOR_MODULE):
227
+ return Check(
228
+ "enrichment:bpm-detector",
229
+ WARN,
230
+ "not installed - yt-listen falls back to librosa-only BPM/key",
231
+ fix=extras.BPM_DETECTOR_FIX,
232
+ )
233
+ return Check(
234
+ "enrichment:bpm-detector",
235
+ OK,
236
+ "installed - chord progression + structure in yt-listen",
237
+ )
238
+
239
+
216
240
  def check_extra(extra: str) -> Check:
217
241
  """Is the heavy stack for one flow importable here (D4's probe, not a spec)?"""
218
242
  if extras.extra_available(extra):
@@ -431,6 +455,7 @@ def collect(
431
455
  "ffmpeg", fix=ffmpeg_fix(platform_name or sys.platform), which=which
432
456
  ),
433
457
  *(check_extra(extra) for extra in extras.EXTRA_MODULES),
458
+ check_bpm_detector(),
434
459
  check_cache(cache_root(base)),
435
460
  check_version(
436
461
  package_version=__version__,
@@ -41,6 +41,17 @@ EXTRA_PACKAGES: dict[str, tuple[str, ...]] = {
41
41
  "ocr": ("rapidocr", "onnxruntime"),
42
42
  }
43
43
 
44
+ #: The enrichment that cannot be a package extra. ``bpm-detector`` gives
45
+ #: ``yt-listen`` chord progression, structural segments and a refined BPM/key,
46
+ #: but it is not on PyPI — so it can only be a PEP 508 direct reference, and PyPI
47
+ #: rejects those in ``Requires-Dist`` (release 0.23.0 died on exactly that,
48
+ #: [[task:2824]]). It is therefore installed with ``pipx inject``: by the plugin
49
+ #: hook, or by hand from the README. ``doctor`` names it for the same reason it
50
+ #: names a missing extra — a silent quality drop is worse than a named one (D6).
51
+ BPM_DETECTOR_MODULE = "bpm_detector"
52
+ BPM_DETECTOR_SPEC = "bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0"
53
+ BPM_DETECTOR_FIX = f'pipx inject {DISTRIBUTION} "{BPM_DETECTOR_SPEC}"'
54
+
44
55
  _MISSING = object()
45
56
 
46
57
 
File without changes
File without changes
File without changes