ytdl — browser-native YouTube downloader
A thin browser-harness-js heredoc, exactly like gsearch/xsearch. There is
no Bun program, no vendored solver, no HTTP client impersonation. Every
hard thing YouTube does to play a video — cookies, poToken, the n-signature,
the SABR multiplex, adaptive-bitrate selection — the page already does for
playback. ytdl just records the result.
ytdl "https://www.youtube.com/watch?v=..." # best quality → ~/Downloads
ytdl "https://www.youtube.com/shorts/<id>" # Shorts — normalized to watch?v=
ytdl "https://www.youtube.com/watch?v=..." -q 360p # 360p
ytdl "https://www.youtube.com/watch?v=..." -q 1080p # 1080p (ffmpeg mux)
ytdl "https://www.youtube.com/watch?v=..." -q audio # audio only
ytdl "https://www.youtube.com/watch?v=..." --info # title / duration / qualities
ytdl "https://www.youtube.com/watch?v=..." -o Name -d ~/Videos
Quality targets
-q |
what happens |
needs ffmpeg? |
360p / 480p / 720p / 1080p / 1440p / 2160p |
force that player quality, capture, mux |
yes |
best |
let the player pick (highest it offers) |
yes |
audio |
force the smallest video, keep only the audio buffer |
no |
ffmpeg is a muxer only here — the player delivers video and audio as
separate ISO-BMFF (fragmented-MP4) streams, so they need combining into one
file. It never re-encodes (-c copy); the output stays pure (lossless) at
YouTube's own codecs. If you need a QuickTime/iOS-friendly file, re-encode the
output yourself in one step — see Traps.
How it works
All inside one browser-harness-js <<EOF heredoc, gsearch-style:
- Connect to the browser's shared CDP session (or
session.connect()).
Page.addScriptToEvaluateOnNewDocument — inject the MSE hook before
any page JS runs:
- Wrap
MediaSource.prototype.addSourceBuffer (via Object.defineProperty)
so each SourceBuffer is tagged with its mime (video/mp4 vs audio/mp4).
- On each SourceBuffer, define an OWN
appendBuffer property that records
the bytes (the demuxed ISO-BMFF fragment) before calling the original.
An own property is required — SourceBuffer.prototype.appendBuffer is a
non-writable native method, so reassigning the prototype silently no-ops.
Target.createTarget foreground → Page.navigate to the watch URL →
wait networkIdle, then poll for #movie_player + <video>. Foreground is
required: background tabs have flaky autoplay, and if the player never
starts, MediaSource is never fed and nothing is captured.
- Disable autonav, then force quality (
player.setPlaybackQualityRange(q,q),
best-effort) and play muted at 16× so the player fetches+appends every
segment fast. Autonav is turned off by clicking .ytp-autonav-toggle (state
read from aria-label / data-tooltip-title: "Autoplay is on" → "... off")
BEFORE play, so the player can never autoplay into the next video — the real
fix for the background-tab case, since even a throttled tab that never
latches has no next-video to run away to. The player's own SABR client
requests the whole timeline as it plays through. This is the whole model:
view the video, sped up.
- Wait until the whole timeline is buffered (
buffered.end >= duration-0.5;
the player buffers ahead), then freeze capture + pause atomically in one
page-side call. We break the FIRST time the timeline is full and never
re-check, so autoplay advancing to the next video doesn't matter — we've
already stopped. A __capDone flag makes the hook pass appends through (stop
recording) the instant coverage completes, so nothing after this enters the
captured buffers. The same poll re-asserts muted + playbackRate=16 each
tick — the player can clobber them on a quality switch, ad, or re-init,
which would un-mute the 16× audio mid-capture.
- Drain captured buffers to disk DURING playback (interleaved with the
coverage poll, ~every 1 s). Page-side
__drainNew(i,maxBytes) returns up to
maxBytes of the bytes appended since the last call, advancing a per-buffer
(_chunkIdx,_chunkOff) cursor, base64-encoded; the REPL decodes with
Buffer.from(b64,'base64') and fs.appendFileSyncs each slice to a per-buffer
temp file. __drainNew never emits more than maxBytes even when a single
fmp4 segment exceeds it — it slices a too-big chunk across two drains (a 1440p
segment is often 1–4 MB), which is the fix for the crash that dropped the
socket. By latch-time nearly all the media is already on disk, so the
post-pause pass pulls only the tail and the tab closes right after. Pick the
largest drained video + largest audio file (the player can create more than
one MediaSource on a quality switch; the small init-segment duplicates get
unlinked) and hand their paths to ffmpeg.
- ffmpeg
-i video -i audio -c copy -movflags +faststart out.mp4 (bash,
after the heredoc returns the temp-file paths).
closeTab in try/finally, fire-and-forget — exact gsearch/xsearch
teardown.
Why capture at MediaSource, not googlevideo
The logged-in web client now plays everything via SABR
(application/vnd.yt-ump), a protobuf multiplex — not discrete itag URLs with
HTTP ranges. Adding &range= to a SABR URL returns a 31-byte
sabr.malformed_config error; there's no media-bytes URL to range-download at
the playback layer. (yt-dlp sidesteps this by impersonating a SABR-free
android_vr client — which is the client-impersonation scaffolding this skill
deliberately drops.)
The one place the demuxed media exists in the clear is
SourceBuffer.appendBuffer — the ISO-BMFF fragments the player hands to
<video> for playback. Capturing there is SABR-agnostic, auth-agnostic, and
gating-agnostic: the page did all of it; we record the output. This is
literally "watch it → record it."
Files
All paths relative to <skill-dir>.
scripts/ytdl — the bash CLI (a browser-harness-js heredoc, no #!bun)
scripts/setup — symlink ytdl + browser-harness-js onto PATH
- (no
lib/ — the whole solver/client-table/download scaffolding is gone)
Traps
- The MSE hook must inject via
Page.addScriptToEvaluateOnNewDocument, not a post-load Runtime.evaluate. The player grabs MediaSource/addSourceBuffer references while it boots; injecting before any page JS runs is the only way to patch them in time. The hook runs in the main world (no worldName), where the player lives.
- Patch
appendBuffer as an OWN property on each SourceBuffer instance, never on the prototype. SourceBuffer.prototype.appendBuffer is a non-writable native method — sb.appendBuffer = fn on the prototype silently no-ops; Object.defineProperty(SourceBuffer.prototype, 'appendBuffer', …) fails to take effect. Defining an own property on the instance shadows the prototype method correctly. (addSourceBuffer can be patched on the prototype via defineProperty — it's writable.)
- Use a foreground tab. Background-tab autoplay is unreliable; if the player doesn't start, MediaSource is never fed and capture is empty.
createTarget({ url: 'about:blank' }) (no background: true). Autonav is also disabled before play (see below) so a backgrounded/throttled tab can't run away to the next video.
- Disable autonav before play, not after. YouTube's
.ytp-autonav-toggle controls autoplay-next; click it OFF (state is aria-label/data-tooltip-title = "Autoplay is on" vs "... off") BEFORE calling play(). This is the real fix for the background-tab case: even if the tab is throttled and the coverage poll never latches, there is no next-video autoplay to run away to. Don't try to defeat autoplay by reacting to ended/timeupdate — by then the next video has already loaded.
- Coverage is driven by playback, not by range requests. The player only appends segments it plays, so ytdl plays through at 16× and waits for the whole timeline to be buffered (
buffered.end >= duration-0.5). Don't try to harvest a URL and range-fetch — see "Why capture at MediaSource." For very long videos this is bounded by real-time/16; a 1h video takes ~4 min of 16× playback.
- The coverage loop must latch, not re-check. We break the FIRST time the timeline is fully buffered and never re-evaluate — so YouTube autoplaying into the next video (which resets
currentTime) doesn't matter: capture is already frozen. Don't rewrite the loop to poll currentTime ≈ duration every tick; autoplay will reset currentTime to ~0 and the check goes false again, spinning until a watchdog. The __capDone freeze-on-latch keeps the next video's segments out of the buffers.
- A fragmented-webm capture may log
[matroska,webm] File ended prematurely from ffmpeg — it's benign. MSE-captured opus/vp9 segments end mid-EBML-element (there's no graceful close on a live capture). ffmpeg still muxes the full duration correctly (decode-tested, exit 0); the warning is about the input container's framing, not missing data. mp4-captured (AV1/H264) buffers don't hit this.
- Quality forcing is best-effort.
player.setPlaybackQualityRange(q,q) is honored on most content, but SABR manages bitrate server-side and may ignore it. --info shows getAvailableQualityLevels(); the returned JSON includes actualQuality so you can see what was really played. If the forced quality wasn't honored, the capture is whatever the player chose.
--info opens the watch page (one page load) to read title/duration/qualities — it's not free, but it's a normal watch-page hit, not a probe storm.
- Bytes cross the CDP boundary as base64 in 256 KB (3-aligned) slices via
Runtime.evaluate returnByValue, decoded with Buffer.from(b64,'base64') and appended. The slice size is divisible by 3 so each slice's base64 is independently decodable (no interior = padding). Don't slice at a non-multiple-of-3 offset or the concatenation decodes to garbage.
- Drain captured buffers to disk DURING playback, not after, and keep each CDP response SMALL.
__drainNew(i,maxBytes) is called on a ~1 s cadence inside the coverage loop — a page-side (_chunkIdx,_chunkOff) cursor tracks bytes already flushed, so each call returns only what was appended since the last, base64-encoded, and the REPL appends to a per-buffer temp file. By latch-time nearly all the media is on disk; the post-pause pass pulls only the tail, so the tab closes immediately at the latch signal (closeTab is fire-and-forget in finally, so it can't fire until the snippet returns — draining during the loop keeps that return fast). Doing the whole pull after pausing blocks the return and left the tab open for the entire multi-MB drain — this was the "tab stays open ~30 s after the video stops" symptom. The bytes live in page memory and the tab can't close until they're off, so closing at latch requires draining during playback.
__drainNew must never emit more than maxBytes, even for a multi-MB segment. A 1440p fmp4 segment is often 1–4 MB, far over the 256 KB slice cap. The original drain had a if (to===from) force one whole chunk through branch that emitted the whole segment in one CDP frame — that single multi-MB returnByValue closes the debug socket (1006) on Dia. The fix slices a too-big chunk across two drains: this call returns its head and leaves _chunkOff pointing into it for the next call. Never reintroduce the force-one-chunk path.
- In
drainOne, append the slice BEFORE testing done. __drainNew returns done:true on the same call that yields the final bytes (the cursor reaches the end mid-call), so if (s.done) return before appending silently drops that last slice and truncates/corrupts the muxed file (ffmpeg then logs EBML exceeds max length / Invalid data). Always append s.b64 first, then check s.done.
- Never send a 4 MB
returnByValue frame. A single 4 MB base64 response (~5.6 MB JSON in one CDP frame) closes the debug WebSocket on some browsers (reproduced on Dia: the socket drops rs→3 on the very first 4 MB slice, while a 256 KB slice survives). This masqueraded as a "capture stops before the video finishes" failure because the post-pause pull — which used 4 MB slices — killed the socket immediately. The slice size is now 256 KB (262143, 3-aligned); keep it small. __drainNew enforces it per call (slicing big chunks across drains); __pullBuffer (absolute-offset slice pull) is retained in the hook as a verified reference/fallback but is not on the hot path — the drain + final tail both use __drainNew.
- Output container follows the video mime.
video/mp4 → .mp4; video/webm → .webm. ffmpeg is invoked with -c copy, so a webm video is muxed to .webm. If codecs mismatch the container, ffmpeg copy will fail — that's a codec/container issue, not a capture issue.
- The pure output isn't QuickTime/iOS-friendly. YouTube typically serves AV1 video + Opus audio;
-c copy preserves those (VLC/browsers play them fine), but QuickTime / AVFoundation / iOS refuse them (no AV1-in-MP4 decoder, no Opus-at-all). The skill stays pure on purpose — lossless stream-copy, no size/quality penalty (a re-encode quadrupled the size and is lossy in testing). If you need QuickTime/iOS, re-encode the output yourself in one step: ffmpeg -y -i "out.mp4" -c:v libx264 -crf 18 -preset veryfast -pix_fmt yuv420p -c:a aac -b:a 192k "out-qt.mp4".
- Re-assert
muted + playbackRate every poll tick. YouTube's player resets muted/playbackRate on a quality switch, ad, or player re-init; a one-shot set at play-start gets clobbered and you'll hear the 16× chipmunk audio. The coverage poll re-asserts both each 250 ms — keep that if you touch the loop.
- Reset
currentTime to 0 at play-start. A signed-in account restores the last watch position ("resume from where you left off") on a bare watch?v=ID URL with no t= — if the account previously watched part of the video, the player starts partway through and the MSE capture only covers from the resume point onward (the buffered.end >= dur-0.5 check still passes, since the player buffers ahead to the end). extract_id already strips any t=/start= URL param, but resume position is account-side, not URL-side, so the play-start nudge also forces v.currentTime = 0 after metadata loads. Keep it — without it, resumed videos download missing their beginning.
- ffmpeg runs with
-y (overwrite). Re-running the same video overwrites the prior output instead of hitting ffmpeg's interactive Overwrite? [y/N] prompt, which fails non-interactively and silently leaves a stale file. Don't drop -y.
- YouTube Shorts (
/shorts/<id>) are normalized to watch?v=<id>. A Short is just a video; the Shorts page is a different UI shell around the same #movie_player, so extract_id pulls the 11-char id from youtube.com/shorts/<id> and the rest of the pipeline captures it in the regular watch player — the autonav toggle, quality forcing, and MSE-hook selectors all target the watch page. Don't try to drive the Shorts shell directly: its swipe-to-next autonav is a different control (.ytp-autonav-toggle is absent) and its layout hides the controls ytdl relies on.
- Live / Premiere uses HLS, not MediaSource segments — not supported by this skill.
1---2name: ytdl3description: Download YouTube videos browser-natively — no `yt-dlp`, no client impersonation, no n-signature solver. The browser plays the video (auth, poToken, n-solving, SABR demux all done by the page itself); ytdl records the demuxed media the player feeds to MediaSource via a `SourceBuffer.appendBuffer` hook and muxes to MP4 with ffmpeg. If the user can watch the video, it's downloadable — made-for-kids, age-gated, and members-only work as long as a logged-in tab can play them. Use when the user wants to download, save, or fetch a YouTube video (audio or video) to disk.4---56# ytdl — browser-native YouTube downloader78A thin `browser-harness-js` heredoc, exactly like `gsearch`/`xsearch`. There is9**no Bun program, no vendored solver, no HTTP client impersonation**. Every10hard thing YouTube does to play a video — cookies, poToken, the n-signature,11the SABR multiplex, adaptive-bitrate selection — the page already does for12playback. ytdl just records the result.1314```bash15ytdl "https://www.youtube.com/watch?v=..." # best quality → ~/Downloads16ytdl "https://www.youtube.com/shorts/<id>" # Shorts — normalized to watch?v=17ytdl "https://www.youtube.com/watch?v=..." -q 360p # 360p18ytdl "https://www.youtube.com/watch?v=..." -q 1080p # 1080p (ffmpeg mux)19ytdl "https://www.youtube.com/watch?v=..." -q audio # audio only20ytdl "https://www.youtube.com/watch?v=..." --info # title / duration / qualities21ytdl "https://www.youtube.com/watch?v=..." -o Name -d ~/Videos22```2324## Quality targets2526| `-q` | what happens | needs ffmpeg? |27|----------|--------------|---------------|28| `360p` / `480p` / `720p` / `1080p` / `1440p` / `2160p` | force that player quality, capture, mux | yes |29| `best` | let the player pick (highest it offers) | yes |30| `audio` | force the smallest video, keep only the audio buffer | no |3132ffmpeg is a **muxer only** here — the player delivers video and audio as33separate ISO-BMFF (fragmented-MP4) streams, so they need combining into one34file. It never re-encodes (`-c copy`); the output stays pure (lossless) at35YouTube's own codecs. If you need a QuickTime/iOS-friendly file, re-encode the36output yourself in one step — see Traps.3738## How it works3940All inside one `browser-harness-js <<EOF` heredoc, gsearch-style:41421. **Connect** to the browser's shared CDP session (or `session.connect()`).432. **`Page.addScriptToEvaluateOnNewDocument`** — inject the MSE hook *before*44 any page JS runs:45 - Wrap `MediaSource.prototype.addSourceBuffer` (via `Object.defineProperty`)46 so each `SourceBuffer` is tagged with its mime (video/mp4 vs audio/mp4).47 - On each SourceBuffer, define an OWN `appendBuffer` property that records48 the bytes (the demuxed ISO-BMFF fragment) before calling the original.49 An own property is required — `SourceBuffer.prototype.appendBuffer` is a50 non-writable native method, so reassigning the prototype silently no-ops.513. **`Target.createTarget` foreground** → `Page.navigate` to the watch URL →52 wait `networkIdle`, then poll for `#movie_player` + `<video>`. Foreground is53 required: background tabs have flaky autoplay, and if the player never54 starts, MediaSource is never fed and nothing is captured.554. **Disable autonav**, then **force quality** (`player.setPlaybackQualityRange(q,q)`,56 best-effort) and **play muted at 16×** so the player fetches+appends every57 segment fast. Autonav is turned off by clicking `.ytp-autonav-toggle` (state58 read from `aria-label` / `data-tooltip-title`: "Autoplay is on" → "... off")59 BEFORE play, so the player can never autoplay into the next video — the real60 fix for the background-tab case, since even a throttled tab that never61 latches has no next-video to run away to. The player's own SABR client62 requests the whole timeline as it plays through. This is the whole model:63 view the video, sped up.645. **Wait until the whole timeline is buffered** (`buffered.end >= duration-0.5`;65 the player buffers ahead), then **freeze capture + pause atomically** in one66 page-side call. We break the FIRST time the timeline is full and never67 re-check, so autoplay advancing to the next video doesn't matter — we've68 already stopped. A `__capDone` flag makes the hook pass appends through (stop69 recording) the instant coverage completes, so nothing after this enters the70 captured buffers. The same poll **re-asserts `muted` + `playbackRate=16` each71 tick** — the player can clobber them on a quality switch, ad, or re-init,72 which would un-mute the 16× audio mid-capture.736. **Drain captured buffers to disk DURING playback** (interleaved with the74 coverage poll, ~every 1 s). Page-side `__drainNew(i,maxBytes)` returns up to75 `maxBytes` of the bytes appended since the last call, advancing a per-buffer76 `(_chunkIdx,_chunkOff)` cursor, base64-encoded; the REPL decodes with77 `Buffer.from(b64,'base64')` and `fs.appendFileSync`s each slice to a per-buffer78 temp file. `__drainNew` never emits more than `maxBytes` even when a single79 fmp4 segment exceeds it — it slices a too-big chunk across two drains (a 1440p80 segment is often 1–4 MB), which is the fix for the crash that dropped the81 socket. By latch-time nearly all the media is already on disk, so the82 post-pause pass pulls only the tail and the tab closes right after. **Pick the83 largest drained video + largest audio file** (the player can create more than84 one MediaSource on a quality switch; the small init-segment duplicates get85 unlinked) and hand their paths to ffmpeg.867. **ffmpeg** `-i video -i audio -c copy -movflags +faststart out.mp4` (bash,87 after the heredoc returns the temp-file paths).888. **`closeTab`** in `try/finally`, fire-and-forget — exact gsearch/xsearch89 teardown.9091## Why capture at MediaSource, not googlevideo9293The logged-in web client now plays everything via **SABR**94(`application/vnd.yt-ump`), a protobuf multiplex — not discrete itag URLs with95HTTP ranges. Adding `&range=` to a SABR URL returns a 31-byte96`sabr.malformed_config` error; there's no media-bytes URL to range-download at97the playback layer. (yt-dlp sidesteps this by impersonating a SABR-*free*98`android_vr` client — which is the client-impersonation scaffolding this skill99deliberately drops.)100101The one place the demuxed media exists in the clear is102`SourceBuffer.appendBuffer` — the ISO-BMFF fragments the player hands to103`<video>` for playback. Capturing there is SABR-agnostic, auth-agnostic, and104gating-agnostic: the page did all of it; we record the output. This is105literally "watch it → record it."106107## Files108109All paths relative to `<skill-dir>`.110111- `scripts/ytdl` — the bash CLI (a `browser-harness-js` heredoc, no `#!bun`)112- `scripts/setup` — symlink `ytdl` + `browser-harness-js` onto PATH113- (no `lib/` — the whole solver/client-table/download scaffolding is gone)114115## Traps116117- **The MSE hook must inject via `Page.addScriptToEvaluateOnNewDocument`, not a post-load `Runtime.evaluate`.** The player grabs `MediaSource`/`addSourceBuffer` references while it boots; injecting before any page JS runs is the only way to patch them in time. The hook runs in the main world (no `worldName`), where the player lives.118- **Patch `appendBuffer` as an OWN property on each `SourceBuffer` instance, never on the prototype.** `SourceBuffer.prototype.appendBuffer` is a non-writable native method — `sb.appendBuffer = fn` on the prototype silently no-ops; `Object.defineProperty(SourceBuffer.prototype, 'appendBuffer', …)` fails to take effect. Defining an own property on the instance shadows the prototype method correctly. (`addSourceBuffer` *can* be patched on the prototype via `defineProperty` — it's writable.)119- **Use a foreground tab.** Background-tab autoplay is unreliable; if the player doesn't start, MediaSource is never fed and capture is empty. `createTarget({ url: 'about:blank' })` (no `background: true`). Autonav is also disabled before play (see below) so a backgrounded/throttled tab can't run away to the next video.120- **Disable autonav before play, not after.** YouTube's `.ytp-autonav-toggle` controls autoplay-next; click it OFF (state is `aria-label`/`data-tooltip-title` = "Autoplay is on" vs "... off") BEFORE calling `play()`. This is the real fix for the background-tab case: even if the tab is throttled and the coverage poll never latches, there is no next-video autoplay to run away to. Don't try to defeat autoplay by reacting to `ended`/`timeupdate` — by then the next video has already loaded.121- **Coverage is driven by playback, not by range requests.** The player only appends segments it plays, so ytdl plays through at 16× and waits for the whole timeline to be buffered (`buffered.end >= duration-0.5`). Don't try to harvest a URL and range-fetch — see "Why capture at MediaSource." For very long videos this is bounded by real-time/16; a 1h video takes ~4 min of 16× playback.122- **The coverage loop must latch, not re-check.** We break the FIRST time the timeline is fully buffered and never re-evaluate — so YouTube autoplaying into the next video (which resets `currentTime`) doesn't matter: capture is already frozen. Don't rewrite the loop to poll `currentTime ≈ duration` every tick; autoplay will reset `currentTime` to ~0 and the check goes false again, spinning until a watchdog. The `__capDone` freeze-on-latch keeps the next video's segments out of the buffers.123- **A fragmented-webm capture may log `[matroska,webm] File ended prematurely` from ffmpeg — it's benign.** MSE-captured opus/vp9 segments end mid-EBML-element (there's no graceful close on a live capture). ffmpeg still muxes the full duration correctly (decode-tested, exit 0); the warning is about the input container's framing, not missing data. mp4-captured (AV1/H264) buffers don't hit this.124- **Quality forcing is best-effort.** `player.setPlaybackQualityRange(q,q)` is honored on most content, but SABR manages bitrate server-side and may ignore it. `--info` shows `getAvailableQualityLevels()`; the returned JSON includes `actualQuality` so you can see what was really played. If the forced quality wasn't honored, the capture is whatever the player chose.125- **`--info` opens the watch page** (one page load) to read title/duration/qualities — it's not free, but it's a normal watch-page hit, not a probe storm.126- **Bytes cross the CDP boundary as base64** in **256 KB** (3-aligned) slices via `Runtime.evaluate` `returnByValue`, decoded with `Buffer.from(b64,'base64')` and appended. The slice size is divisible by 3 so each slice's base64 is independently decodable (no interior `=` padding). Don't slice at a non-multiple-of-3 offset or the concatenation decodes to garbage.127- **Drain captured buffers to disk DURING playback, not after, and keep each CDP response SMALL.** `__drainNew(i,maxBytes)` is called on a ~1 s cadence inside the coverage loop — a page-side `(_chunkIdx,_chunkOff)` cursor tracks bytes already flushed, so each call returns only what was appended since the last, base64-encoded, and the REPL appends to a per-buffer temp file. By latch-time nearly all the media is on disk; the post-pause pass pulls only the tail, so the tab closes immediately at the latch signal (closeTab is fire-and-forget in `finally`, so it can't fire until the snippet returns — draining during the loop keeps that return fast). Doing the whole pull *after* pausing blocks the return and left the tab open for the entire multi-MB drain — this was the "tab stays open ~30 s after the video stops" symptom. The bytes live in page memory and the tab can't close until they're off, so closing at latch *requires* draining during playback.128- **`__drainNew` must never emit more than `maxBytes`, even for a multi-MB segment.** A 1440p fmp4 segment is often 1–4 MB, far over the 256 KB slice cap. The original drain had a `if (to===from) force one whole chunk through` branch that emitted the whole segment in one CDP frame — that single multi-MB `returnByValue` **closes the debug socket** (1006) on Dia. The fix slices a too-big chunk across two drains: this call returns its head and leaves `_chunkOff` pointing into it for the next call. Never reintroduce the force-one-chunk path.129- **In `drainOne`, append the slice BEFORE testing `done`.** `__drainNew` returns `done:true` on the *same* call that yields the final bytes (the cursor reaches the end mid-call), so `if (s.done) return` before appending silently drops that last slice and truncates/corrupts the muxed file (ffmpeg then logs EBML `exceeds max length` / `Invalid data`). Always append `s.b64` first, then check `s.done`.130- **Never send a 4 MB `returnByValue` frame.** A single 4 MB base64 response (~5.6 MB JSON in one CDP frame) **closes the debug WebSocket** on some browsers (reproduced on Dia: the socket drops `rs→3` on the very first 4 MB slice, while a 256 KB slice survives). This masqueraded as a "capture stops before the video finishes" failure because the post-pause pull — which used 4 MB slices — killed the socket immediately. The slice size is now **256 KB** (`262143`, 3-aligned); keep it small. `__drainNew` enforces it per call (slicing big chunks across drains); `__pullBuffer` (absolute-offset slice pull) is retained in the hook as a verified reference/fallback but is not on the hot path — the drain + final tail both use `__drainNew`.131- **Output container follows the video mime.** `video/mp4` → `.mp4`; `video/webm` → `.webm`. ffmpeg is invoked with `-c copy`, so a webm video is muxed to `.webm`. If codecs mismatch the container, ffmpeg copy will fail — that's a codec/container issue, not a capture issue.132- **The pure output isn't QuickTime/iOS-friendly.** YouTube typically serves AV1 video + Opus audio; `-c copy` preserves those (VLC/browsers play them fine), but QuickTime / AVFoundation / iOS refuse them (no AV1-in-MP4 decoder, no Opus-at-all). The skill stays pure on purpose — lossless stream-copy, no size/quality penalty (a re-encode quadrupled the size and is lossy in testing). If you need QuickTime/iOS, re-encode the output yourself in one step: `ffmpeg -y -i "out.mp4" -c:v libx264 -crf 18 -preset veryfast -pix_fmt yuv420p -c:a aac -b:a 192k "out-qt.mp4"`.133- **Re-assert `muted` + `playbackRate` every poll tick.** YouTube's player resets `muted`/`playbackRate` on a quality switch, ad, or player re-init; a one-shot set at play-start gets clobbered and you'll hear the 16× chipmunk audio. The coverage poll re-asserts both each 250 ms — keep that if you touch the loop.134- **Reset `currentTime` to 0 at play-start.** A signed-in account restores the last watch position ("resume from where you left off") on a bare `watch?v=ID` URL with no `t=` — if the account previously watched part of the video, the player starts partway through and the MSE capture only covers from the resume point onward (the `buffered.end >= dur-0.5` check still passes, since the player buffers ahead to the end). `extract_id` already strips any `t=`/`start=` URL param, but resume position is account-side, not URL-side, so the play-start nudge also forces `v.currentTime = 0` after metadata loads. Keep it — without it, resumed videos download missing their beginning.135- **ffmpeg runs with `-y` (overwrite).** Re-running the same video overwrites the prior output instead of hitting ffmpeg's interactive `Overwrite? [y/N]` prompt, which fails non-interactively and silently leaves a stale file. Don't drop `-y`.136- **YouTube Shorts (`/shorts/<id>`) are normalized to `watch?v=<id>`.** A Short is just a video; the Shorts page is a different UI shell around the same `#movie_player`, so `extract_id` pulls the 11-char id from `youtube.com/shorts/<id>` and the rest of the pipeline captures it in the regular watch player — the autonav toggle, quality forcing, and MSE-hook selectors all target the watch page. Don't try to drive the Shorts shell directly: its swipe-to-next autonav is a different control (`.ytp-autonav-toggle` is absent) and its layout hides the controls ytdl relies on.137- **Live / Premiere uses HLS**, not MediaSource segments — not supported by this skill.