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
Core Principle
The page plays the video; ytdl records the demuxed media the player feeds to MediaSource via a SourceBuffer.appendBuffer hook and muxes with ffmpeg (-c copy). No yt-dlp, no client impersonation, no n-signature solver, if a logged-in tab can play it, it is downloadable.
When to Use / NOT
- Use when the user wants to download, save, or fetch a YouTube video or audio to disk.
- NOT for non-YouTube sources, or when the tab cannot play the video (gated content without a logged-in session).
Workflow
- Optionally
ytdl URL --info for title/duration/qualities.
ytdl URL [-q 360p|720p|1080p|best|audio] [-o Name -d dir].
- Confirm the MP4 landed at the output path. Stop when the file exists with the expected streams.
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.
7. ffmpeg -i video -i audio -c copy -movflags +faststart out.mp4 (bash,
after the heredoc returns the temp-file paths).
8. 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 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.
__drainNew(i,maxBytes) runs 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 temp file.
By latch time nearly all the media is on disk. The post-latch pass
pulls only the tail, so the tab closes immediately at the latch
signal (incremental drain keeps the return fast; closeTab is
fire-and-forget in finally).
Doing the whole pull after pausing blocks the return and leaves
the tab open for the whole multi-MB drain, the observed "tab stays
open ~30 s after the video stops" symptom.
The bytes live in page memory, and the tab cannot close until they
flush, so draining during playback is required.
__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 4 MB base64 response is ~5.6 MB of JSON in one CDP frame and closes the debug WebSocket on some browsers (on Dia the socket drops rs→3 on the 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 used 4 MB slices and killed the socket immediately. The slice size is now 256 KB (3-aligned); keep it small.
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 capture covers only from the resume point onward. The buffered.end >= dur-0.5 check still passes, because 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.
Red Flags
Background tabs (flaky autoplay, the player must start in foreground or MediaSource is never fed). reassigning SourceBuffer.prototype.appendBuffer (non-writable native method, silently no-ops. must define an OWN property). letting autonav stay on (player autoplays into the next video). expecting ffmpeg to re-encode (it is a muxer only, re-encode yourself for iOS/QuickTime).
Verification
Output file exists at the target path; ffprobe shows the expected streams (video+audio unless -q audio) and a duration matching --info.
References
No reference capsules, the skill is self-contained.
1---2name: ytdl3description: Use when the user wants to download or save a YouTube video (audio or video) to disk. Browser-native capture, no yt-dlp or signature solving: the page plays the video and ytdl records the demuxed media, then muxes to MP4 with ffmpeg. Age-gated and members-only videos work with a logged-in tab.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## Core Principle2526The page plays the video; ytdl records the demuxed media the player feeds to MediaSource via a `SourceBuffer.appendBuffer` hook and muxes with ffmpeg (`-c copy`). No yt-dlp, no client impersonation, no n-signature solver, if a logged-in tab can play it, it is downloadable.2728## When to Use / NOT2930- Use when the user wants to download, save, or fetch a YouTube video or audio to disk.31- NOT for non-YouTube sources, or when the tab cannot play the video (gated content without a logged-in session).3233## Workflow34351. Optionally `ytdl URL --info` for title/duration/qualities.362. `ytdl URL [-q 360p|720p|1080p|best|audio] [-o Name -d dir]`.373. Confirm the MP4 landed at the output path. Stop when the file exists with the expected streams.383940## Quality targets4142| `-q` | what happens | needs ffmpeg? |43|----------|--------------|---------------|44| `360p` / `480p` / `720p` / `1080p` / `1440p` / `2160p` | force that player quality, capture, mux | yes |45| `best` | let the player pick (highest it offers) | yes |46| `audio` | force the smallest video, keep only the audio buffer | no |4748ffmpeg is a **muxer only** here, the player delivers video and audio as49separate ISO-BMFF (fragmented-MP4) streams, so they need combining into one50file. It never re-encodes (`-c copy`); the output stays pure (lossless) at51YouTube's own codecs. If you need a QuickTime/iOS-friendly file, re-encode the52output yourself in one step, see Traps.5354## How it works5556All inside one `browser-harness-js <<EOF` heredoc, gsearch-style:57581. **Connect** to the browser's shared CDP session (or `session.connect()`).592. **`Page.addScriptToEvaluateOnNewDocument`**, inject the MSE hook *before*60 any page JS runs:61 - Wrap `MediaSource.prototype.addSourceBuffer` (via `Object.defineProperty`)62 so each `SourceBuffer` is tagged with its mime (video/mp4 vs audio/mp4).63 - On each SourceBuffer, define an OWN `appendBuffer` property that records64 the bytes (the demuxed ISO-BMFF fragment) before calling the original.65 An own property is required, `SourceBuffer.prototype.appendBuffer` is a66 non-writable native method, so reassigning the prototype silently no-ops.673. **`Target.createTarget` foreground** → `Page.navigate` to the watch URL →68 wait `networkIdle`, then poll for `#movie_player` + `<video>`. Foreground is69 required: background tabs have flaky autoplay, and if the player never70 starts, MediaSource is never fed and nothing is captured.714. **Disable autonav**, then **force quality** (`player.setPlaybackQualityRange(q,q)`,72 best-effort) and **play muted at 16×** so the player fetches+appends every73 segment fast. Autonav is turned off by clicking `.ytp-autonav-toggle` (state74 read from `aria-label` / `data-tooltip-title`: "Autoplay is on" → "... off")75 BEFORE play, so the player can never autoplay into the next video, the real76 fix for the background-tab case, since even a throttled tab that never77 latches has no next-video to run away to. The player's own SABR client78 requests the whole timeline as it plays through. This is the whole model:79 view the video, sped up.805. **Wait until the whole timeline is buffered** (`buffered.end >= duration-0.5`;81 the player buffers ahead), then **freeze capture + pause atomically** in one82 page-side call. We break the FIRST time the timeline is full and never83 re-check, so autoplay advancing to the next video doesn't matter, we've84 already stopped. A `__capDone` flag makes the hook pass appends through (stop85 recording) the instant coverage completes, so nothing after this enters the86 captured buffers. The same poll **re-asserts `muted` + `playbackRate=16` each87 tick**, the player can clobber them on a quality switch, ad, or re-init,88 which would un-mute the 16× audio mid-capture.896. **Drain captured buffers to disk DURING playback** (interleaved with the90 coverage poll, ~every 1 s). Page-side `__drainNew(i,maxBytes)` returns up to91 `maxBytes` of the bytes appended since the last call, advancing a per-buffer92 `(_chunkIdx,_chunkOff)` cursor, base64-encoded; the REPL decodes with93 `Buffer.from(b64,'base64')` and `fs.appendFileSync`s each slice to a per-buffer94 temp file. `__drainNew` never emits more than `maxBytes` even when a single95 fmp4 segment exceeds it, it slices a too-big chunk across two drains (a 1440p96 segment is often 1–4 MB), which is the fix for the crash that dropped the97 socket. By latch-time nearly all the media is already on disk, so the98 post-pause pass pulls only the tail and the tab closes right after.99100 **Pick the101 largest drained video + largest audio file** (the player can create more than102 one MediaSource on a quality switch; the small init-segment duplicates get103 unlinked) and hand their paths to ffmpeg.1047. **ffmpeg** `-i video -i audio -c copy -movflags +faststart out.mp4` (bash,105 after the heredoc returns the temp-file paths).1068. **`closeTab`** in `try/finally`, fire-and-forget, exact gsearch/xsearch107 teardown.108109## Why capture at MediaSource, not googlevideo110111The logged-in web client now plays everything via **SABR**112(`application/vnd.yt-ump`), a protobuf multiplex, not discrete itag URLs with113HTTP ranges. Adding `&range=` to a SABR URL returns a 31-byte114`sabr.malformed_config` error; there's no media-bytes URL to range-download at115the playback layer. (yt-dlp sidesteps this by impersonating a SABR-*free*116`android_vr` client, which is the client-impersonation scaffolding this skill117deliberately drops.)118119The one place the demuxed media exists in the clear is120`SourceBuffer.appendBuffer`, the ISO-BMFF fragments the player hands to121`<video>` for playback. Capturing there is SABR-agnostic, auth-agnostic, and122gating-agnostic: the page did all of it; we record the output. This is123literally "watch it → record it."124125## Files126127All paths relative to `<skill-dir>`.128129- `scripts/ytdl`, the bash CLI (a `browser-harness-js` heredoc, no `#!bun`)130- `scripts/setup`, symlink `ytdl` + `browser-harness-js` onto PATH131- (no `lib/`, the whole solver/client-table/download scaffolding is gone)132133## Traps134135- **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.136- **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.)137- **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.138- **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.139- **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.140- **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.141- **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.142- **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 played. If the forced quality wasn't honored, the capture is whatever the player chose.143- **`--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.144- **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.145- **Drain captured buffers to disk DURING playback, not after.**146 `__drainNew(i,maxBytes)` runs on a ~1 s cadence inside the coverage loop.147 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 temp file.148 By latch time nearly all the media is on disk. The post-latch pass149 pulls only the tail, so the tab closes immediately at the latch150 signal (incremental drain keeps the return fast; closeTab is151 fire-and-forget in `finally`).152153 Doing the whole pull *after* pausing blocks the return and leaves154 the tab open for the whole multi-MB drain, the observed "tab stays155 open ~30 s after the video stops" symptom.156157 The bytes live in page memory, and the tab cannot close until they158 flush, so draining during playback is required.159- **`__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.160- **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`.161- **Never send a 4 MB `returnByValue` frame.**162 A 4 MB base64 response is ~5.6 MB of JSON in one CDP frame and closes the debug WebSocket on some browsers (on Dia the socket drops `rs→3` on the first 4 MB slice, while a 256 KB slice survives).163 This masqueraded as a "capture stops before the video finishes" failure because the post-pause pull used 4 MB slices and killed the socket immediately. The slice size is now 256 KB (3-aligned); keep it small.164- **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.165- **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"`.166- **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.167- **Reset `currentTime` to 0 at play-start.**168 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 capture covers only from the resume point onward. The `buffered.end >= dur-0.5` check still passes, because the player buffers ahead to the end.169 `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.170- **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`.171- **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.172- **Live / Premiere uses HLS**, not MediaSource segments, not supported by this skill.173174## Red Flags175176Background tabs (flaky autoplay, the player must start in foreground or MediaSource is never fed). reassigning `SourceBuffer.prototype.appendBuffer` (non-writable native method, silently no-ops. must define an OWN property). letting autonav stay on (player autoplays into the next video). expecting ffmpeg to re-encode (it is a muxer only, re-encode yourself for iOS/QuickTime).177178## Verification179180Output file exists at the target path; `ffprobe` shows the expected streams (video+audio unless `-q audio`) and a duration matching `--info`.181182183## References184185No reference capsules, the skill is self-contained.