This is a rewrite rather than a release with features in it.
The parts you can see are the transcript path, the download path, and a provenance block on every record.
The parts you cannot see are the reason those work: the tool now knows which of eleven public surfaces answered a question and says so on the record.
Read the breaking changes before you upgrade.
The store schema is new and old .db files are refused by name.
Nothing needs a key
There is no API key to get, no Google Cloud project to make, and no quota to run out of.
Every read goes to a surface a browser already reaches: the watch page, the InnerTube endpoints the page itself calls, the channel Atom feed, /oembed, the thumbnail CDN, the autocomplete endpoint, and music.youtube.com.
The InnerTube key is not shipped in the binary.
It is read off the first page of a run, because it identifies the web app rather than you, it is the same for every visitor, and it rotates, and a hardcoded one is a tool that breaks on a Tuesday for no reason you can see.
A policy test fails the build on any string literal shaped like a key, so this stays the only way one enters the program.
The one thing that is about you is a session cookie, and only if you hand one over with ytb auth import --cookies.
That is tier 1, it is the only tier 1 surface there is, and any record that touched it says tier: 1.
Transcripts need no yt-dlp
ytb transcript used to shell out.
It no longer does, and there is no fallback that quietly does.
ytb transcript dQw4w9WgXcQ
ytb transcript dQw4w9WgXcQ --format srt
ytb transcript dQw4w9WgXcQ --lang vi --translate enNo yt-dlp, no Deno, no ffmpeg, no JavaScript interpreter.
goja is gone from go.mod entirely, which is most of the binary size difference.
The caption XML is parsed by pkg/srv3 in pure Go, once, and text, srt, vtt and json all come off the same timeline, so the timings in the srt and the timings in the json are the same timings.
The caption URL comes from the ANDROID player response and not the web one, because the web baseUrl answers 200 with zero bytes.
ytb captions lists what a video has before you pick.
Downloads always range
Every request to googlevideo carries a Range header, in 1 MiB chunks.
This is not a tuning choice.
An un-ranged GET to googlevideo is throttled to roughly 32 KiB/s and the same read in ranges runs at around 4 MiB/s, so whether the header is there is the difference between a download and a wait.
There is no code path in the download engine that omits one, including the retries and the requests made after a stream URL is refreshed, and a policy test holds that line.
ytb download dQw4w9WgXcQ --audio
ytb download dQw4w9WgXcQ -vvThe total is known up front from contentLength, so progress is real rather than a spinner, and an interrupted download resumes by offset.
The expire parameter is parsed when the format is read and a download long enough to pass it re-reads the format rather than failing at 90%.
Audio-only and video-only need no ffmpeg at all, and --mux says plainly what is missing when ffmpeg is not installed.
-vv prints every request with its status, its timing and its Range, which is the fastest way to see what the tool actually did.
Every record says where it came from
Every record carries an envelope: kind, tier, surfaces, sources, client, via and missed.
A record without one is a claim with no evidence.
Two reads of the same video disagree in ways that used to be invisible: the watch page has the exact view count and no comment count, the Atom feed has an exact publication time for the newest fifteen uploads and nothing else, and a search lockup has a rounded "36K views" and no description.
With nothing on the record to say which surfaces answered, a listing row and a full read look identical and a rounded number looks like an exact one.
surfacesare the surface ids that answered, in read order.sourcesare the URLs read, so every row can be traced back to a page you can open.clientis every InnerTube client claimed, because which client asked decides what comes back.vianames the surface behind a field two surfaces could have supplied, down to the block:duration_secondsiss1 microformat.lengthSeconds, becausevideoDetailson the same page says 213 where the microformat says 214.missedis what the read did not see, in sentences rather than codes. Empty means the read believes it got everything its surfaces carry. It never means the thing has nothing.
The lists are not omitempty, on purpose.
An empty surfaces is a bug and should be visible as one, and an empty missed is a claim, which is a different statement from having said nothing.
Three new commands print the tables the envelope points into, and none of them makes a request:
ytb surfaces # the eleven surfaces, with the host each one reads
ytb clients # every InnerTube client this tool claims to be
ytb routes # every read, with its HTTP route and its MCP tool nameA knowledge graph, not a pile of JSON
pkg/graph gives every node a yt: URI.
A handle is never a key, because handles move.
The vocabulary is closed: 21 predicates, each with a domain, a range and a parser that emits it, and ytb predicates prints the lot.
Every claim carries its source, surface, client and tier, and the client is part of the store's primary key, because WEB and ANDROID answering differently is data rather than a conflict to resolve.
ytb edges dQw4w9WgXcQ
ytb graph @RickAstleyYT --budget 50
ytb rdf dQw4w9WgXcQ --format turtle
ytb rdf dQw4w9WgXcQ --checkpkg/rdf writes n-triples, turtle and json-ld, byte-stable between runs.
The mapping starts from the schema.org terms YouTube already publishes on its own pages rather than from a vocabulary we invented, and ytb rdf --check compares our triples against the page's own microdata and reports per-predicate agreement.
Serving
ytb serve publishes one /v1/<verb> route per read, NDJSON for lists, plus /healthz and /v1/openapi.json.
ytb mcp speaks MCP over stdio.
Both are generated from the same op table the CLI is generated from, so neither can drift from the other.
ytb routes prints all three names side by side, so what the server offers can be read without starting it.
Breaking changes
The store schema is new.
videos, channels and a table per record type are gone, replaced by nodes, claims and reads.
An old file is refused when it is opened, by name, rather than failing three commands later with "no such table: nodes".
Delete it and crawl again.
youtube/ is now ytb/.
If you imported the package, the path changed.
Records have new fields.
Anything parsing ytb output with a strict schema will see kind, tier, surfaces, sources, client, via and missed on every record.
Go 1.26.5 is the toolchain, and govulncheck is clean at the tag.
Full notes with examples: https://ytb-cli.tamnd.com/release-notes/v0-5-0/
Commits
Changelog
Other
- daaeb2a: Add pkg/ytid, which reads an id without asking YouTube (@tamnd)
- 5deeb66: Add policy tests for tab blobs and token searching (@tamnd)
- 8be1b3f: Add the Atom feed, the only surface with an exact timestamp (@tamnd)
- a78c79a: Add the graph plane (@tamnd)
- dadc9be: Add the policy tests (@tamnd)
- 56a30ec: Add the session store and the SAPISIDHASH header (@tamnd)
- 35c2c7d: Add ytb archive, one read written down in full (@tamnd)
- dc5355c: Add ytb auth import, status and clear (@tamnd)
- 5872a6a: Add ytb cache path, info and clear (@tamnd)
- 85a4460: Add ytb edges, ytb graph and ytb predicates (@tamnd)
- cc3aa49: Add ytb id, the classification as a record (@tamnd)
- bb21131: Add ytb surfaces, ytb clients and ytb routes (@tamnd)
- 014334d: Ask the site whether these are still its bytes (@tamnd)
- b9c26ae: Cache responses keyed by URL and by client (@tamnd)
- fb3a39a: Count requests as they go out (@tamnd)
- 37b0aea: Crawl the frontier on a request budget (@tamnd)
- 2385a0b: Document signing in (@tamnd)
- b411400: Document the channel plane as it now behaves (@tamnd)
- 6bb011a: Document the music plane (@tamnd)
- de4c068: Document the store as it is now (@tamnd)
- a11f563: Document the video plane as it now behaves (@tamnd)
- 5855387: Document ytb cache, and fix the exit codes on the troubleshooting page (@tamnd)
- c2899f1: Drop published_at from the comment record (@tamnd)
- 067c8fb: Drop the signature decipherer and the JS interpreter (@tamnd)
- abd0c7a: Exit 2 when nothing was named, and 6 when the video is not there (@tamnd)
- 4718c2b: Fetch media in ranges and never any other way (@tamnd)
- 97a345f: Find continuation tokens by key instead of by path (@tamnd)
- 3b9d15e: Find the about panel under the header, not the first engagement panel (@tamnd)
- cd66538: Give a suggestion the same envelope as every other record (@tamnd)
- aec5cb4: Give download the flags the ranged engine needs (@tamnd)
- 293f450: Give every record kind an envelope (@tamnd)
- 6d029dd: Harvest the InnerTube key rather than shipping one (@tamnd)
- 12d8c03: Hold the source to the rules it claims to follow (@tamnd)
- 2b2c987: Honour HTTPS_PROXY (@tamnd)
- eeb2b0a: Keep one caption parser (@tamnd)
- 7406c7e: Keep the VL prefix on the wire and off the record (@tamnd)
- 52d24a8: Keep the four tables closed (@tamnd)
- bb31d62: Keep the related shelf as the rows it is (@tamnd)
- 8d0cbc6: Log what the client read, and count what it requested (@tamnd)
- 2501a04: Make -n mean the same thing on every command (@tamnd)
- 45c6eb4: Make -v print the requests that went out (@tamnd)
- 4da9735: Make the exit codes match what the errors say (@tamnd)
- c97e64a: Move to Go 1.26.5 and bump every dependency (@tamnd)
- ff8fc7c: Name the page behind a search, trending, related and hashtag row (@tamnd)
- 52f95f8: Parse srv3 once and write four formats off it (@tamnd)
- 17e1855: Pin release actions and cosign to fixed versions (#12) (@tamnd)
- b1445bd: Print one row shape per music stream (@tamnd)
- 8be15f8: Print the new record, and say what the table left out (@tamnd)
- 0480f3f: Put client identity in one struct (@tamnd)
- dba670f: Put the docs on one sentence per line (@tamnd)
- 107c594: Quote YouTube when it hides a comment section (@tamnd)
- 0f5e23e: Read a refusal that arrived as a messageRenderer, and exit 4 on one (@tamnd)
- eecb0d2: Read a rendered count in the language it was rendered in (@tamnd)
- 5421673: Read a search in rank order and stop guessing at field names (@tamnd)
- 4b6cc4c: Read a shorts playlist, which renders nothing the item parser knew (@tamnd)
- bef5d51: Read captions from the ANDROID player and drop yt-dlp (@tamnd)
- 2516b33: Read channel tabs off the response and check the one that came back (@tamnd)
- 3d76859: Read the awkward shapes back from committed bytes (@tamnd)
- 2c586ee: Read the channel about panel as a continuation (@tamnd)
- 5cb70ef: Read the channel from all four of the blocks it publishes (@tamnd)
- cae271c: Rebuild the playlist read on the lockup shape (@tamnd)
- 29f93fc: Refresh the docs site against what the commands actually do (@tamnd)
- 9f8e877: Rename the package to ytb (@tamnd)
- 04dcc5b: Resolve a channel through navigation/resolve_url (@tamnd)
- 5bce027: Rewire the commands onto the new store (@tamnd)
- c0a7810: Rewrite the README against what the binary does (@tamnd)
- a221abe: Rewrite the music plane on typed endpoints (@tamnd)
- 3699af6: Rewrite the store as nodes, claims and reads (@tamnd)
- 839bd89: Rewrite the video record so absent and zero are different claims (@tamnd)
- 5861561: Route every InnerTube call through one path (@tamnd)
- 19b881f: Say what the walk spent when the budget stops it (@tamnd)
- 53b6f53: Say which failure it was when the retries run out (@tamnd)
- ad14300: Say why a mix row has no channel_id (@tamnd)
- e6d9c8b: Serve every read, not the third of them that had no table (@tamnd)
- 5448b38: Split the channel tab flags into commands, and let the playlist win (@tamnd)
- 9661f91: Stamp the tier and the session surface on every record (@tamnd)
- 66b54ca: Stop ignoring -o on ytb transcript (@tamnd)
- 8a811a5: Stop refetching a key the page just handed us (@tamnd)
- fa90460: Tell a refusal apart from a failure (@tamnd)
- 735fda5: Test the music read against saved pages (@tamnd)
- f57af91: Update every dependency before the tag (@tamnd)
- ee465cf: Walk the JSON tree in key order (@tamnd)
- 8eadeb6: Write down the served surface (@tamnd)
- 98bc797: Write down what every parser returns (@tamnd)
- 248f4a2: Write the graph as RDF, and check it against the page (@tamnd)
- c7c8ed7: Write the v0.5.0 release notes (@tamnd)