Fetch YouTube video transcripts from the command line.
A thin wrapper around youtube-transcript-api
that extracts video IDs from URLs, handles multiple videos, and optionally
prefixes each segment with a timestamp.
brew install marcelocantos/tap/yttEach release attaches standalone binaries for macOS arm64, Linux x86_64,
and Linux arm64. Download the tarball matching your platform from the
releases page,
extract, and put ytt on your PATH.
Requires Python 3.10+:
pipx install git+https://github.com/marcelocantos/yttytt dQw4w9WgXcQ # raw video ID
ytt https://www.youtube.com/watch?v=dQw4w9WgXcQ # full URL
ytt https://youtu.be/dQw4w9WgXcQ # short URL
ytt --timestamps dQw4w9WgXcQ # one line per segment, [mm:ss] prefix
ytt --json dQw4w9WgXcQ # full API payload as JSON
ytt <id1> <id2> <id3> # multiple videos, blank line betweenPlain output joins all segments with spaces — convenient for piping into word counts, LLM prompts, or search tools:
ytt dQw4w9WgXcQ | wc -w
ytt dQw4w9WgXcQ | grep -i "never"With --timestamps (-t), each segment is on its own line:
[00:00] Never gonna give you up
[00:03] Never gonna let you down
[00:07] Never gonna run around and desert you
...
With --json, the full upstream payload is emitted — per-segment timing,
language metadata, and the auto-generated flag — one compact JSON object
per video (JSONL for multi-video):
ytt --json dQw4w9WgXcQ | jq .{
"video_id": "dQw4w9WgXcQ",
"language": "English",
"language_code": "en",
"is_generated": false,
"snippets": [
{"text": "Never gonna give you up", "start": 0.0, "duration": 2.5},
...
]
}| Flag | Purpose |
|---|---|
-t, --timestamps |
Prefix each segment with [mm:ss] (or [h:mm:ss] for long videos), one per line |
--json |
Emit the full API payload as JSON (one object per video, JSONL for multi). Mutually exclusive with -t. |
--version |
Print version |
--help |
Print usage |
--help-agent |
Extended help oriented toward AI/agent consumers |
| Command | Purpose |
|---|---|
ytt ingest [PLAYLIST_URL] |
Bulk-ingest a playlist + tracked channels — see Playlist ingest below |
| Code | Meaning |
|---|---|
| 0 | All transcripts fetched successfully |
| 1 | One or more videos failed (unavailable, transcripts disabled, etc.) |
| 2 | Usage error (no arguments, bad flag) |
Errors are written to stderr, one line per failure, in the form
ytt: <video-id>: <reason>.
ytt ingest is a bulk workflow for building a local knowledge base of
video transcripts and synopses. It walks a YouTube playlist and any
tracked channels, fetches transcripts for new videos, and writes one
directory per video under $YOUTUBE_INGEST_ROOT.
export YOUTUBE_INGEST_PLAYLIST=https://www.youtube.com/playlist?list=PL...
export YOUTUBE_INGEST_ROOT=~/knowledge/youtube
ytt ingest$YOUTUBE_INGEST_ROOT/
├── <video-id>/
│ ├── .transcript/transcript.json # full youtube-transcript-api payload (hidden from Obsidian graph)
│ ├── meta.json # title, channel, upload date, duration, …
│ └── <slug>.md # synopsis (generated via `claude`)
├── .processed # dedup state (one video ID per line)
├── .channels/<handle> # per-channel cursor file
├── .ingest.log # append-only run log
└── youtube-knowledge-base.md # index, regenerated from the per-video files
| Env var | Default | Purpose |
|---|---|---|
YOUTUBE_INGEST_PLAYLIST |
(required) | Playlist URL. Can be passed as the first arg instead. |
YOUTUBE_INGEST_ROOT |
~/think/knowledge/youtube |
Where ingested videos land. |
YOUTUBE_CHANNELS_FILE |
~/.config/ytt/channels.yaml |
YAML list of channels to track newest-first. Resolved from $XDG_CONFIG_HOME/ytt/ — not the install directory, which a brew upgrade replaces. |
YOUTUBE_INGEST_CONCURRENCY |
4 |
Parallel video workers. |
YOUTUBE_INGEST_YTT_BIN |
ytt (PATH) |
Absolute path to the ytt used for transcript fetches. Pin it in scheduled runs so launchd and your shell can't resolve two different builds. |
YOUTUBE_INGEST_CLAUDE_BIN |
claude (PATH) |
Absolute path to Claude Code used for synopses. Pin it in scheduled runs because launchd has a deliberately minimal PATH. |
YOUTUBE_INGEST_LOG |
$YOUTUBE_INGEST_ROOT/.ingest.log |
Ingest log path. Point it outside the content tree for scheduled runs. |
YOUTUBE_INGEST_NETWORK_WAIT |
14400 |
Seconds to wait (awake-time) for connectivity before giving up — covers launchd ticks that fire in a no-network DarkWake window. |
YOUTUBE_INGEST_STALE_DAYS |
7 |
Days of zero ingests (with channels tracked) before the run is judged unhealthy. 0 disables the check. |
YOUTUBE_INGEST_STATE_DIR |
~/.local/state/ytt |
Where the liveness stamp lives. Kept out of the content tree. |
YOUTUBE_INGEST_BLURTER_BIN |
blurter (PATH) |
The blurter binary used to report events. Pin it in scheduled runs. |
To run ingest on a daily schedule, install the bundled launchd agent:
cp "$(brew --prefix)/libexec/scripts/playlist-ingest/launchd/com.marcelocantos.youtube-ingest.plist" \
~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.marcelocantos.youtube-ingest.plistIt runs the installed ytt ingest at 06:30 daily, pins the ytt
binary, and writes logs to ~/.local/var/log/youtube-ingest/ (not into
the content tree). Edit the playlist URL and paths in the plist first;
they are machine-specific. The scheduled path is deliberately
network-tolerant: a tick that fires while the machine is asleep or
between networks waits for connectivity rather than recording a false
"nothing new".
Channel config lives in ~/.config/ytt/channels.yaml:
mkdir -p ~/.config/ytt
cp "$(brew --prefix ytt)/libexec/scripts/playlist-ingest/channels.example.yaml" \
~/.config/ytt/channels.yaml
$EDITOR ~/.config/ytt/channels.yaml
ytt ingest --dry-run # confirm the channels are seen before ingestingResolution order is $YOUTUBE_CHANNELS_FILE, then
$XDG_CONFIG_HOME/ytt/channels.yaml (default ~/.config/ytt/), then a
copy beside the scripts (dev checkouts only). Keep your config out of the
install directory: Homebrew replaces .../Cellar/ytt/<version>/libexec/
wholesale on every upgrade, so config stored there disappears silently.
On first sight of a channel, the latest video is ingested and recorded as a cursor — no backfill of older uploads. Subsequent runs walk newer videos until the cursor is hit. With no channels file at all, ingest runs in playlist-only mode.
If a channels file goes missing while .channels/<handle> cursors still
exist, that is treated as an orphaned config — a real failure, not a
preference — and the run fails loudly instead of reporting "nothing to
do". This case cost 15 days of silent no-ops before v0.10.0.
ytt does not deliver notifications. It reports events to blurter, which owns the Slack credential, the delivery policy (dedup, re-notify windows, recovery notices) and the sinks. blurter is a hard dependency, declared by the Homebrew formula.
brew services start blurter # if it isn't already runningConfigure Slack once, in blurter, not per app — see its README. ytt holds no credential and needs no notification configuration of its own.
A run is unhealthy when any of these hold, and each is reported as a problem
event:
- a preflight abort (missing
ytt/claude/curl, or network never returned) - a discovery source failed (playlist or channel feed)
- a channels config was orphaned (see above)
- any queued video failed to ingest, or the run watchdog fired
- the Claude spend limit cut the run short
- the knowledge-base index failed to refresh
- nothing has been ingested for
YOUTUBE_INGEST_STALE_DAYSdays while channels are tracked — the liveness backstop for "every step succeeded and yet no knowledge arrived"
A healthy run reports an ok event, which blurter keeps silent unless it closes
out a problem it previously reported.
blurter send spools and exits, so reporting never blocks the ingest and never
fails it, and an alert raised while the machine is offline is still delivered
later. Repeat suppression, RECOVERED notices and sink fallback all live in
blurter — ytt deliberately implements none of it.
Use ytt ingest --dry-run to run discovery and the health checks and print the
queue without fetching transcripts, calling Claude, or reporting events.
ytt ingest shells out to yt-dlp, jq, and yq; the synopsis step
also runs claude (Claude Code CLI). The Homebrew formula declares
yt-dlp, jq, and yq as depends_on; install claude separately
via npm i -g @anthropic-ai/claude-code if you want synopses.
The bundled launchd plist pins both ytt and claude to absolute paths and
fails the scheduled run if any discovered video does not land, so scheduler
status reflects an incomplete ingest rather than a misleading successful exit.
- Internet access to YouTube (the underlying library scrapes YouTube's caption endpoints; YouTube occasionally changes these and breaks transcript fetching until the library catches up)
- Python 3.10+ only if installing from source; the Homebrew and GitHub-release downloads bundle their own interpreter
- For
ytt ingest:yt-dlp,jq,yqon PATH (auto-installed via Homebrew), plus optionallyclaude(npm) for synopsis generation
Apache 2.0 — see LICENSE.