SlopTube

InnerTube API#

InnerTube is the JSON API behind every official YouTube client. All requests are POST https://youtubei.googleapis.com/youtubei/v1/<endpoint>?prettyPrint=false with a body holding the endpoint fields plus a context.client object identifying the client. The code is in src/core/innertube/.

Client profiles#

ClientProfile.cpp is the only place client names, ids, versions and user agents are written.

Kind Name / id Used for Why
Tv TVHTML5 / 7 Every metadata request and every account action Accepts the OAuth token from the device flow; compact responses
VisionOs VISIONOS / 101 Stream URLs (first choice) Anonymous; the only client whose adaptive streams download past the first minute (needs visitorData)
Android ANDROID / 3 Stream URLs (fallback) Anonymous; its progressive itag 18 is complete
Ios IOS / 5 Not used for playback Its streams stop after about a minute
Mweb MWEB / 2 Uploader and avatar lookups Anonymous, and has small responses for both

User agents are templates ({version}, {model}, {os}) expanded once from the profile's own fields, so a version bump is a one-line change.

Requests go over IPv4 when there is a route: on the target phone's Wi-Fi the same search took 2.8-5.5 s over IPv6 against 1.1-1.8 s over IPv4 (http::Client). Reads (browse, next, player, search, account and playlist lists) are sent again once, on a fresh connection, when the server stays silent for 5 s (Request::retryOnStall); actions never are, so a like or a comment cannot be applied twice.

Requests carry X-YouTube-Client-Name, X-YouTube-Client-Version, and the visitorData the server assigned on the first response (keeping one visitor identity avoids bot checks).

Endpoints#

Api method Endpoint Body Parser
feed(Home/Subscriptions/History/Playlists/Channels) browse browseId = FEwhat_to_watch, FEsubscriptions, FEhistory, FEplaylist_aggregation, FEchannels page
playlist(id) browse browseId = VL<id> page
channel(id) browse browseId = channel id channel
channelAvatar(id) browse (MWEB) browseId = channel id, params = the channel's search tab, empty query channelAvatar
videoChannel(videoId) player (MWEB) videoId videoChannel
search(query, filters) search query, params = the filters (see Search) page
searchSuggestions(query) not InnerTube: suggestqueries-clients6.youtube.com/complete/search — JSON array
channelTab(id, tab) browse (MWEB) browseId = channel id, params = the tab's channelTab (see Channel tabs)
liveChat(token) live_chat/get_live_chat continuation liveChat (see Live chat)
shorts() search empty query, params = EgIQCQ== (type: Shorts) page, shorts only (see Shorts feed)
continuation(shorts page) reel/reel_watch_sequence (MWEB), then player (MWEB) per short sequenceParams reelSequence, videoDetails
continuation(page) same endpoint as the page continuation page, appended with appendPage
watch(videoId) next videoId watch
comments(token) next continuation comments
player(videoId) player videoId, contentCheckOk, racyCheckOk player
storyboard(videoId) player (ANDROID) same storyboard
channelPosts(id) browse (MWEB) browseId = channel id, params = the channel's posts tab posts
postDetail(params) browse (MWEB) browseId = FEpost_detail, params from the post posts (+ comments token)
postComments(token) browse (MWEB) continuation comments
subscriptionPosts(channels) several of the above see Community posts —
accounts() account/accounts_list requestType = ACCOUNTS_LIST_REQUEST_TYPE_CHANNEL_SWITCHER accounts
rate(id, Like) like/like target.videoId —
rate(id, Dislike) like/dislike target.videoId —
rate(id, None) like/removelike target.videoId —
setSubscribed(ch, true) subscription/subscribe channelIds[], params —
setSubscribed(ch, false) subscription/unsubscribe channelIds[] —
postComment(id, text) comment/create_comment createCommentParams, commentText —
reply(id, comment, text) comment/create_comment_reply createReplyParams, commentText —
rateComment(id, comment, r) comment/perform_comment_action actions[] —
saveOptions(videoId) playlist/get_add_to_playlist videoIds[] saveOptions (playlistAddToOptionRenderer: id, title, privacy, whether it holds the video)
setSaved(playlist, video, true) browse/edit_playlist playlistId, actions[] = ACTION_ADD_VIDEO + addedVideoId —
setSaved(playlist, video, false) browse/edit_playlist playlistId, actions[] = ACTION_REMOVE_VIDEO_BY_VIDEO_ID + removedVideoId —
createPlaylist(title, privacy, video) playlist/create title, privacyStatus (PRIVATE/UNLISTED/PUBLIC), videoIds[] new playlist id

Avatar lookups#

Feed avatars are resolved with the cheapest requests found, measured on the live service:

