Guides

YouTube#

Captions and blocks#

Captions are the cheapest and most accurate transcript: no audio download, no speech-to-text. youtube-captions reads them keylessly through the innertube player API and the timedtext endpoint.

How a track is chosen#

With a language (--lang de, language: 'de'):

  1. a creator track in that language,
  2. an auto-generated (asr) track in that language, unless allowAuto is false,
  3. YouTube's machine translation (tlang) of the best translatable track, unless allowTranslated is false. The transcript is marked translated: true. When the player lists translation targets, only a listed language is used, with YouTube's own code for it (pt-BR becomes pt).

Without a language: a creator track in the spoken language (an asr track's language reveals it), then any creator track (the video's default first), then the asr track.

Languages match exactly first, then by primary subtag (en matches en-GB). Tracks download as srv3, then srv1. Parsers also handle json3 and WebVTT, and the format is detected from the body, never from the request, because the endpoint does not always honour fmt. The raw track text is kept as is; cleaning and translation by an LLM are separate layers.

Player clients and fallback#

Clients are tried in order. Defaults, verified live from a GCP VM on 2026-11-14:

Client Version Notes
android (ANDROID) 20.10.38 first; caption URLs fetch without a PO token
ios (IOS) 20.10.4 same tracks as ANDROID

Checked and dropped the same day: TVHTML5_SIMPLY_EMBEDDED_PLAYER ("YouTube is no longer supported in this application or device"), WEB and MWEB ("The page needs to be reloaded", even with visitorData; they now need a PO token), ANDROID_VR and TVHTML5 (the bot check from the VM, while ANDROID and IOS passed). The recorded responses are in providers/youtube/test/fixtures/.

The next client gets a turn when the current one:

  • is refused for its own reasons (LOGIN_REQUIRED without a video reason, UNPLAYABLE "page needs to be reloaded", "no longer supported"),
  • gets "Sign in to confirm you're not a bot" (often specific to one client),
  • returns no captions,
  • fails with an HTTP error other than a block (a retired version gets HTTP 400 "Precondition check failed").

A verdict about the video itself (removed, private, members-only, upcoming) stops the search. Each client tried is recorded as an attempt (youtube/android, youtube/ios) on the player response and on the error. Player responses are cached per video for 60 seconds, so metadata and captions share one call.

Settings: YOUTUBE_CLIENTS=ios,android changes the order or drops a client. YOUTUBE_ANDROID_VERSION and YOUTUBE_IOS_VERSION raise a version without a release. Other modules (metadata, browse, comments) call innertubeRequest(endpoint, body, client, ctx) with their own client spec and share the transport below.

Errors#

Situation Code What to do
invalid id, removed video, private video not-found check the URL
age-restricted unavailable a signed-in, age-verified YOUTUBE_COOKIE, or the yt-dlp provider with cookies
members-only unavailable a member's YOUTUBE_COOKIE
blocked in this IP's region unavailable run from a region where it plays
upcoming stream or premiere unavailable the hint includes the scheduled start time
live stream still running unavailable captions appear after it ends
bot check on every client, the HTTP 429 "automated queries" page, HTTP 403 on every caption request blocked see below
HTTP 429 from the API without the block page rate-limited retried automatically, then wait
every client refused for client reasons unavailable or upstream raise a client version or change YOUTUBE_CLIENTS
empty caption track upstream YouTube wants a PO token: see below

What a block looks like#

YouTube flags many cloud and datacenter IPs. Two shapes were seen from a GCP VM on 2026-11-14:

  • the player API answers LOGIN_REQUIRED "Sign in to confirm you're not a bot" (for some clients; vid2context moves on to the next one, and reports blocked only when every client gets it),
  • timedtext answers HTTP 429 with an HTML page: "Sorry... your computer or network may be sending automated queries". On that day the player API kept answering while every caption request got this page.

Retries never help with a block and tend to extend it. So a blocked result is not retried, and it opens a circuit breaker: for YOUTUBE_BLOCK_COOLDOWN_SEC (default 600, 0 turns it off) every YouTube call in the process fails fast with the same error and hint, and an attempt with reason circuit-open. Rate limits and 5xx get exponential backoff with full jitter instead (at most 3 retries, Retry-After honoured).

