yt-tools-cli 0.23.1__tar.gz → 0.23.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.23.1 → yt_tools_cli-0.23.2}/.claude-plugin/plugin.json +1 -1
  2. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/CHANGELOG.md +27 -0
  3. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/PKG-INFO +35 -11
  4. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/README.md +34 -10
  5. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/pyproject.toml +1 -1
  6. yt_tools_cli-0.23.2/scripts/publish-check.py +232 -0
  7. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/skills/using-yt-tools/SKILL.md +36 -13
  8. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_publish_check.py +64 -4
  9. yt_tools_cli-0.23.1/scripts/publish-check.py +0 -142
  10. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.github/workflows/ci.yml +0 -0
  11. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.github/workflows/release.yml +0 -0
  12. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.gitignore +0 -0
  13. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/CONTRIBUTING.md +0 -0
  14. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/LICENSE +0 -0
  15. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/hooks/hooks.json +0 -0
  16. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/check-core-install.sh +0 -0
  17. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/check-metadata.py +0 -0
  18. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/check-pypi-name.py +0 -0
  19. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/ensure-install.ps1 +0 -0
  20. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/ensure-install.py +0 -0
  21. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/ensure-install.sh +0 -0
  22. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/sync-version.py +0 -0
  23. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/__init__.py +0 -0
  24. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_cache.py +0 -0
  25. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_check_metadata.py +0 -0
  26. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_cli_smoke.py +0 -0
  27. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_comments.py +0 -0
  28. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_core.py +0 -0
  29. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_distribution_name.py +0 -0
  30. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_doctor.py +0 -0
  31. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_failure_modes.py +0 -0
  32. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_frames.py +0 -0
  33. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_listen.py +0 -0
  34. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_markdown.py +0 -0
  35. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_meta.py +0 -0
  36. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_missing_extra.py +0 -0
  37. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_ocr.py +0 -0
  38. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_packaging.py +0 -0
  39. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_parse_interval.py +0 -0
  40. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_pypi_name.py +0 -0
  41. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_search.py +0 -0
  42. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_skill_portable.py +0 -0
  43. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_transcript.py +0 -0
  44. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_version.py +0 -0
  45. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_watch.py +0 -0
  46. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/__init__.py +0 -0
  47. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/_metadata.py +0 -0
  48. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/cache.py +0 -0
  49. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/cli.py +0 -0
  50. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/comments.py +0 -0
  51. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/core.py +0 -0
  52. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/doctor.py +0 -0
  53. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/extras.py +0 -0
  54. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/frames.py +0 -0
  55. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/listen.py +0 -0
  56. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/markdown.py +0 -0
  57. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/meta.py +0 -0
  58. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/ocr.py +0 -0
  59. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/search.py +0 -0
  60. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/transcript.py +0 -0
  61. {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.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.23.1",
4
+ "version": "0.23.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",
@@ -11,6 +11,33 @@ Releases before 0.8.0 are summarized only in the git log.
11
11
 
12
12
  _(nothing yet)_
13
13
 
14
+ ## [0.23.2] — 2026-09-13
15
+
16
+ ### Fixed
17
+
18
+ - `scripts/publish-check.py` no longer reports a false `version drift` seconds
19
+ after a successful upload. PyPI's *project-level* JSON API is CDN-cached
20
+ (`cache-control: max-age=900`), so the `verify` job — which runs about five
21
+ seconds after the publish step — read the previous version and failed the
22
+ `0.23.1` release run while the release itself was live and complete. The check
23
+ now asks the *version-scoped* endpoint (`/pypi/<name>/<version>/json`, a URL
24
+ that cannot be stale) first, and lets the project endpoint only explain. Same
25
+ failure class as the simple-index cache behind the 0.22.1 preflight note: the
26
+ source a check adjudicates on has to be one that cannot lag.
27
+
28
+ ### Changed
29
+
30
+ - The README and the skill lead with the PyPI install instead of the git URL:
31
+ `pipx install "yt-tools-cli[full]"`, a pinned form
32
+ (`pipx install "yt-tools-cli[full]==0.23.1"`), and an explicit note that the
33
+ distribution is `yt-tools-cli` while the project, the repository and every
34
+ command stay `yt-tools` (import name `yt_tools`).
35
+ - The *changing extras* advice is corrected: `pipx uninstall` and then install,
36
+ instead of `pipx install --force`. A forced reinstall on an existing venv was
37
+ observed leaving the new extras half-installed (`scenedetect` skipped entirely)
38
+ and resolving `librosa` 1.0 past its cap; `pipx runpip yt-tools-cli list` is
39
+ the check that sees it.
40
+
14
41
  ## [0.23.1] — 2026-09-13
15
42
 
16
43
  > `0.23.0` never reached PyPI: the upload was rejected and no artifact exists
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: yt-tools-cli
3
- Version: 0.23.1
3
+ Version: 0.23.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
@@ -128,20 +128,44 @@ directory at this repo's `skills/` (or symlink `skills/using-yt-tools` into it).
128
128
 
129
129
  ### Standalone CLI (any environment)
130
130
 
131
- Install directly from this Git repo via [`pipx`](https://pipx.pypa.io/):
131
+ The distribution is **`yt-tools-cli`**: the project, the repository, the plugin and
132
+ every console command stay `yt-tools`, and the import name stays `yt_tools`.
133
+ `pip`/`pipx`/`uv` resolve the distribution name, so that is the one below.
132
134
 
133
135
  ```bash
134
136
  # 1. bootstrap pipx (one-time per user)
135
137
  python -m pip install --user pipx
136
138
  python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
137
139
 
138
- # 2. install yt-tools (core) from this repo
139
- pipx install git+https://github.com/kzntsv-dev/yt-tools.git
140
+ # 2. install from PyPI — core + [frames] + [audio], i.e. every flow except OCR
141
+ pipx install "yt-tools-cli[full]"
140
142
 
141
- # 3. add the extras you need — one combined install re-creates the same venv
142
- pipx install --force "git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]"
143
+ # 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
143
148
  ```
144
149
 
150
+ `uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
151
+ `uvx --from "yt-tools-cli[full]" yt-transcript <url>`.
152
+
153
+ **From a checkout instead of PyPI** — the plugin path, or anyone tracking
154
+ `master`:
155
+
156
+ ```bash
157
+ pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
158
+ ```
159
+
160
+ > **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
161
+ > first, then install the new extras set. `pipx install --force` on an existing
162
+ > venv can leave the new extras half-installed and resolve against the old pin
163
+ > set — observed on a `[full]` reinstall that kept `librosa` 1.0 instead of the
164
+ > capped 0.11 and skipped `scenedetect` entirely. Verify with
165
+ > `pipx runpip yt-tools-cli list`, not by the version alone.
166
+ > `pipx inject yt-tools-cli <packages>` is the narrow alternative when you only
167
+ > need to add packages.
168
+
145
169
  #### What each extra buys you
146
170
 
147
171
  A plain install is deliberately light: it never pulls OpenCV, librosa or
@@ -176,11 +200,11 @@ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detec
176
200
  Without it `yt-listen` still runs: librosa-only BPM and key, with every section
177
201
  it cannot fill marked `n/a` in the markdown rather than silently missing.
178
202
 
179
- > **Adding an extra to an existing pipx venv.** Re-run `pipx install --force`
180
- > with the full extras list (as in step 3) rather than installing a second
181
- > time — a plain reinstall of a different extras set replaces the venv. Use
182
- > `pipx inject yt-tools-cli scenedetect opencv-python` only when you cannot
183
- > re-run the installer.
203
+ > **Adding a single extra to an existing pipx venv.** `pipx inject yt-tools-cli
204
+ > scenedetect opencv-python` adds packages without rebuilding the venv, which is
205
+ > the cheapest way to widen an install. To change the *extras set* (e.g. move
206
+ > from core to `[full]`), uninstall and reinstall instead — see the note in
207
+ > [Standalone CLI](#standalone-cli-any-environment).
184
208
 
185
209
  #### What happens when an extra is missing
186
210
 
@@ -86,20 +86,44 @@ directory at this repo's `skills/` (or symlink `skills/using-yt-tools` into it).
86
86
 
87
87
  ### Standalone CLI (any environment)
88
88
 
89
- Install directly from this Git repo via [`pipx`](https://pipx.pypa.io/):
89
+ The distribution is **`yt-tools-cli`**: the project, the repository, the plugin and
90
+ every console command stay `yt-tools`, and the import name stays `yt_tools`.
91
+ `pip`/`pipx`/`uv` resolve the distribution name, so that is the one below.
90
92
 
91
93
  ```bash
92
94
  # 1. bootstrap pipx (one-time per user)
93
95
  python -m pip install --user pipx
94
96
  python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
95
97
 
96
- # 2. install yt-tools (core) from this repo
97
- pipx install git+https://github.com/kzntsv-dev/yt-tools.git
98
+ # 2. install from PyPI — core + [frames] + [audio], i.e. every flow except OCR
99
+ pipx install "yt-tools-cli[full]"
98
100
 
99
- # 3. add the extras you need — one combined install re-creates the same venv
100
- pipx install --force "git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]"
101
+ # 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
101
106
  ```
102
107
 
108
+ `uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
109
+ `uvx --from "yt-tools-cli[full]" yt-transcript <url>`.
110
+
111
+ **From a checkout instead of PyPI** — the plugin path, or anyone tracking
112
+ `master`:
113
+
114
+ ```bash
115
+ pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
116
+ ```
117
+
118
+ > **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
119
+ > first, then install the new extras set. `pipx install --force` on an existing
120
+ > venv can leave the new extras half-installed and resolve against the old pin
121
+ > set — observed on a `[full]` reinstall that kept `librosa` 1.0 instead of the
122
+ > capped 0.11 and skipped `scenedetect` entirely. Verify with
123
+ > `pipx runpip yt-tools-cli list`, not by the version alone.
124
+ > `pipx inject yt-tools-cli <packages>` is the narrow alternative when you only
125
+ > need to add packages.
126
+
103
127
  #### What each extra buys you
104
128
 
105
129
  A plain install is deliberately light: it never pulls OpenCV, librosa or
@@ -134,11 +158,11 @@ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detec
134
158
  Without it `yt-listen` still runs: librosa-only BPM and key, with every section
135
159
  it cannot fill marked `n/a` in the markdown rather than silently missing.
136
160
 
137
- > **Adding an extra to an existing pipx venv.** Re-run `pipx install --force`
138
- > with the full extras list (as in step 3) rather than installing a second
139
- > time — a plain reinstall of a different extras set replaces the venv. Use
140
- > `pipx inject yt-tools-cli scenedetect opencv-python` only when you cannot
141
- > re-run the installer.
161
+ > **Adding a single extra to an existing pipx venv.** `pipx inject yt-tools-cli
162
+ > scenedetect opencv-python` adds packages without rebuilding the venv, which is
163
+ > the cheapest way to widen an install. To change the *extras set* (e.g. move
164
+ > from core to `[full]`), uninstall and reinstall instead — see the note in
165
+ > [Standalone CLI](#standalone-cli-any-environment).
142
166
 
143
167
  #### What happens when an extra is missing
144
168
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "yt-tools-cli"
7
- version = "0.23.1"
7
+ version = "0.23.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"
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Did the release actually land on PyPI?
3
+
4
+ A green build is not a published release. ``sched`` learned this the expensive
5
+ way: a wave failed on the ``tsc``/dist step and nobody noticed until an external
6
+ check looked — twice for the same version (their ``scripts/publish-check.mjs``).
7
+ The question "is the version people install the version we tagged?" belongs to a
8
+ machine, and it belongs *after* the upload.
9
+
10
+ So this runs after the publish step and answers three things:
11
+
12
+ 1. does *this* version exist on the index at all ("never landed" — a 404 body);
13
+ 2. is its artifact set complete (a release that landed half-built is not a
14
+ release);
15
+ 3. if it is absent, did something else land instead ("version drift" — the build
16
+ published another version, or a stale tag).
17
+
18
+ **Which payload answers which question matters.** The project-level endpoint
19
+ ``/pypi/<name>/json`` is CDN-cached (``cache-control: max-age=900``), so seconds
20
+ after a successful upload it still serves the *previous* version — and this check
21
+ runs seconds after a successful upload. Adjudicating on it produced a false
22
+ "version drift" on release 0.23.1 for a release that had landed ([[task:2826]],
23
+ the whole reason this file asks twice). The version-scoped endpoint
24
+ ``/pypi/<name>/<version>/json`` is a URL nobody has fetched before, so it cannot
25
+ be stale: it decides. The project endpoint only ever explains.
26
+
27
+ Exit codes: ``0`` landed · ``1`` not landed / drifted / incomplete · ``2`` the
28
+ check could not run (an unrunnable check is never a pass).
29
+
30
+ Usage::
31
+
32
+ python scripts/publish-check.py # version from pyproject.toml
33
+ python scripts/publish-check.py --version 0.23.1
34
+ # offline / tests: one file per endpoint
35
+ python scripts/publish-check.py --json-file version.json --project-json-file project.json
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import json
42
+ import re
43
+ import sys
44
+ import urllib.error
45
+ import urllib.request
46
+ from pathlib import Path
47
+
48
+ #: The version-scoped endpoint: unique per release, therefore never stale.
49
+ PYPI_VERSION_JSON = "https://pypi.org/pypi/{name}/{version}/json"
50
+ #: The project-scoped endpoint: what `latest` means to a user, and CDN-cached.
51
+ PYPI_PROJECT_JSON = "https://pypi.org/pypi/{name}/json"
52
+ _VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
53
+ _NAME_RE = re.compile(r'^name\s*=\s*"([^"]+)"', re.MULTILINE)
54
+
55
+ #: Both artifact kinds a release is expected to ship.
56
+ EXPECTED_KINDS = ("bdist_wheel", "sdist")
57
+
58
+ NOT_FOUND: dict = {"message": "Not Found"}
59
+
60
+
61
+ class CheckError(RuntimeError):
62
+ """The check could not run (bad fixture, unreachable index, HTTP != 404)."""
63
+
64
+
65
+ def read_pyproject(path: Path) -> tuple[str | None, str | None]:
66
+ """``(name, version)`` from ``pyproject.toml``, or ``(None, None)``."""
67
+ try:
68
+ text = path.read_text(encoding="utf-8")
69
+ except OSError:
70
+ return None, None
71
+ name = _NAME_RE.search(text)
72
+ version = _VERSION_RE.search(text)
73
+ return (name.group(1) if name else None, version.group(1) if version else None)
74
+
75
+
76
+ def read_json_file(path: Path) -> dict:
77
+ try:
78
+ return json.loads(path.read_text(encoding="utf-8"))
79
+ except (OSError, json.JSONDecodeError) as exc:
80
+ raise CheckError(f"cannot read {path}: {exc}") from exc
81
+
82
+
83
+ def fetch_payload(url: str) -> dict:
84
+ """The index payload, or ``NOT_FOUND`` when the index says 404.
85
+
86
+ A 404 is a *result* (nothing is there), not a broken check — that distinction
87
+ is the difference between "wait for the upload" and "the check is unusable".
88
+ """
89
+ try:
90
+ with urllib.request.urlopen(url, timeout=60) as response:
91
+ return json.load(response)
92
+ except urllib.error.HTTPError as exc:
93
+ if exc.code == 404:
94
+ return dict(NOT_FOUND)
95
+ raise CheckError(f"the index answered {exc.code} for {url}") from exc
96
+ except (urllib.error.URLError, OSError) as exc:
97
+ raise CheckError(f"cannot reach the index at {url}: {exc}") from exc
98
+
99
+
100
+ def _canonical(name: str) -> str:
101
+ return name.lower().replace("_", "-")
102
+
103
+
104
+ def _project_note(project_payload: dict | None, expected_version: str) -> list[str]:
105
+ """Explain a lagging project endpoint — never decide with it."""
106
+ if not project_payload or "info" not in project_payload:
107
+ return []
108
+ latest = project_payload["info"].get("version")
109
+ if not latest or latest == expected_version:
110
+ return []
111
+ return [
112
+ f" note: the project endpoint still serves {latest} - PyPI's project JSON API is "
113
+ "CDN-cached (cache-control: max-age=900), so it can lag minutes behind an upload; "
114
+ "the version endpoint above is the authority"
115
+ ]
116
+
117
+
118
+ def _not_landed(
119
+ lines: list[str], project_payload: dict | None, expected_version: str
120
+ ) -> tuple[int, list[str]]:
121
+ """Our version is absent — the project endpoint says whether something else landed."""
122
+ if project_payload and "info" in project_payload:
123
+ info = project_payload["info"]
124
+ latest = info.get("version")
125
+ lines.append(f" on the project endpoint: {info.get('name', '?')} {latest}")
126
+ if latest and latest != expected_version:
127
+ lines.append(f" verdict: version drift - tagged {expected_version}, installable {latest}")
128
+ lines.append(" fix : the upload published another version; check the tag and the build")
129
+ return 1, lines
130
+ lines.append(" verdict: never landed - the release is not on the index")
131
+ lines.append(" fix : wait for the publish job, or re-run it; the upload itself failed")
132
+ return 1, lines
133
+
134
+
135
+ def check(
136
+ version_payload: dict,
137
+ project_payload: dict | None,
138
+ expected_name: str,
139
+ expected_version: str,
140
+ ) -> tuple[int, list[str]]:
141
+ lines = [f"publish check: {expected_name} {expected_version}"]
142
+
143
+ if "info" not in version_payload:
144
+ return _not_landed(lines, project_payload, expected_version)
145
+
146
+ info = version_payload.get("info", {})
147
+ published_name = info.get("name", "?")
148
+ published_version = info.get("version", "?")
149
+ lines.append(f" on the version endpoint: {published_name} {published_version}")
150
+
151
+ if _canonical(published_name) != _canonical(expected_name):
152
+ lines.append(f" verdict: name drift - expected {expected_name!r}, index has {published_name!r}")
153
+ return 1, lines
154
+
155
+ if published_version != expected_version:
156
+ lines.append(
157
+ f" verdict: version drift - tagged {expected_version}, "
158
+ f"that endpoint serves {published_version}"
159
+ )
160
+ lines.append(" fix : the upload published another version; check the tag and the build")
161
+ return 1, lines
162
+
163
+ kinds = {f.get("packagetype") for f in version_payload.get("urls", [])}
164
+ missing = [kind for kind in EXPECTED_KINDS if kind not in kinds]
165
+ if missing:
166
+ lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k)) or 'none'}")
167
+ lines.append(f" verdict: incomplete - missing {', '.join(missing)}")
168
+ lines.append(" fix : the publish step uploaded a partial release")
169
+ return 1, lines
170
+
171
+ lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k))}")
172
+ lines.extend(_project_note(project_payload, expected_version))
173
+ lines.append(" verdict: landed - what people install is what we tagged")
174
+ return 0, lines
175
+
176
+
177
+ def main(argv: list[str] | None = None) -> int:
178
+ parser = argparse.ArgumentParser(
179
+ prog="publish-check",
180
+ description="Verify that a release landed on PyPI (run after the publish step).",
181
+ )
182
+ parser.add_argument("--name", help="distribution name (default: pyproject.toml)")
183
+ parser.add_argument("--version", help="expected version (default: pyproject.toml)")
184
+ parser.add_argument("--json-file", type=Path, help="read the version-endpoint payload from a file (offline)")
185
+ parser.add_argument(
186
+ "--project-json-file",
187
+ type=Path,
188
+ help="read the project-endpoint payload from a file (offline; optional)",
189
+ )
190
+ parser.add_argument("--url", help="override the version-endpoint URL")
191
+ parser.add_argument("--project-url", help="override the project-endpoint URL")
192
+ args = parser.parse_args(argv)
193
+
194
+ pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
195
+ declared_name, declared_version = read_pyproject(pyproject)
196
+ name = args.name or declared_name
197
+ version = args.version or declared_version
198
+ if not name or not version:
199
+ print(f"error: no name/version in {pyproject} and none given", file=sys.stderr)
200
+ return 2
201
+
202
+ try:
203
+ if args.json_file is not None:
204
+ version_payload = read_json_file(args.json_file)
205
+ project_payload = (
206
+ read_json_file(args.project_json_file) if args.project_json_file is not None else None
207
+ )
208
+ else:
209
+ version_payload = fetch_payload(
210
+ args.url or PYPI_VERSION_JSON.format(name=name, version=version)
211
+ )
212
+ project_url = args.project_url or PYPI_PROJECT_JSON.format(name=name)
213
+ try:
214
+ project_payload = fetch_payload(project_url)
215
+ except CheckError:
216
+ # Only needed to *explain* an absent version; a proven landing
217
+ # must not fail because the advisory probe hiccuped.
218
+ if "info" in version_payload:
219
+ project_payload = None
220
+ else:
221
+ raise
222
+ except CheckError as exc:
223
+ print(f"error: {exc}", file=sys.stderr)
224
+ return 2
225
+
226
+ code, lines = check(version_payload, project_payload, name, version)
227
+ print("\n".join(lines))
228
+ return code
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: using-yt-tools
3
- version: 0.23.1
3
+ version: 0.23.2
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
 