Request Response Time
Channel home (browse, MWEB) 180–540 KB ~0.9 s
Channel search tab with an empty query (channelAvatar) ~30 KB ~0.13 s
player (MWEB), for the uploader of a video (videoChannel) ~13 KB ~0.1 s

TV home tiles no longer link to their channel, so their avatars need both lookups, chained. Searching the TV client for a channel id does not work: it returns unrelated channels.

Responses carrying an error object become a failed Result with the server's (localised) message.

Filters are the params of search, a protobuf as the web client's filter menu builds it: field 1 is the sort (3 = popularity; relevance is the default, and the menu no longer offers upload date or rating), field 2 a message of filters. TVHTML5 honours all of them, including Shorts with a real query; MWEB ignores some. The params are standard base64 (protobuf::base64).

Filter Field Values
Upload date 1 1 last hour, 2 today, 3 this week, 4 this month, 5 this year
Type 2 1 video, 9 short, 2 channel, 3 playlist, 4 movie
Duration 3 4 under 3 min, 5 3-20 min, 2 over 20 min
Features 4 HD, 5 subtitles, 8 live, 14 4K 1

Combinations work, and continuations keep the filters. Suggestions come from the service the official apps use, in plain JSON with xhr=t (["query", [["suggestion", 0, [...]], ...], {...}]), in the app's hl/gl, in about 0.1 s.

Channel tabs#

A channel's Videos, Shorts, Live and Playlists tabs are read anonymously through MWEB browse with fixed params, the same for every channel (kChannelTabParams). Asked for a tab it does not have, the server answers with the home tab, so parser::channelTab checks that the selected tab's URL ends with the tab's path (/videos, /shorts, /streams, /playlists) and returns nothing otherwise.

Tab Items Renderer
Videos, Live richGridRenderer → richItemRenderer videoWithContextRenderer: headline, lengthText, shortViewCountText, publishedTimeText; a thumbnailOverlayTimeStatusRenderer of style LIVE marks a broadcast
Shorts same shortsLockupViewModel: id from reelWatchEndpoint, views from overlayMetadata; the title only exists in accessibilityText ("title, views - action"), cut at its last separators
Playlists sectionListRenderer → itemSectionRenderer compactPlaylistRenderer: playlistId, title, videoCountShortText

The next page's token is the continuationItemRenderer ending the list (header chips and the about panel carry other continuations, which are skipped); continuations go to the same endpoint (Endpoint::ChannelTab) and come back as appendContinuationItemsAction. Pages hold 30 items (48 shorts), 60-340 KB, in about 0.2 s. They do not name the channel; the app fills it in.

Live chat#

Only the WEB next response carries a live chat's token, at about 760 KB, so the app builds it from the channel and video ids (Api::liveChatToken, a protobuf whose fields decode the same as the web client's token), then polls live_chat/get_live_chat with the TV client, anonymously (MWEB and VISIONOS are refused). The first answer holds the latest 50-70 messages (about 100 KB), the next ones what arrived since (a few KB), every 3-5 s as timedContinuationData.timeoutMs says.