Fixes for a block, best first#

  1. Run vid2context on your own machine. A home connection is rarely blocked.
  2. Keep the server where it is but send its YouTube traffic through your home network: see docs/guides/cloudflare-tunnel.md.
  3. Route through a proxy. vid2context has no proxy code of its own: Node 24 honours HTTPS_PROXY when started with NODE_USE_ENV_PROXY=1, for example NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://host:port npx vid2context <url>.
  4. Set YOUTUBE_COOKIE to the Cookie header of a signed-in youtube.com tab. With a SAPISID cookie, the matching SAPISIDHASH Authorization header is derived. Warning: the cookie ties every request to that Google account, and heavy use can get the account flagged, so use a spare account. The provider runs on Node, Workers and in the browser and never reads files, so reading a Netscape cookies file (YOUTUBE_COOKIES_FILE) into YOUTUBE_COOKIE is left to the Node surfaces (CLI, server); until they do, paste the header value itself. YouTube honours account cookies mainly for its web clients, so with the default app clients the gain is uncertain. Not verified live (no account was used).
  5. Install the yt-dlp provider, which has its own client rotation and cookie support.
  6. Wait. Blocks usually lift after some time without requests.

PO tokens#

When YouTube wants a proof-of-origin token for a client, caption tracks come back empty. YOUTUBE_PO_TOKEN is sent in the player request (serviceIntegrityDimensions.poToken) and on caption URLs (&pot=, with &c=<client>). YOUTUBE_VISITOR_DATA must be the visitorData the token was minted for; it goes into the client context and the x-goog-visitor-id header. Tokens are minted outside vid2context (for example with BgUtils) and expire. Neither was needed on 2026-11-14 with ANDROID or IOS. Cookie and token values are never logged.

Metadata and chapters#

