Imported from iwan-uschka/alfred-workflow-youtube-transcript (
AGENTS.md). Install upstream withnpx skills add iwan-uschka/alfred-workflow-youtube-transcript. Copyright stays with the author.
AGENTS.md
Guidance for coding agents working on this repo.
What this is
An Alfred 5 workflow that copies the transcript of a YouTube video to the
clipboard. The transcript is in the video's original language, unless you
pass a language code. The video comes from the URL passed to the keyword, or
from the frontmost browser tab. By default the output is plain text with one
caption line per line; raw copies the untouched VTT instead. One Bash
script does the work, a Perl filter does the VTT → text step, and
info.plist wires them into Alfred.
ytt [url] [lang] [raw] → Keyword input → Run Script (youtube-transcript.sh "$1") → Notification
Flow
scripts/youtube-transcript.sh:
- Add the Homebrew dirs to PATH, set
LANG, and check foryt-dlpandjq. - Split
$1on whitespace, in any order.rawsets raw mode. A token matching^[a-z]{2,3}(-[A-Za-z0-9]+)*$is the language (de,pt-BR,zh-Hans). Any other token is the URL. That pattern allows only letters, digits and hyphens, and every URL has a.or/, so the two can't be confused. Two languages or two URLs is an error. - With no URL, read the frontmost app's bundle id with
lsappinfo, get the tab URL withosascript, and choose the--cookies-from-browsername. - Reject anything that isn't a YouTube video URL (watch, shorts, live,
youtu.be, and the
m.andmusic.hosts). yt-dlp -J --skip-download --no-playlistwritesinfo.json.- Language: with none given, L is
.language. If that's null, L comes from the auto-caption key ending in-orig, minus the suffix. If both fail, it's an error asking forytt enor similar. - Track: jq picks manual
L, then the first manualL-*alphabetically, then autoL-orig, autoL, and the first autoL-*. Only tracks that offer avttformat count. Matching ignores case, sopt-brfindspt-BR. - Regional L: if a regional L like
de-DEmatches nothing, the same order runs again for its base languagede. - Nothing found: the error names L and lists the base languages
available, deduped and capped at 8 each. For auto tracks it lists only
the
-origkeys, because the other auto keys are the ~100 translation targets YouTube offers for every video.
- Language: with none given, L is
yt-dlp --load-info-json info.json --skip-download --write-subs|--write-auto-subs --sub-langs <key> --sub-format vtt -o "<tmp>/%(id)s.%(ext)s"writes<tmp>/<id>.<key>.vtt. yt-dlp puts the language before the extension. This was checked against real yt-dlp 2026.08.19.- Raw mode copies the file as-is. Otherwise it goes through
scripts/vtt-to-text.pl. The result goes topbcopy, and stdout getsCopied N lines · <key> (manual|auto) · <title>, which becomes the notification.
Non-obvious decisions
- Track selection comes from
-Joutput, not one combined yt-dlp call.--write-subs --write-auto-subs --sub-langs Lcan't find a video whose only manual track is regional (en-US,de-DE-<id>), and it doesn't say whether you got the manual or the auto track. Picking the key frominfo.jsonmakes the choice explicit and testable.--load-info-jsonthen reuses the metadata, so the page is only resolved once. - The default is the original language, not English, because auto
enon a non-English video is a machine translation. Measured on a German video:automatic_captionshadde,de-DE,de-origanden, but noen-orig, and theenURL carriedtlang=en. Those translated tracks are also the ones that got HTTP 429 from YouTube: the first try failed, the next succeeded, and later runs failed again after many probes in a row.ytt enstill asks for one explicitly. The download step retries once afterYTT_RETRY_DELAYseconds (default 3), but only when yt-dlp's error mentions 429. If that also fails, the notification shows yt-dlp's own error line. Retrying more would just hammer YouTube. .languagecan be regional. The same German video reported.languageasde-DE, and its only manual track wasde-DE-v329N2ay57Q, which theL-*rule finds. That's why a regional L that matches nothing falls back to its base language. The notification shows the track key as-is, so that video reads· de-DE-v329N2ay57Q (manual) ·.--no-playlist. A tab opened from a playlist has&list=in its URL. Without the flag, yt-dlp resolves the whole playlist.lsappinfoinstead of System Events to find the frontmost app. Asking System Events needs its own Automation permission.lsappinfoneeds none, so the only prompt the user sees is the one for the browser.- Browsers. Safari uses
current tab, and the Chromium family (Chrome, Brave, Edge, Vivaldi, Arc) usesactive tab. All are addressed by bundle id (tell application id …), so app display names don't matter. Arc gets the tab URL but no cookies, because yt-dlp's--cookies-from-browserdoesn't support Arc. Firefox has no AppleScript tab access, so it gets an error that points atytt <url>. - Cookie fallback. Cookies only apply when the URL comes from the
browser. They help with age-gated or members-only captions. If a yt-dlp
call with cookies fails and its error names the cookie store (matched
case-insensitively against
cookie|keychain|keyring|safe storage), it runs again once without them, and cookies stay off for the rest of the run. Common causes are Alfred lacking Full Disk Access for Safari's cookie file and a denied Chrome keychain prompt. Public videos shouldn't fail over a cookie read. Any other failure — a 429, a network blip — is reported as-is rather than retried bare, which would only swap yt-dlp's real error for a misleading one and drop the cookies a gated video needs. - PATH is appended to, not prepended. Alfred runs scripts with
/usr/bin:/bin:/usr/sbin:/sbin, so/opt/homebrew/binand/usr/local/binare added when missing. Appending keeps whatever the caller put first in front, which lets the tests shadowyt-dlpwith a stub.YTT_BREW_PATHS(space-separated) overrides the list, so a test can simulate a missing tool. LANG=en_US.UTF-8. Alfred's environment has no locale. Without one,pbcopyreads UTF-8 input as MacRoman and mangles every non-ASCII character.- Rolling dedupe (
vtt-to-text.pl). In auto captions, each cue repeats the previous line above the new one, with a 10ms cue in between that repeats it again. The filter prints a line only when it differs from the last printed line, and it skips blank lines before that comparison, so the" "spacer lines inside rolling cues don't reset it. Blocks split only on truly empty lines, because a line holding one space still belongs to its cue. Entities decode in one pass, so&lt;becomes<and not<. On real data, a 172-cue auto track came out as 85 lines with no consecutive duplicates. As a side effect, a manual track that repeats a line on purpose ("No." / "No.") also collapses to one line. That's accepted. - Errors go to stdout and stderr as
ERROR: …, because Alfred's notification only shows stdout. Same pattern as the siblingalfred-workflow-copy-rendered-markdown. - Bash 3.2. Alfred runs
/bin/bash, so the script uses nomapfileand no associative arrays, and it guards expansion of a possibly-empty array underset -u.
Conventions
- Keep logic in
scripts/.info.plistonly calls./scripts/youtube-transcript.sh "$1". - The script can be sourced:
mainruns only when the file is executed, so tests can callis_youtube_urland friends directly. - Lint:
shellcheck -x scripts/*.sh test/*.sh build.sh make_release.sh. - After editing
info.plist:plutil -lint info.plist. - Tests:
bash test/vtt-to-text.test.sh,bash test/youtube-transcript.test.sh,bash test/build.test.shandbash test/make_release.test.sh. The build and release tests stage a copy of the packaged files in a temp dir — the release tests in a throwaway git repo with a local bareorigin— so they never write to the repo'sdist/and never reach the network. The flow tests put stubs foryt-dlp,lsappinfoandosascriptfirst on PATH. The yt-dlp stub logs each call's argv to a file, so tests assert on cookie flags and track keys. jq and perl are the real ones.YTT_CLIPBOARD_CMDswapspbcopyfor a stub, so tests never touch the real clipboard. It's also handy by hand:YTT_CLIPBOARD_CMD=cat scripts/youtube-transcript.sh "<url>". - The flow tests read the Run Script action's
escapingbitmask out ofinfo.plist, apply those escaping rules to a multi-token query themselves, and then run the action's own command line, so raising the bitmask back to one that escapes spaces fails the suite instead of only showing up in Alfred. - Nothing automated covers the real browser path, meaning the AppleScript
against a live browser and the Automation and keychain prompts. Check that
by hand in Alfred after changing
resolve_front_url. - Repackage with
./build.sh(→dist/Youtube-Transcript.alfredworkflow). - Release with
bash make_release.sh x.y.z. It bumpsinfo.plist's version, builds the versioned artifact plus checksum, and prints the commit/push/gh release createcommands. It never commits or publishes on its own.
Files
scripts/youtube-transcript.sh: the flow above. Every error exits 1 with anERROR:line.scripts/vtt-to-text.pl: VTT → text filter, stdin → stdout, core Perl only.test/youtube-transcript.test.sh: stubbed flow tests.test/vtt-to-text.test.sh: fixture tests. Eachtest/fixtures/<name>.vttmust convert to exactly<name>.expected.txt.test/build.test.sh: packaging tests forbuild.sh— archive contents with and withouticon.png, and the missing-zip/unziperrors.test/make_release.test.sh: guard and rollback tests formake_release.sh— version format, dirty tree, local and remote tag, unreachableorigin, and theinfo.plistrestore when a later step fails.test/fixtures/auto-rolling.vtt: a real rolling auto-caption excerpt. Its first cue's spacer line is a single space, not an empty line — don't let an editor strip it;test/vtt-to-text.test.shasserts that byte is still there.manual.vttis invented and covers cue ids, NOTE/STYLE blocks, entities,<i>/<v>tags, and a bare number that is caption text, not a cue id.empty.vttis a header-only file that flattens to nothing, which is how the flow tests reach the "captions are empty" error.info.plist: Alfred workflow definition.build.sh: zips the workflow intodist/. It addsicon.pngif one exists; there is none yet.make_release.sh: version bump plus release artifact.