parser::liveChat keeps liveChatTextMessageRenderer (author, photo, text runs, where a Unicode emoji run's emojiId is the character itself and a channel emoji shows as its shortcut) and liveChatPaidMessageRenderer (with purchaseAmountText); engagement notices, gifts and memberships are skipped.

Renderers understood#

Renderer Where Becomes
tileRenderer TV feeds, related videos, channel shelves Item (with channelId from the tile menu)
lockupViewModel Search results Item (with avatar from decoratedAvatarViewModel)
nextContinuationData Everywhere page continuation (shelf-level pagination inside horizontalListRenderer is ignored)
adSlotRenderer, adSlotAndLayoutRenderer Feeds skipped entirely
videoMetadataRenderer, videoOwnerRenderer, subscribeButtonRenderer next Watch title, views, date, ChannelSummary
likeButtonRenderer next rating, like count
expandableVideoDescriptionBodyRenderer next description
engagementPanelSectionListRenderer (comment-item-section) next comment count, tokens for "Top" and "Newest"
macroMarkersListEntity (MARKER_TYPE_CHAPTERS) next Watch::chapters (title, start)
commentRenderer next continuation Comment (author, avatar, text, likes, vote, replies token)
channelHeaderRenderer, shelfRenderer channel browse Channel (banner, avatar, handle, subscribers, shelves)
accountItem accounts_list Account
backstagePostRenderer MWEB posts tab, FEpost_detail Post (author, date, text, images, poll, video, likes, comment count, page params)

FEplaylist_aggregation answers every continuation with its first page again. appendPage drops items already in the list and ends the feed when a page brings nothing new, so the account's playlists do not repeat forever.

Text fields come in three encodings (simpleText, runs[], content); parser::text() flattens all of them.

Community posts#

The TV client has no posts at all (a channel's posts tab and FEpost_detail come back empty), and no personalised source of posts accepts the OAuth token, so posts are read anonymously through MWEB, which serves the same backstagePostRenderers as the web site:

Request Response Time
Subscribed channels, FEchannels (TV) ~13 KB ~0.15 s
A channel's posts tab (MWEB), latest ~10 posts 20–150 KB ~0.2 s
A post's own page (MWEB) ~17 KB ~0.13 s
A page of post comments (MWEB) ~40 KB ~0.3 s

subscriptionPosts picks up to 6 channels — first the uploaders of the subscriptions feed, which is ordered by latest activity, then the rest of FEchannels (alphabetical) — fetches their posts tabs, keeps the newest 3 posts of each and interleaves them channel by channel (about 2 s in total, once, in the background). The WEB client gives the same posts in responses about twice as large.

Parsed attachments: backstageImageRenderer (one image; the smallest rendition at least 640 px wide), postMultiImageRenderer (carousel), pollRenderer and quizViewModel (answers and vote total; anonymous responses carry no percentages), videoRenderer/compactVideoRenderer. Author avatars are protocol-relative (//yt3…) and get https:.

Post comments are commentRenderers like the TV ones, but replies hang off the commentThreadRenderer (commentRepliesRenderer → continuationCommand) and pages continue through continuationItemRenderer; parser::comments handles both shapes.

Not supported: voting on posts and polls, and rating or answering post comments. The user's own vote is unknown (anonymous reads), and like/like with target.postId is answered with success whatever the target (even a made-up id) without changing the public count, so the app shows a toast instead of pretending.

Mixes#

Feeds and suggestions carry mixes: playlists YouTube makes from a video, a channel or a genre (ids starting with RD; "Radio inspirée du titre", "Radios de genre" in French). They cannot be browsed as playlists (browse VL<id> answers "invalid argument"). next asked with the mix's playlistId alone starts it instead: the first shelf of its suggestions ("Up next") is the queue, 20 videos from the one playing (Api::mix, parser::mixQueue). The app plays the first with that queue as the list it came from, so "next" and autoplay go through the mix.

Shorts feed#

The TV client ignores the Shorts filter of search: with an empty query it returns one page of personalised results mixing long videos (15–25 min) and a few shorts, without continuation. shorts() keeps only the shorts, and the first one seeds reel/reel_watch_sequence, the endless sequence behind the official Shorts pager. Its parameters are a protobuf built in reelSequenceParams: 1 the seed video id, 5 {3: 5}, 10 25, 13 0. MWEB answers it anonymously with about 8 shorts per page (~20 KB) and a continuation token; WEB gives the same ids in responses 10 to 50 times larger.

Sequence entries carry the video id and a 1080×1920 poster but no title, so each short is completed by an MWEB player (videoDetails: title, uploader, uploader id). A page costs about 2–3 s on a worker, fetched ahead of the pager. Measured: 3 pages, 42 shorts, no duplicate.

Chapters#

The TV next response carries a video's chapters in frameworkUpdates.entityBatchUpdate: a mutation whose macroMarkersListEntity.markersList has markerType MARKER_TYPE_CHAPTERS and one marker per chapter (title, startMillis as a string, durationMillis). Lists of other marker types (key moments) are ignored. Videos without chapters have no such list.

Comment parameters#

Comment writes take opaque base64url protobuf parameters, built with core/util/Protobuf.hpp:

Message Fields
create comment 2: video id, 5: {1: 0}, 10: 7
create reply 2: video id, 4: parent comment id, 5: {1: 0}, 10: 7
comment action 1: action type, 2: 2, 3: comment id, 5: video id

Action types: 5 = like, 4 = dislike, 6 = remove like. The TV backend accepts like and dislike; clearing a vote is answered with STATUS_FAILED, in which case the app restores the previous state and shows a toast.

Storyboards#

Seek previews come from playerStoryboardSpecRenderer.spec, which only the ANDROID and IOS clients send (VISIONOS, MWEB and TV do not), hence the separate storyboard request. The spec is <base url>|<level>|<level>…, each level width#height#count#columns#rows#intervalMs#name#sigh. In the base URL $L is the level index and $N its name; $M is the sheet number, and the sheet URL gets &sigh=. storyboardFrame (Storyboard.hpp) picks the smallest level at least as wide as asked and returns the sheet URL and the frame's rectangle in it. A typical video has 48×27 (one sheet), 80×45 (10×10 sheets) and 160×90 (5×5 sheets, one frame every 2 s); the app uses the largest.

Streams#

See media.md, and Live streams for broadcasts in progress. Only formats with a direct url are used; ciphered or SABR-only formats are skipped.

Edit this page on GitHub