# Yt Playlist Subs

> >-

- **Type:** Skill
- **Install:** `agentstack add skill-jjohnnystwsss-yt-playlist-subs-yt-playlist-subs`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [jjohnnystwsss](https://agentstack.voostack.com/s/jjohnnystwsss)
- **Installs:** 0
- **Category:** [Content & Media](https://agentstack.voostack.com/c/content-and-media)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [jjohnnystwsss](https://github.com/jjohnnystwsss)
- **Source:** https://github.com/jjohnnystwsss/yt-playlist-subs

## Install

```sh
agentstack add skill-jjohnnystwsss-yt-playlist-subs-yt-playlist-subs
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# YouTube playlist → subtitles / transcripts

Given a YouTube playlist URL (or a list of video URLs), get the cleanest available
transcript for each video and merge them into one episode-ordered, source-badged markdown.
This workflow was validated end-to-end on macOS (a 10-video playlist covering all four
situations → 10/10 transcribed).

繁體中文版流程見文末「中文流程」。

## Core principle (read this first)

Subtitle sources have a priority order, and **one video can carry several tracks** — pick
the best one:

1. **Creator's manual subtitles** — most accurate, preferred.
2. **Original-language auto (ASR) subtitles** — next best.
3. **Machine-translated subtitles** (codes shaped like `en-zh-TW`, i.e. "translate lang A
   into lang B") — usually noise, **avoid**.
4. **No captions at all** → download audio, transcribe with **Whisper**.
5. **Members-only videos** → need a member cookie, then the same logic.

**Language codes vary per video** (manual is often `zh-Hant`; auto may be `zh-TW` or
`zh-Hant-zh-Hant`), so fetch with a **loose matcher** to grab everything in one pass (e.g.
`zh-Hant.*,zh-TW` or `en.*`), then pick one track per episode at assembly time. `assemble.py`
does this and, with no `priority` configured, still avoids machine-translated tracks via a
language-neutral heuristic.

Don't hard-code a language. All domain settings (subtitle languages, Whisper prompt,
corrections dictionary, output paths) live in `config.json` — see `references/config.md`.

## Two points that need the user (stop and ask)

The cookie step is where this gets stuck. When you hit it, **prompt clearly and pause** —
don't try to brute-force it, and don't try to automate the export (it's a manual browser
action by design; automating it walks back into the rotation problem below):

- **Keychain prompt** (macOS, for decrypting Chrome cookies of public videos): the first run
  pops a Keychain dialog — ask the user to click Allow.
- **Member cookie export** (only if fetching members-only videos): ask the user to install
  the Chrome extension **"Get cookies.txt LOCALLY"** and export it themselves.
  ⚠️ This file is as sensitive as their login — **remind them to delete it when done.**

See step 1.

---

## One-time setup

```bash
./setup.sh            # creates ytenv (yt-dlp+curl_cffi) and whisperenv (faster-whisper, py3.11)
```

Or manually:

```bash
# yt-dlp with browser impersonation (Homebrew's build lacks curl_cffi)
python3 -m venv ytenv && ./ytenv/bin/pip install -r requirements.txt
./ytenv/bin/yt-dlp --list-impersonate-targets   # must list Chrome

# faster-whisper — its native deps often lack wheels on the newest Python, so use 3.11
python3.11 -m venv whisperenv && ./whisperenv/bin/pip install "faster-whisper>=1.0.0"
```

Work in a clean folder; the relative paths below (`subs/`, `audio/`, `whisper_out/`,
`titles.tsv`) are relative to it. Copy `references/config.example.json` (or
`config.zh-example.json`) to `config.json` and fill it in.

> Shortcut: `./run.sh "" [cookies.txt]` runs steps 0,2,3,4,6,7 for you. The
> steps below are for doing it by hand or understanding each stage.

---

## Workflow (seven steps)

### (0) Playlist video list → `titles.tsv`

```bash
./ytenv/bin/yt-dlp --flat-playlist --cookies cookies.txt \
  --print "%(playlist_index)03d\t%(id)s\t%(title)s" "" > titles.tsv
```

> ⚠️ The `\t` in `--print` is emitted as a **literal backslash-t**, so parse with
> `split /\\t|\t/` — the scripts already handle this.

### (1) Get a cookie — most common snag, follow exactly

**Public videos:**
```bash
# On macOS, fetch the key once (pops a Keychain prompt — ask the user to Allow)
security find-generic-password -ws "Chrome Safe Storage"
# Then export the cookie to a file immediately; reuse the file so the prompt doesn't recur
./ytenv/bin/yt-dlp --cookies-from-browser chrome --cookies cookies.txt \
  --skip-download --playlist-items 0 ""
```
- macOS often reports `cannot decrypt v10 cookies: no key found`; the line above is the fix.
- After exporting, use `--cookies cookies.txt` everywhere; don't keep using
  `--cookies-from-browser`.

**Members-only videos:** `--cookies-from-browser` reports `no longer valid / rotated`
(YouTube rotates `__Secure-3PSIDTS`; closing Chrome doesn't help). **Reliable fix:** the user
installs **"Get cookies.txt LOCALLY"** and exports `cookies.txt` from a youtube.com tab.
To avoid the export being rotated instantly, have them: open an **incognito window** →
log in → open a members-only video and confirm it plays → export **without closing the tab**
→ save the file → **then close the incognito window**. ⚠️ Sensitive file — remind them to delete it.

**Cookie troubleshooting (counter-intuitive — know these):**
- `account cookies no longer valid / rotated` is often a **soft warning** — member content
  still downloads. Don't trust the warning; **probe a real member video** instead.
- yt-dlp **rewrites** `cookies.txt` as it runs, so a newer mtime does **not** mean the user
  exported a fresh cookie. Verify by content or a real probe, not the timestamp.
- **Preflight before a big batch** (classify login + membership up front):
  ```bash
  scripts/cookie_check.sh cookies.txt  ./ytenv/bin/yt-dlp
  ```
  `Downloading subtitles` / `no subtitles` → cookie works (grab subs, else Whisper).
  `Join this channel` → cookie invalid or the account isn't a member — ask the user to
  re-export (or confirm membership) before continuing.

### (2) Batch-fetch subtitles (situations 1, 2)

```bash
./ytenv/bin/yt-dlp --skip-download --write-subs --write-auto-subs --ignore-errors \
  --sub-langs "" --sub-format "srt/vtt" --convert-subs srt \
  --cookies cookies.txt --impersonate chrome \
  --sleep-requests 1 --retries 8 --retry-sleep 12 \
  -o "subs/%(playlist_index)03d-%(id)s.%(ext)s" ""
```
- `--write-subs` (manual) + `--write-auto-subs` (auto) in one pass. `--sub-langs` comes from
  config's `sub_langs`.
- `--impersonate chrome` + cookie makes yt-dlp use the `web_creator` client, so the subtitle
  endpoint isn't 429'd. **Don't pin `--player-client`** — let it choose.

### (3) Classify each video (scan stderr / inspect subs/)

- Log shows `Join this channel` → **members-only** (note the id for step 5).
- An srt was written to `subs/` → **has captions**.
- `There are no subtitles` → **no captions** (Whisper in steps 4/6).

Collect caption-less ids into `nocap_urls.txt` (one `https://youtu.be/` per line).

### (4) No captions → download audio (situation 3)

```bash
./ytenv/bin/yt-dlp -f "bestaudio[ext=m4a]/bestaudio" --ignore-errors \
  --cookies cookies.txt --impersonate chrome --sleep-requests 1 --retries 8 \
  -o "audio/%(id)s.%(ext)s" -a nocap_urls.txt
```
> With an explicit URL list, `playlist_index` becomes `NA`, so name by `%(id)s` and map back
> to episode numbers via `titles.tsv`.

### (5) Members-only videos (situation 4)

Swap in the member cookie from step 1 and rerun step 2 for the member ids (output to
`member_subs/%(id)s.%(ext)s`); for any still caption-less, do steps 4 + 6.

### (6) Whisper-transcribe the caption-less videos

```bash
./whisperenv/bin/python scripts/whisper_transcribe.py --config config.json
```
Transcribes every file in `audio/` into `whisper_out/`, and:
- uses `initial_prompt` (domain terms) to cut errors, then applies the `corrections` dict;
- **VAD** for speed, but a very short clip can be filtered to empty → it **retries once with
  VAD off** if it gets 0 segments;
- **hallucination guard**: on silent / music-only audio Whisper invents text (looping a line,
  or spam like "please subscribe"); such outputs are flagged "no narration" and not used;
- resumable: already-done srt files are skipped.

### (7) Assemble → one markdown

```bash
python3 scripts/assemble.py --config config.json
```
Per episode picks by source priority (public official > member official > Whisper) → parses
the srt to clean text (strips indices / timestamps / tags / overlaps) → applies corrections →
merges into one episode-ordered markdown with source badges; anything missing is listed
separately. Output goes to config's `dest`.

---

## Gotchas (learned the hard way)

- **429 Too Many Requests (timedtext throttle):** dense requests get the subtitle endpoint
  temporarily blocked. Fix = `cookies + --impersonate chrome + --sleep-requests + --retries`
  — a background batch then runs with zero errors.
- **PO Token / JS challenge:** some player clients need a PO token; with a cookie, yt-dlp
  uses `web_creator` + deno to solve the JS challenge. Don't pin the player client; ensure
  `deno` is on PATH (Homebrew's yt-dlp bundles it).
- **`--convert-subs srt`:** vtt→srt uses yt-dlp's built-in converter; no ffmpeg needed.
- **Silent / no-narration clips:** nothing can extract speech that isn't there — flag them
  honestly as "no narration".

## Bundled resources

- `run.sh` — one-shot orchestrator for steps 0,2,3,4,6,7.
- `setup.sh` — creates both venvs, checks impersonation.
- `scripts/whisper_transcribe.py` — Whisper (config-driven + VAD fallback + hallucination guard).
- `scripts/assemble.py` — track selection + srt cleaning + markdown (config-driven).
- `scripts/cookie_check.sh` — cookie/membership preflight.
- `references/config.md` — every config field; `references/config.example.json` /
  `config.zh-example.json` — templates.

---

## 中文流程(摘要)

完整規格同上英文版。七步:(0) `--flat-playlist` 取 `titles.tsv`(注意 `\t` 是字面反斜線 t)→
(1) 取 cookie(公開:鑰匙圈授權後匯出檔;會員:用「Get cookies.txt LOCALLY」擴充、無痕視窗匯出、
匯完再關)→ (2) `--write-subs --write-auto-subs` 帶 `--impersonate chrome` 批次抓、寬鬆比對語言碼 →
(3) 分類(`Join this channel`=會員、有 srt=有字幕、`no subtitles`=無字幕)→ (4) 無字幕下載 `bestaudio` →
(5) 會員影片換會員 cookie 重跑 → (6) `whisper_transcribe.py` 轉錄(VAD 後備 + 幻覺偵測 + 校正字典)→
(7) `assemble.py` 依來源優先序挑軌、清理、合併成一份 markdown。

**cookie 疑難排解**:`rotated` 常是軟警告(用 `scripts/cookie_check.sh` 拿一支會員影片實測才準);
yt-dlp 會回寫 cookie 檔,mtime 變新不代表換新。互動點(鑰匙圈、會員 cookie 匯出)要停下等使用者,
用完提醒刪掉敏感 cookie 檔。所有語言/領域設定放 `config.json`,見 `references/config.md`。

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [jjohnnystwsss](https://github.com/jjohnnystwsss)
- **Source:** [jjohnnystwsss/yt-playlist-subs](https://github.com/jjohnnystwsss/yt-playlist-subs)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-jjohnnystwsss-yt-playlist-subs-yt-playlist-subs
- Seller: https://agentstack.voostack.com/s/jjohnnystwsss
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