@@ -133,25 +133,47 @@ binaries).
133
133
 
134
134
  ### Installing the CLI
135
135
 
136
- Install once per machine — one command, the same in every harness:
136
+ Install once per machine — one command, the same in every harness. The
137
+ **distribution** name is `yt-tools-cli` (the project, repo, plugin and all
138
+ commands stay `yt-tools`; the import name is `yt_tools`):
137
139
 
138
140
  ```bash
139
- pipx install git+https://github.com/kzntsv-dev/yt-tools.git
141
+ # from PyPI — core + [frames] + [audio], i.e. every flow except OCR
142
+ pipx install "yt-tools-cli[full]"
143
+
144
+ # core only (transcript / meta / comments / search / cache)
145
+ pipx install yt-tools-cli
146
+
147
+ # reproducible: pin the exact version
148
+ pipx install "yt-tools-cli[full]==0.23.1"
140
149
  ```
141
150
 
142
- For chord progression + structural analysis in `yt-listen`, add the `[full]`
143
- extra:
151
+ From a checkout instead of PyPI (the plugin path, or tracking `master`):
144
152
 
145
153
  ```bash
146
- pipx install "git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]"
154
+ pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
147
155
  ```
148
156
 
157
+ The `[full]` extra is core + `[frames]` + `[audio]`. Chord progression,
158
+ structural segments and the refined BPM/key in `yt-listen` come from
159
+ `bpm-detector`, which is **not on PyPI** and therefore cannot live in the
160
+ published metadata — the Python package index rejects direct VCS references.
161
+ Add it on top:
162
+
163
+ ```bash
164
+ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0"
165
+ ```
166
+
167
+ 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`.
170
+
149
171
  Some harnesses do this for you: if your host ships the bundled yt-tools plugin
150
172
  (Claude Code does), its `SessionStart` hook pipx-installs the `[full]` set from
151
- the plugin's own clone on the first session after install — no PyPI
152
- involvement, and a blocked `[full]` VCS fetch falls back to `[frames,audio]`,
153
- where every flow except OCR still works. A hook is a convenience, not a
154
- requirement: the two commands above are the contract.
173
+ the plugin's own clone on the first session after install, then injects
174
+ `bpm-detector` best-effort — a blocked VCS fetch warns and leaves the
175
+ librosa-only path, it never fails the install. A hook is a convenience, not a
176
+ requirement: the commands above are the contract.
155
177
 
156
178
  ### First run on a machine: `yt-tools doctor`
157
179
 
@@ -211,9 +233,10 @@ the optional `[ocr]` extra):
211
233
  ```