youtube.getMetadata makes one player request (the same cached response the captions provider uses), one next request (the watch page's data, WEB client) and one HEAD request for the maxres thumbnail, the last two in parallel. No key. It succeeds whenever the player returns videoDetails, including age-restricted, members-only, live and upcoming videos: those states are reported in extra, and the transcript stage raises the errors. If the next request fails, the player fields are still returned and a warning is logged.

Fields and where they come from#

Field Source
title, description (full), tags, durationSec player videoDetails (title, shortDescription, keywords, lengthSeconds; 0 for live and upcoming becomes undefined)
channel.name, channel.id, channel.url player author and channelId; the URL is the stable /channel/<id> form
publishedAt the date line under the title in next (dateText), as an ISO date YYYY-MM-DD. "Premiered Feb 24, 2026", "Streamed live on Apr 1, 2026" and "Started streaming on Aug 19, 2026" give that date. Relative dates ("23 hours ago") and "Scheduled for" give none
thumbnailUrl maxresdefault.jpg when a HEAD request finds it, else the largest thumbnail the player lists (usually sddefault or hqdefault). The player never lists maxres, and its presence cannot be inferred (5 of 14 sampled videos listing sddefault had none)
language the language of the auto-caption (asr) track, which is the spoken language; undefined without one
isLive, extra.isLive player isLive
extra.viewCount player viewCount (total views, also for live streams)
extra.likeCount the exact count in the like button's label in next ("along with 20,138,292 other people"); absent when likes are hidden
extra.liveViewers the "watching now" (live) or "waiting" (upcoming) count in next
extra.dateText the date line as YouTube shows it
extra.channelHandle, extra.channelHandleUrl the owner's @handle from next and https://www.youtube.com/@handle
extra.subscriberCountText as shown, for example "6.74M subscribers"
extra.isShort true when the input was a /shorts/ URL, or every video stream is taller than wide and the video is at most 3 minutes long. Neither the player nor next flags Shorts directly (checked on 2026-11-14)
extra.isUpcoming, extra.premiereAt player isUpcoming, and the scheduled start (scheduledStartTime, ISO 8601) of an upcoming premiere or live stream
extra.ageRestricted the player status is not OK and its reason mentions age or "inappropriate"
extra.membersOnly the player reason mentions members, or next shows the members-only badge
extra.isLiveContent, extra.playability player isLiveContent and playabilityStatus.status
extra.links every http(s) URL in the description, de-duplicated, in order, without trailing punctuation
extra.chapterSource description, markers or key-moments (absent when there are no chapters)

Not available without an extra request: the category and a separate upload date. They are only in the WEB player response's microformat, and the ANDROID and IOS player responses and next do not carry them, so they are left out rather than costing a second player call.

Chapter precedence#

The first source that yields a list wins:

  1. Creator chapters parsed from the description.
  2. YouTube's chapter markers in next (player bar markers, key DESCRIPTION_CHAPTERS): YouTube's own parse of the description.
  3. Auto-generated chapters, shown as "key moments" (key AUTO_CHAPTERS).

Every chapter gets endSec: the next chapter's start, and the duration for the last one (no endSec for the last chapter when the duration is unknown).

Description rules#

A description yields chapters when it has a line stamped 0:00 (or 00:00, 00:00:00), at least three timestamped lines in all, and ascending times. Lines before the 0:00 line are ignored, and the list ends at the first time that does not ascend or that falls past the end of the video, so stray timestamps elsewhere do not leak in.

  • Timestamps: h:mm:ss, hh:mm:ss, mm:ss, m:ss, optionally in brackets or parentheses, optionally as a range (0:00 - 0:30: Intro gives "Intro" at 0).
  • The title may come before or after the timestamp, with or without a separator (-, |, :, dashes, bullets). Bullets and list numbers before a leading timestamp are dropped; a list number before a trailing-timestamp title stays, as on YouTube.
  • A timestamp in the middle of a line is never a chapter ("at 3:45 he says"), nor a trailing one after words such as "at", "to", "from", "around" ("skip to 12:30").
  • Titles are trimmed, inner spaces collapsed, emoji at either end and quotes that wrap the whole title removed (YouTube does the same).
  • YouTube's help page asks for chapters of at least 10 seconds, but YouTube accepts shorter ones (the 5-second "Intro" of jNQXAC9IVRw), so this rule is not enforced.

Checked against 220 real videos on 2026-11-14: the parser matches YouTube's own chapter list for every description except six where the creator's list is valid but YouTube showed no description chapters (those keep the creator's chapters) and one that uses time ranges (YouTube keeps "0:30:" in the titles; this parser reads the range).

YOUTUBE_WEB_VERSION overrides the WEB client version used for next (default 2.20261112.10.00).

Comments#

Comments often hold what a video leaves out: owners' experiences, corrections from experts, and the creator's pinned clarifications. Ask for them with getVideoContext(registry, url, { comments: 50 }, ctx) (or comments: true for the default of 50). They come back in VideoContext.comments, a separate array that is never merged into the transcript. Comments are untrusted text written by the public: show them to a model as quoted material, not as instructions.

How they are fetched: keyless, through innertube next with the WEB client (no YouTube Data API key). The first call finds the comments section, then each continuation returns about 20 top-level threads. Paging stops as soon as the limit is reached, and ctx.signal is checked between pages.

Setting Default What it does
limit (the comments number) 50 Top-level comments returned, 1 to 500.
YOUTUBE_COMMENTS_SORT top top (YouTube's ranking) or newest. Callers of getYouTubeComments can also pass sort.
YOUTUBE_COMMENT_REPLIES 5 Replies kept per expanded thread, 0 to 100. 0 turns reply fetching off (no extra requests).
YOUTUBE_WEB_VERSION 2.20251110.01.00 WEB client version. Raise it if YouTube starts rejecting the pinned one. Shared with metadata and browse.

Replies cost one request per thread, so they are fetched for at most 20 threads: every thread the creator replied in first, then the 5 most liked threads with replies. Within a thread a creator reply is always kept, even when the reply limit is 1; the rest are filled in YouTube's order. If a thread is marked as having a creator reply that is not on the first page of replies, up to 3 reply pages are read to find it.

Each comment maps to the core Comment type:

  • id, author (the display name, usually the @handle), text (plain text; links are kept as full URLs, emoji as characters, timestamps like 6:38 as written).
  • likes: YouTube shows rounded counts (1.2K), so likes above 1,000 are approximate.
  • publishedAt: approximate. YouTube only says "2 years ago", so the date is computed from that text and the time of the request. Day-or-longer units give a date only (2024-11-14); months count as 30 days and years as 365.
  • isCreator (the author is the video's channel) and isPinned: set only when true. The pinned comment is always first.
  • replies: present only on expanded threads.

Not represented: the creator's heart on a comment (the core type has no field for it, and nothing is added to the text), whether a comment was edited, and author channel ids.

When there are no comments to give, the result is [] with an info log saying why: comments turned off (for example videos made for kids), members-only or age-restricted videos, and videos with no comments yet. A video that does not exist, was removed or is private is an error, using the same error codes as captions. Each request goes through the shared YouTube transport, so a blocked IP fails fast with the same blocked error and hint as captions.

YouTube sends comments in two shapes: the WEB client uses entity payloads (frameworkUpdates mutations keyed from a commentViewModel), and the mobile web client still uses the classic commentRenderer. Both were seen live on 2026-11-14 and both are parsed.

listItems (and expandInput, the MCP tool list_items, the HTTP route GET /items) expands a playlist, a channel or a search into video refs. Each item is the same ref a single-video URL gives (youtube:<videoId>, watch URL), so cached transcripts are shared. Everything is keyless, through innertube browse, search and navigation/resolve_url with the WEB client; no YouTube Data API key is used. URL matching is synchronous and makes no request.

Input forms#

Input Ref kind Ref id Lists
youtube.com/playlist?list=PL... (also UU, FL, OL... albums, any list= without v=, /embed/videoseries?list=) playlist youtube:playlist:<listId> the playlist, in playlist order
youtube.com/@handle channel youtube:channel:@handle every upload (videos, shorts, past streams), newest first
youtube.com/@handle/videos, /shorts, /streams channel youtube:channel:@handle/videos that tab, newest first
youtube.com/@handle/playlists channel youtube:channel:@handle/playlists the videos of every playlist on the tab, playlist by playlist
youtube.com/@handle/featured channel youtube:channel:@handle/featured the videos shown on the Home tab
youtube.com/channel/UC..., /c/name, /user/name, each with the same optional tab channel youtube:channel:UC..., youtube:channel:c:name, youtube:channel:user:name (plus /<tab>) as above
ytsearch:<query>, ytsearch<N>:<query>, youtube.com/results?search_query= playlist (core has no search kind) youtube:ytsearch:<query>, youtube:ytsearch<N>:<query> video results only, in YouTube's ranking

Channel nativeId is the UC... id when the URL carries it, otherwise @handle, c:name or user:name. The tab is recorded after a slash in the ref id and the URL, never in nativeId. Other channel subpages (/community, /about) match as the channel itself.

Hosts: only youtube.com, www., m., music.youtube.com, youtube-nocookie.com and youtu.be match, plus the ytsearch prefix, so these forms never shadow another provider.

Rejected with unsupported-input: mixes (list=RD..., personalized and endless), ytsearch: without a query, and a count outside 1 to 200.

A watch URL with both v= and list= stays a single video. To list the playlist, pass the /playlist?list= form.

Limits and order#

  • limit stops paging as soon as it is reached; no further page is requested. Playlists and channels have no default limit, so an uncapped channel listing pages through every upload (about 100 per request).
  • Search returns 20 results by default, ytsearch<N>: asks for N, and the maximum is 200. A smaller limit wins.
  • ctx.signal is checked between pages.
  • Pages follow continuation tokens until the list ends; a token seen twice ends the listing.

How channels are listed#

A handle, /c/ or /user/ URL is resolved to its UC... id with navigation/resolve_url, which also returns the tab's browse params. Then the upload playlists derived from the id are tried first, because one playlist request gives a stable newest-first order: UU... (all uploads) for a bare channel, UULF... (long-form videos) for /videos, UUSH... for /shorts, UULV... for /streams. If that playlist does not exist, the channel tab itself is listed. A channel without the requested tab (YouTube answers with the Home tab instead) lists nothing, with a log line.

What is skipped#

  • Unavailable entries (private, deleted or otherwise unplayable videos). Playlists are requested with unavailable entries shown, so they can be counted, then skipped.
  • Duplicates: a video already listed is not listed again, even across the playlists of a /playlists tab.
  • Search shelves ("People also watched", shorts shelves) and ads; the search uses the type=video filter.
  • On the /playlists tab, a private or deleted playlist is skipped with a warning; the rest are still listed.

Skipped counts go to one log line on stderr: youtube: skipped list entries with unavailable, duplicates and listed.

Errors#

Situation Code
playlist does not exist, is private or was deleted; channel or handle does not exist not-found
list blocked in the region YouTube sees unavailable
IP blocked (HTTP 429 block page) blocked, through the shared YouTube transport and circuit breaker
a mix, an empty search, a bad search count unsupported-input

Metadata for lists#

getMetadata on a list ref returns a VideoMetadata with chapters: [] and extra.kind set to playlist, channel or search. Playlists carry the title, description, owner channel, thumbnail and extra.itemCount (the count in the header). Channels carry the title, description, avatar, extra.handle and extra.itemCount. A search needs no request: its title is YouTube search: <query>.

YOUTUBE_WEB_VERSION (shared with comments) overrides the WEB client version if YouTube starts rejecting the pinned one.