yt-dlp
Extract text and audio from YouTube, Vimeo, and hundreds of other sites yt-dlp supports. The core move: prefer captions and descriptions over audio transcription — most “TLDR this video” requests don’t need the audio at all.
Prerequisites
(command -v yt-dlp >/dev/null 2>&1 && yt-dlp --version 2>&1 | awk '{print "yt-dlp: " $0}') || echo "yt-dlp: NOT INSTALLED — see references/setup.md"
(command -v ffmpeg >/dev/null 2>&1 && ffmpeg -version 2>&1 | head -1 | awk '{print "ffmpeg: " $0}') || echo "ffmpeg: NOT INSTALLED — needed only for audio-transcription fallback; see references/setup.md"
If either tool is missing, read references/setup.md for install options. yt-dlp is required for every workflow below; ffmpeg is needed only when falling back to audio transcription.
Start cheap: captions + description in one call
For almost every “what does this video say / teach me about / TLDR” request, one yt-dlp invocation is enough. It pulls the creator’s description, chapter markers, and auto-generated captions without downloading the audio:
yt-dlp --skip-download \
--write-description --write-info-json \
--write-auto-sub --sub-lang en --sub-format vtt \
-o "/tmp/ytmeta-%(id)s" "<url>"
That writes (see ../../shared/output-conventions.md for the full /tmp naming table):
/tmp/ytmeta-<id>.description— video description/tmp/ytmeta-<id>.info.json— title, chapters, duration, uploader, thumbnails/tmp/ytmeta-<id>.en.vtt— auto-captions (when available)
URL-quoting gotcha: use plain double quotes, no backslash escaping of ? or = inside "...". Zsh does not strip backslashes inside double quotes, so a URL like "https://...watch\?v\=abc" passes through with literal backslashes; yt-dlp falls back to its generic extractor, typically lands on YouTube’s homepage, and downloads zero items.
Non-English videos: --sub-lang en only fetches English captions. For other languages, replace en with the appropriate ISO code (e.g. es, de, ja, pt-BR). If you’re not sure what’s available, list the options first:
yt-dlp --list-subs "<url>"
That prints two tables — “Available automatic captions” (auto-generated) and “Available subtitles” (creator-provided) — with language codes. Re-run the subs+description command above with the right --sub-lang code, or use --sub-lang <code>,en to try the native language with English as a fallback.
Escalate only as needed
Stop at whichever step is sufficient:
- Description alone. Read
/tmp/ytmeta-<id>.description. Creators often write summaries + timestamps in the description — for tech tutorials, product reviews, and long-form talks, this alone can carry a TLDR. - Info JSON for chapters. Read
/tmp/ytmeta-<id>.info.json— it hastitle,chapters(array of{start_time, title}),duration, anduploader. Chapters + title give you structure. - Caption file. Read
/tmp/ytmeta-<id>.en.vttwhen description/chapters aren’t enough. Auto-subs are rough (no punctuation, occasional mishears) but fine for “what did they say about X” Q&A. - Audio transcription fallback. Only when captions are unavailable, visibly low-quality (heavy accents, music, specialized jargon garbled), or the user explicitly asks for a high-accuracy transcript. See the next section.
Audio transcription fallback
Download the audio, then hand off to the macwhisper-cli skill:
yt-dlp -x --audio-format m4a -o "/tmp/yt-%(id)s.%(ext)s" "<url>"
yt-dlp pulls the best audio stream and uses ffmpeg internally (via -x) to produce /tmp/yt-<id>.m4a. From there, mw transcribe /tmp/yt-<id>.m4a > /tmp/mw-yt-<id>.txt — follow the output conventions.
If this (or any download) dies with HTTP Error 403: Forbidden on the actual data stream — often after a client finds a format then fails on the bytes — see the YouTube 403 / PO-token / DRM entry under Troubleshooting.
Transposed audio output (pitch shift)
For “give me this YouTube link transposed down a semitone / in a lower key” requests: download the audio as MP3, then pitch-shift with ffmpeg. Tempo and duration are preserved — only pitch changes.
yt-dlp -x --audio-format mp3 -o "/tmp/yt-%(id)s.%(ext)s" "<url>"
Compute the ratio for n semitones (negative = down): ratio = 2^(n/12).
| Semitones | −3 | −2 | −1 | +1 | +2 | +3 |
|---|---|---|---|---|---|---|
| Ratio | 0.8409 | 0.8909 | 0.9439 | 1.0595 | 1.1225 | 1.1892 |
Preferred filter is rubberband (best quality), but most Homebrew/apt ffmpeg builds are compiled without librubberband — check first:
ffmpeg -hide_banner -filters 2>/dev/null | grep rubberband
If rubberband is available:
ffmpeg -i /tmp/yt-<id>.mp3 -af "rubberband=pitch=<ratio>" -b:a 256k /tmp/yt-<id>-t<n>.mp3
Otherwise (the usual case), use asetrate + atempo — resample to shift pitch, then correct the tempo back:
ffmpeg -i /tmp/yt-<id>.mp3 \
-af "asetrate=44100*<ratio>,aresample=44100,atempo=1/<ratio>" \
-b:a 256k /tmp/yt-<id>-t<n>.mp3
Notes:
atempoaccepts 0.5–100, so1/<ratio>is fine for any musical transposition (±12 semitones and beyond).- If the source sample rate isn’t 44100, either works anyway — but for exactness read it with
ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of csv=p=0 <file>and substitute. - Each MP3 re-encode loses a little quality;
-b:a 256kkeeps it inaudible for most material. For lossless output, use.wav/.flacas the output extension and drop-b:a. - Deliver the result with its final name (e.g.
song-down2.mp3) — copy/rename out of/tmpif the user wants to keep it.
Direct audio/video URL (not a video-sharing site)
For direct URLs, pick a short <slug> (e.g., the URL’s basename without extension, or $(date +%s) as a fallback) so multiple direct-URL downloads in one session don’t clobber each other. See ../../shared/output-conventions.md for the naming table.
For a direct .m4a/.wav URL, yt-dlp isn’t needed — curl is enough:
curl -sL "<url>" -o /tmp/dl-<slug>.m4a \
&& mw transcribe /tmp/dl-<slug>.m4a > /tmp/mw-dl-<slug>.txt
For a format MacWhisper doesn’t accept (.webm, .mkv, raw video), download and let ffmpeg convert:
curl -sL "<url>" -o /tmp/dl-<slug>.webm \
&& ffmpeg -i /tmp/dl-<slug>.webm -vn -c:a aac /tmp/dl-<slug>.m4a \
&& mw transcribe /tmp/dl-<slug>.m4a > /tmp/mw-dl-<slug>.txt
Handling composed requests
“Teach me about this video — I want the TLDR, then I want to ask questions.” This is the canonical composed flow and it’s a single pipeline:
- Run the subs+description pull (subs-first route above).
- Read description and/or captions to produce the TLDR.
- Leave
/tmp/ytmeta-<id>.*in place. Follow-up Q&A reads the same files without re-fetching.
See ../../shared/output-conventions.md for the Read-on-demand / path-back defaults.
Important guidelines
-
Subs-first, transcription as fallback. Audio transcription is the expensive option; most URL requests never need it. See
../../shared/routing.mdfor the full decision matrix. -
Use
%(id)sin-otemplates./tmp/ytmeta-%(id)skeeps multiple videos from clobbering each other and makes cleanup scriptable (rm /tmp/ytmeta-abc123.*). -
Quote URLs plainly. Double quotes, no backslashes inside them. (See the gotcha under Start cheap above.)
-
Check
yt-dlp --helpfor anything unusual — the surface is huge. Common power-tools:--dateafter,--match-filter,--playlist-items,--cookies-from-browser.
Cleanup
rm /tmp/ytmeta-<id>.* /tmp/yt-<id>.m4a /tmp/mw-yt-<id>.txt
Or for everything this skill has produced in a session:
rm /tmp/ytmeta-* /tmp/yt-*.m4a /tmp/yt-*.mp3 /tmp/dl-* /tmp/mw-dl-*.txt
Explicit cleanup is optional (macOS cleans /tmp periodically), but worth running for sensitive content.
Troubleshooting
- “NOT INSTALLED” on yt-dlp or ffmpeg — See references/setup.md.
- yt-dlp downloads the wrong thing (homepage, playlists, zero items) — URL-quoting gotcha: check the URL hasn’t been passed with literal backslash-escaped
?or=inside double quotes. - Captions are empty or missing — Either the video has no auto-subs (creator disabled them) or they exist in a language other than English. Run
yt-dlp --list-subs "<url>"to see what’s available; if nothing shows up, fall back to audio transcription. yt-dlp -xerrors about ffmpeg — ffmpeg isn’t on PATH. Install it (see references/setup.md) before retrying.- Very long download on a short video — yt-dlp may be choosing a high-bitrate format. For audio-only transcription,
-x --audio-format m4akeeps it small. For subs-only runs,--skip-downloadavoids audio entirely. - YouTube
HTTP Error 403: Forbiddenon the data stream (PO-token / DRM / SABR wall) — The extractor negotiates a format fine, then 403s on the actual bytes. Recognize it by any of these tells:HTTP Error 403: Forbiddenon the download (not the metadata fetch);- a client finds a format (e.g. format 251) and then 403s on the stream;
- warnings naming a
GVS PO Token(... require a GVS PO Token ...),DRM protectedformats, or aSABR-only streaming experiment.
YouTube gates playback behind rotating proof-of-origin tokens; individual player clients each fail a different way (
android_vr403s on the bytes,ios/mweb/tv_simplydemand a PO token,tvreports DRM). Fix it with this ladder — in order, and stop at the step that works:-
Update yt-dlp first. These breakages are patched constantly, so a stale binary (older than ~90 days) is the single most common root cause. Update by however it was installed — check the shebang of
$(which yt-dlp)if unsure:brew upgrade yt-dlp # Homebrew pipx upgrade yt-dlp # pipx pip install -U yt-dlp # pip -
Retry with every player client at once:
yt-dlp --extractor-args "youtube:player_client=all" <your-other-args> "<url>"This one retry is the whole move:
player_client=alltries every client (android_vr,ios,tv,mweb, web) in a single pass, so it already covers the ground you’d otherwise walk client by client — and a web client mints the required GVS PO token via yt-dlp’s bundled JS (deno) solver. Because the 403 here is a missing PO token rather than an expired login, cookies are the wrong lever:--cookies-from-browserleaves it at 403 even with fresh cookies. Reach for that flag only when the video is genuinely age- or login-gated.