212
234
  python -m pip install --user pipx
213
235
  python -m pipx ensurepath # one-time; restart shell after
214
- pipx install git+https://github.com/kzntsv-dev/yt-tools.git # core
215
- # For chord progression / structure analysis (optional):
216
- # pipx install "git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]"
236
+ pipx install "yt-tools-cli[full]" # core + frames + audio
237
+ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0"
238
+ # or from a checkout:
239
+ pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
217
240
  ```
218
241
  If your host ships the bundled plugin (Claude Code users: install
219
242
  `yt-tools@opeitcloc03-claude-plugins`), prefer it — its SessionStart hook
@@ -38,8 +38,8 @@ def _payload(version: str, *, wheel: bool = True, sdist: bool = True) -> str:
38
38
  return json.dumps({"info": {"name": "yt-tools-cli", "version": version}, "urls": files})
39
39
 
40
40
 
41
- def _write(tmp_path: Path, text: str) -> Path:
42
- path = tmp_path / "pypi.json"
41
+ def _write(tmp_path: Path, text: str, name: str = "pypi.json") -> Path:
42
+ path = tmp_path / name
43
43
  path.write_text(text, encoding="utf-8")
44
44
  return path
45
45
 
@@ -61,12 +61,57 @@ def test_a_landed_release_passes(tmp_path):
61
61
 
62
62
 
63
63
  def test_a_version_that_never_landed_fails_and_names_both_versions(tmp_path):
64
- """The failure sched saw twice: build green, registry still on the old one."""
65
- proc = _run("--version", "9.9.9", "--json-file", str(_write(tmp_path, _payload("0.22.1"))))
64
+ """The failure sched saw twice: build green, registry still on the old one.
65
+
66
+ Restated for the two-endpoint design: *our* version endpoint 404s (the
67
+ release is not there) while the project endpoint names another version —
68
+ which is the actual drift signature.
69
+ [test-modify: test_a_version_that_never_landed_fails_and_names_both_versions:
70
+ was `--json-file` carrying the project payload with another version; is the
71
+ same payload passed as `--project-json-file` next to a 404 version payload;
72
+ reason: the check now asks the version-scoped endpoint first (it cannot be
73
+ CDN-stale — task:2826), so "what the index serves" is the project payload
74
+ and "is our release there" is the version payload]
75
+ """
76
+ proc = _run(
77
+ "--version",
78
+ "9.9.9",
79
+ "--json-file",
80
+ str(_write(tmp_path, '{"message": "Not Found"}', name="version.json")),
81
+ "--project-json-file",
82
+ str(_write(tmp_path, _payload("0.22.1"), name="project.json")),
83
+ )
66
84
 
67
85
  assert proc.returncode == 1
68
86
  assert "9.9.9" in proc.stdout # what we tagged
69
87
  assert "0.22.1" in proc.stdout # what the world installs
88
+ assert "drift" in proc.stdout
89
+
90
+
91
+ def test_a_stale_project_endpoint_does_not_fail_a_landed_release(tmp_path):
92
+ """The v0.23.1 incident, pinned as a test ([[task:2826]]).
93
+
94
+ PyPI's project-level JSON API is CDN-cached (`cache-control: max-age=900`,
95
+ `X-Cache: HIT`), so five seconds after a successful upload it still serves
96
+ the *previous* version — and the `verify` job runs exactly then. Adjudicating
97
+ on that payload produced a false `version drift` on a release that had
98
+ landed: the uploaded files were on the index the whole time. The
99
+ version-scoped endpoint is a URL nobody has fetched before, so it cannot be
100
+ stale; the project endpoint is only allowed to explain, never to decide.
101
+ """
102
+ expected = _pyproject_version()
103
+ proc = _run(
104
+ "--json-file",
105
+ str(_write(tmp_path, _payload(expected), name="version.json")),
106
+ "--project-json-file",
107
+ str(_write(tmp_path, _payload("0.0.0"), name="project.json")),
108
+ )
109
+
110
+ assert proc.returncode == 0, proc.stdout + proc.stderr
111
+ assert "landed" in proc.stdout
112
+ # Named as a cache note, not as a verdict.
113
+ assert "0.0.0" in proc.stdout
114
+ assert "cache" in proc.stdout.lower()
70
115
 
71
116
 
72
117
  def test_a_missing_artifact_kind_fails(tmp_path):
@@ -91,6 +136,21 @@ def test_a_project_that_does_not_exist_yet_is_not_a_mismatch(tmp_path):
91
136
  assert "does not exist" in proc.stdout or "never landed" in proc.stdout
92
137
 
93
138
 
139
+ def test_a_project_endpoint_that_is_also_missing_still_says_never_landed(tmp_path):
140
+ """Both endpoints 404: the release never landed (no drift to report)."""
141
+ proc = _run(
142
+ "--version",
143
+ "9.9.9",
144
+ "--json-file",
145
+ str(_write(tmp_path, '{"message": "Not Found"}', name="version.json")),
146
+ "--project-json-file",
147
+ str(_write(tmp_path, '{"message": "Not Found"}', name="project.json")),
148
+ )
149
+
150
+ assert proc.returncode == 1
151
+ assert "never landed" in proc.stdout
152
+
153
+
94
154
  def test_the_default_expected_version_is_the_tree_s(tmp_path):
95
155
  """No arguments: check what this checkout says it is."""
96
156
  proc = _run("--json-file", str(_write(tmp_path, _payload(_pyproject_version()))))
@@ -1,142 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Did the release actually land on PyPI?
3
-
4
- A green build is not a published release. ``sched`` learned this the expensive
5
- way: a wave failed on the ``tsc``/dist step and nobody noticed until an external
6
- check looked — twice for the same version (their ``scripts/publish-check.mjs``).
7
- The question "is the version people install the version we tagged?" belongs to a
8
- machine, and it belongs *after* the upload.
9
-
10
- So this runs after the publish step and answers three things:
11
-
12
- 1. does the distribution exist on the index at all ("never landed" — a 404 body);
13
- 2. is its latest version the one this checkout declares ("version drift" — the
14
- build published something else, or a stale tag);
15
- 3. are both artifacts there (a release that landed half-built is not a release).
16
-
17
- Those are three different failures with three different fixes, so they are
18
- reported separately. Exit codes: ``0`` landed · ``1`` not landed / drifted /
19
- incomplete · ``2`` the check could not run (an unrunnable check is never a pass).
20
-
21
- Usage::
22
-
23
- python scripts/publish-check.py # version from pyproject.toml
24
- python scripts/publish-check.py --version 0.23.0
25
- python scripts/publish-check.py --json-file fixture.json # offline / tests
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import argparse
31
- import json
32
- import re
33
- import sys
34
- import urllib.error
35
- import urllib.request
36
- from pathlib import Path
37
-
38
- PYPI_JSON = "https://pypi.org/pypi/{name}/json"
39
- _VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
40
- _NAME_RE = re.compile(r'^name\s*=\s*"([^"]+)"', re.MULTILINE)
41
-
42
- #: Both artifact kinds a release is expected to ship.
43
- EXPECTED_KINDS = ("bdist_wheel", "sdist")
44
-
45
-
46
- def read_pyproject(path: Path) -> tuple[str | None, str | None]:
47
- """``(name, version)`` from ``pyproject.toml``, or ``(None, None)``."""
48
- try:
49
- text = path.read_text(encoding="utf-8")
50
- except OSError:
51
- return None, None
52
- name = _NAME_RE.search(text)
53
- version = _VERSION_RE.search(text)
54
- return (name.group(1) if name else None, version.group(1) if version else None)
55
-
56
-
57
- def fetch_payload(name: str, url: str | None) -> dict:
58
- with urllib.request.urlopen(url or PYPI_JSON.format(name=name), timeout=60) as response:
59
- return json.load(response)
60
-
61
-
62
- def check(payload: dict, expected_name: str, expected_version: str) -> tuple[int, list[str]]:
63
- lines = [f"publish check: {expected_name} {expected_version}"]
64
-
65
- if "info" not in payload:
66
- lines.append(" verdict: does not exist on the index - the release never landed")
67
- lines.append(" fix : wait for the publish job, or re-run it; the upload itself failed")
68
- return 1, lines
69
-
70
- info = payload.get("info", {})
71
- published_name = info.get("name", "?")
72
- published_version = info.get("version", "?")
73
- lines.append(f" on the index: {published_name} {published_version}")
74
-
75
- if published_name.lower().replace("_", "-") != expected_name.lower().replace("_", "-"):
76
- lines.append(f" verdict: name drift - expected {expected_name!r}, index has {published_name!r}")
77
- return 1, lines
78
-
79
- if published_version != expected_version:
80
- lines.append(f" verdict: version drift - tagged {expected_version}, installable {published_version}")
81
- lines.append(" fix : the upload published another version; check the tag and the build")
82
- return 1, lines
83
-
84
- kinds = {f.get("packagetype") for f in payload.get("urls", [])}
85
- missing = [kind for kind in EXPECTED_KINDS if kind not in kinds]
86
- if missing:
87
- lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k)) or 'none'}")
88
- lines.append(f" verdict: incomplete - missing {', '.join(missing)}")
89
- lines.append(" fix : the publish step uploaded a partial release")
90
- return 1, lines
91
-
92
- lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k))}")
93
- lines.append(" verdict: landed - what people install is what we tagged")
94
- return 0, lines
95
-
96
-
97
- def main(argv: list[str] | None = None) -> int:
98
- parser = argparse.ArgumentParser(
99
- prog="publish-check",
100
- description="Verify that a release landed on PyPI (run after the publish step).",
101
- )
102
- parser.add_argument("--name", help="distribution name (default: pyproject.toml)")
103
- parser.add_argument("--version", help="expected version (default: pyproject.toml)")
104
- parser.add_argument("--json-file", type=Path, help="read the index payload from a file (offline)")
105
- parser.add_argument("--url", help="override the JSON API URL")
106
- args = parser.parse_args(argv)
107
-
108
- pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
109
- declared_name, declared_version = read_pyproject(pyproject)
110
- name = args.name or declared_name
111
- version = args.version or declared_version
112
- if not name or not version:
113
- print(f"error: no name/version in {pyproject} and none given", file=sys.stderr)
114
- return 2
115
-
116
- if args.json_file is not None:
117
- try:
118
- payload = json.loads(args.json_file.read_text(encoding="utf-8"))
119
- except (OSError, json.JSONDecodeError) as exc:
120
- print(f"error: cannot read {args.json_file}: {exc}", file=sys.stderr)
121
- return 2
122
- else:
123
- try:
124
- payload = fetch_payload(name, args.url)
125
- except urllib.error.HTTPError as exc:
126
- # A 404 is a *result* (nothing landed), not a broken check.
127
- if exc.code == 404:
128
- payload = {"message": "Not Found"}
129
- else:
130
- print(f"error: the index answered {exc.code} for {name}", file=sys.stderr)
131
- return 2
132
- except (urllib.error.URLError, OSError) as exc:
133
- print(f"error: cannot reach the index for {name}: {exc}", file=sys.stderr)
134
- return 2
135
-
136
- code, lines = check(payload, name, version)
137
- print("\n".join(lines))
138
- return code
139
-
140
-
141
- if __name__ == "__main__":
142
- sys.exit(main())
File without changes
File without changes