YouTube Live Chat Streaming
Overview
Lumio receives YouTube live chat messages via InnerTube - YouTube's own internal polling endpoint (youtubei/v1/live_chat/get_live_chat). This transport costs 0 Data API quota and delivers the full range of chat event types. gRPC streamList and REST polling are available as optional fallbacks but are disabled by default.
Architecture
Chat Transport
| Mode | Cost | Default |
|---|---|---|
| InnerTube (primary) | 0 quota | Always active |
| gRPC streamList (fallback) | ~0 quota | Disabled (grpc_fallback_enabled = false) |
| REST Data API v3 (fallback) | 5 units per poll | Disabled (rest_fallback_enabled = false) |
The worker starts every stream task in InnerTube mode. gRPC is lazy-connected only if enabled and InnerTube fails. REST polling is a last resort.
Fallback cascade: InnerTube → gRPC (if enabled) → REST (if enabled). Each transition triggers after 3 consecutive failures within 60 seconds.
How InnerTube Chat Works
InnerTube uses YouTube's own live chat polling contract:
- Bootstrap - The worker resolves the InnerTube API key and client version through the credential resolver, then fetches the live chat embed page (
youtube.com/live_chat?v={video_id}&is_popout=1) to extract the initialcontinuationtoken. Continuations are cached in Redis so restarts don't re-scrape the embed page. - Poll loop -
get_live_chatis called with the continuation token. The response carries the next continuation token and a server-recommended polling interval. The worker honors that interval (minimum 1 s, default 3 s). - All event types flow through InnerTube - text messages, SuperChats, SuperStickers, memberships, gift memberships, message deletions, user bans, poll events, and members-only mode changes are all returned in the same response.
- Missing continuation - a response without a next continuation token is ambiguous: YouTube omits it both when the chat is over and when it rotates the token, which typically happens at go-live. The worker therefore drops the cached token and bootstraps a fresh one from the embed page; only a second consecutive response without a token ends the task. Any response that does carry a token restores the full re-bootstrap budget, so a later rotation is recovered as well. The explicit chat-ended event remains the unambiguous terminal signal.
- Poll failures - when the InnerTube failure threshold is reached (3 failures within 60 s), the cached continuation is discarded together with its Redis entry, so the next iteration re-bootstraps instead of replaying a token YouTube keeps rejecting.
The Redis continuation entry is deleted on every terminal path - chat ended, no token after a re-bootstrap, and the failure threshold. A respawned task therefore never resumes a dead token.
InnerTube is unauthenticated for chat reception - no OAuth token required.
Broadcast Discovery
Broadcast discovery runs independently of chat reception:
- Primary: InnerTube
browseendpoint - Queriesyoutubei.googleapis.com/youtubei/v1/browsefor the channel's public Streams tab. CurrentlockupViewModelentries identify live broadcasts by the…_LIVEbadge style and scheduled broadcasts by the badge textUpcoming; the request pinshl=enand UTC. The parser also accepts legacyvideoRendererentries. 0 Data API quota. The response supplies video IDs, titles, available viewer counts, and scheduled times, but noliveChatId. Unlisted and private broadcasts are not discovered here. - liveChatId resolution - For broadcasts without a known chat ID, the worker calls Data API v3
liveBroadcasts.list?part=snippet,status&id=…(1 unit per call, up to 50 IDs). Known IDs from tracked streams orchannel_statusare reused. An unresolved ID is retried after at least 10 minutes; API errors use a separate backoff. The Data API'sscheduledStartTimetakes precedence over the time parsed from the Streams tab. An upcoming broadcast still appears on the dashboard without a chat ID, but has no reader task or Multichat dropdown entry until the ID resolves. A live broadcast waits for its ID before publication. - Fallback: Data API v3 - If InnerTube browse fails entirely, the worker lists
activeandupcomingbroadcasts separately, with pagination. This path is gated byrest_fallback_enabledand costs at least 2 units per discovery cycle.
The ignored innertube_smoke test checks public Streams-tab discovery against live YouTube responses. It reports each channel's live and upcoming counts and upcoming video IDs. Supply additional channel IDs through SMOKE_YOUTUBE_CHANNELS=UC…,UC… when checking a specific channel:
SMOKE_YOUTUBE_CHANNELS=UC1234567890123456789012 cargo test -p lo-youtube-api --test innertube_smoke -- --ignored --nocapture
InnerTube Credential Rotation
InnerTube requests need YouTube's current WEB-client API key and client version. Lumio resolves both through the same centralized resolver:
- Override -
api_key_override/client_version_override, only when an operator intentionally pins a value. - Redis - cached values from the shared refresher worker.
- Scrape - fresh
youtube.comscrape; successful results are written back to Redis. - Compiled constant - cold-boot last resort from
lo_youtube_api::innertube.
The override layer is a deliberate pin. When either override is non-empty, Redis and scrape-based auto-rotation are bypassed for that value until the override is removed.
Redis keys:
| Key | Shape | TTL |
|---|---|---|
lumio:yt:innertube_key | JSON string with the last scraped InnerTube API key | 24 h |
lumio:yt:innertube_version | JSON string with the last scraped InnerTube client version | 24 h |
lumio:yt:innertube_refresh:leader | SET NX leader lock for the shared refresher replica | ~90% of refresh_interval_secs |
lumio:yt:continuation:{account_id}:{live_chat_id} | next-poll continuation token for one active chat | 1 h |
The shared innertube_credentials worker runs once on API startup and then every refresh_interval_secs (default 21 600 s / 6 h). One API replica wins the Redis leader lock per interval, scrapes youtube.com, refreshes both credential keys, and logs value changes. The 24 h credential TTL is intentionally longer than the refresh interval, so normal account workers read warm Redis entries instead of scraping independently.
Keeping the cold-boot layer fresh. The compiled constant and the
youtube.innertube_observer.*_cold_boot seed in apps/api/config/default.toml
only matter when override, Redis and scrape all miss - but a stale value there
turns a cold boot into a failed one. The InnerTube Drift Check workflow
(.github/workflows/innertube-drift-check.yml) runs daily, compares both
checked-in values against live youtube.com, and on drift force-pushes the
bump onto a single next-based branch, chore/innertube-drift-bump. It never
writes the *_override pins.
The branch - not a pull request - is that workflow's deliverable. GitHub Actions cannot open pull requests in this organization, because the setting that would allow it also lets workflows approve pull requests. So the job refreshes an already-open drift PR when one exists, and otherwise posts a one-click compare link to its job summary and to the InnerTube Discord channel for a maintainer to open. A scrape failure still fails the job loudly.
If Redis or scraping fails, the system fails open: account workers still try their own scrape and then fall back to the compiled constant. If an InnerTube poll returns a credential-rotation error (400 or 403, excluding quotaExceeded), the stream task bypasses Redis, force-scrapes both values, writes fresh hits back to Redis, and retries the poll exactly once. If the fresh scrape returns the same values that just failed, the retry is skipped and the task falls through to the normal InnerTube failure counter; there is no hot retry loop against youtube.com.
Discovery intervals:
| State | Interval |
|---|---|
| Idle (no active broadcasts) | 60 s |
| Active (broadcasts running) | 60 s |
InnerTube costs 0 quota, so frequent polling is safe. Each cycle refreshes viewer counts, likes, total views, and detects new or ended broadcasts.
Badge + Emote Enrichment
Member badge images and channel-custom emotes are extracted inline from InnerTube chat responses - no separate observer is needed:
- Member badges - Each message's
authorBadgesarray is parsed for custom-thumbnail badges. The badge image URL and tier label are stored in theMembershipBadgeCache(LRU + Redis) and attached to rendered messages. - Channel emotes - Custom emojis with a
channelId/emoteIdpattern are extracted from emoji renderers and upserted into the durable emote catalog viaEmoteCatalog::upsert. Rows are keyed on the broadcaster whose chat the emote appeared in, with the hosting channel kept inowner_id.
Broadcast Statistics
The worker fetches like counts and total view counts via InnerTube endpoints at 0 quota cost, once per 60-second discovery cycle per live broadcast:
updated_metadataendpoint - Current like count per broadcastplayerendpoint - Lifetime view count per broadcast
These are stored in channel_status (like_count, total_views) and displayed on the dashboard and popout viewer badge.
InnerTube proxy pool
These two endpoints - and only these two - are routed through a pool of
Lumio-operated InnerTube proxy hosts when one is configured. The proxy is the
Rust binary in apps/innertube-proxy, shipped as a ghcr container and run on a
trusted (non-datacenter) IP: YouTube's player endpoint answers
LOGIN_REQUIRED from datacenter and Cloudflare-edge IPs alike, so the request
has to originate somewhere YouTube trusts. Chat polling, browse discovery and
the credential scrape do not go through the proxy.
Failover lives in the API, not in front of the hosts:
- The pool (
InnerTubeProxyincrates/lo-youtube-api/src/innertube/browse.rs) holds an ordered host list, one shared bearer token, and a stickylast_goodindex. - A request starts at
last_goodand rotates forward through the list until a host answers usefully. The host that answers becomes the new sticky start, so a dead host is skipped on later calls with no background health-check and no load balancer. - Failover triggers on a transport error, a non-2xx status, or a
LOGIN_REQUIRED-shaped body. Onlyplayerresponses carryplayabilityStatus, soupdated_metadatabodies are never misclassified. - When every host fails, the call returns nothing and the statistic falls through to the channel-feed fallback below.
The two transports use different route spellings: the proxy exposes /player
and /updated-metadata (hyphenated), while a direct call uses
youtubei/v1/player and youtubei/v1/updated_metadata (underscored). Proxied
requests authenticate with Authorization: Bearer <token>; direct requests
instead send browser-shaped User-Agent, Accept-Language, Origin and
Referer headers.
With no proxy configured the API calls YouTube directly - the normal local-dev setup, where the statistics endpoints may return nothing from a datacenter IP.
Channel-feed fallback
Both InnerTube endpoints are undocumented and fail silently: any change to their response shape yields None, which would write NULL and make the badge disappear. A second, quota-free source covers that gap - the public channel Atom feed at https://www.youtube.com/feeds/videos.xml?channel_id={YT_CHANNEL_ID}, whose entries carry a media:community block:
<media:community>
<media:starRating count="41" average="5.00" min="1" max="5"/>
<media:statistics views="603"/>
</media:community>
starRating count is the like count; statistics views is the cumulative view count (not concurrent viewers - those keep coming from broadcast discovery). The sibling average has been a constant 5.00 since YouTube removed public dislikes and is ignored.
Rules the worker applies (ChannelFeedCache in apps/api/src/workers/youtube.rs):
- InnerTube stays primary. The feed is consulted only for a value InnerTube did not deliver; when both numbers are present no request is made at all.
- One GET per channel, at most every 15 minutes. YouTube serves the feed with
cache-control: public, max-age=900, so a faster cadence would only re-read the same edge-cached bytes. It can never replace the 60-second InnerTube poll. - Matched strictly on
videoId. Uploads, Shorts and live broadcasts share the same 15-entry feed, so "newest entry" is not necessarily the stream. - Also covers non-live broadcasts. Upcoming broadcasts and premieres never hit InnerTube, so the feed is their only statistics source.
- Early warning. A fill happening at all means InnerTube returned nothing where the public feed has a number - the earliest signal that an InnerTube path changed. The worker logs it per fill.
- Retries a partial outage on fresh connections. YouTube's feed servers sometimes answer
404on some connections and serve the feed on others; a kept-alive connection repeats its first answer, a new one is a new draw. The feed therefore uses its own client without connection pooling, and a404or5xxis retried up to 4 requests in total within one fetch. Transport errors, other statuses and unparseable bodies are not retried. - Degrades quietly when the feed is down. A fetch whose last request still fails (YouTube then serves a generic HTML error page instead of Atom) or an unparseable body keeps the previous snapshot and is retried only after the 15-minute interval. Only the first failure of a run logs a warning, the recovery logs once at info, and repeats stay at debug, so a feed that stays down does not log once per channel every 15 minutes (YouTube served
404/500for every channel for several hours on 2026-10-05, and from 2026-10-12 on answered most requests with404- per channel between none and about 40 % of fresh connections still got the feed). The feed is undocumented and best-effort; a channel it does not serve simply keeps whatever InnerTube delivers. Every attempt counts intoyoutube_channel_feed_fetches_total{outcome}(see Metrics) -http_errorwith nookmeans YouTube stopped serving the feed.
The feed needs no authentication and no PubSubHubbub subscription; it is a plain scheduled GET (see Webhooks). A live smoke test guards the assumptions:
cargo test -p lo-youtube-api --test channel_feed_smoke -- --ignored --nocapture
Multi-Stream Support
YouTube allows multiple simultaneous live streams per channel. The worker manages them all:
- Discovery finds all active and upcoming broadcasts.
- A separate stream task is spawned per broadcast that has a
liveChatId(each with its own InnerTube continuation state). A scheduled broadcast without a chat ID is still recorded for the dashboard. - Task lifecycle is managed per-broadcast: new → spawn, ended → cancel.
- Every discovery cycle refreshes the tracked state - broadcast status, title, and scheduled start - for broadcasts that already have a task. A broadcast keeps its
videoIdwhen it goes live, so the status a task was spawned with would otherwise stay frozen for the task's whole lifetime. - Active streams are stored in Redis at
lumio:youtube:active_streams:{account_id}(TTL 120 s) for the frontend.
The upcoming → live transition is therefore visible everywhere the tracked state is published, within one discovery cycle:
youtubeActiveStreams(GraphQL and REST) reportslive, which is what the chat send path needs to resolve aliveChatId.- The Multichat stops rendering the broadcast as a waiting room.
- When the broadcast later disappears, the cleanup branch sees
liveand archives thechannel_statusrow as offline. Rows are only deleted for broadcasts that disappear while stillupcoming- a cancelled or rescheduled premiere.
A scheduled start is carried over as long as discovery reports one, and cleared once the broadcast is no longer upcoming.
Messages from all streams appear together in the Multichat.
Scheduled broadcasts with a liveChatId have a pre-stream waiting-room chat. The worker spawns a reader task for that chat just like a live one. Outgoing sends and moderation also target it: when no broadcast is live yet, liveChatId resolution falls back to the single open upcoming waiting-room chat (a live broadcast always takes precedence). A scheduled broadcast without a chat ID remains visible on the dashboard while its chat ID is retried.
Configuration
All settings live in the [youtube] section of config/*.toml or as environment variables.
| Setting | ENV override | Default | Description |
|---|---|---|---|
grpc_fallback_enabled | LUMIO__YOUTUBE__GRPC_FALLBACK_ENABLED | false | Enable gRPC streamList as fallback when InnerTube fails |
rest_fallback_enabled | LUMIO__YOUTUBE__REST_FALLBACK_ENABLED | false | Enable REST polling as fallback when gRPC also fails; also gates Data API broadcast discovery fallback |
innertube_proxy_urls | LUMIO__YOUTUBE__INNERTUBE_PROXY_URLS (comma-separated) | (empty) | Ordered list of InnerTube proxy hosts for the player + updated_metadata endpoints |
innertube_proxy_url | LUMIO__YOUTUBE__INNERTUBE_PROXY_URL | (unset) | Single-host alias, folded into the resolved host list. Prefer innertube_proxy_urls. |
innertube_proxy_token | LUMIO__YOUTUBE__INNERTUBE_PROXY_TOKEN | (unset) | Shared bearer token presented to every host in the pool |
The two URL settings are merged into one deduplicated, order-preserving list
(innertube_proxy_urls first, then innertube_proxy_url); blank entries are
dropped. The pool is only built when the resolved list is non-empty and a
token is set - configuring one without the other logs a warning and leaves the
proxy disabled. Duplicate entries are collapsed with a warning.
The [youtube.innertube_observer] subsection controls badge caching and InnerTube credentials:
| Setting | ENV override | Default | Description |
|---|---|---|---|
api_key_override | LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_OVERRIDE | (empty) | Emergency pin for the InnerTube API key; disables auto-rotation for the key while set |
client_version_override | LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CLIENT_VERSION_OVERRIDE | (empty) | Emergency pin for the InnerTube client version; disables auto-rotation for the version while set |
refresh_interval_secs | LUMIO__YOUTUBE__INNERTUBE_OBSERVER__REFRESH_INTERVAL_SECS | 21600 | Shared credential refresher interval (6 h) |
cache_ttl_seconds | LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CACHE_TTL_SECONDS | 1209600 | Member + tier-badge Redis entry TTL (14 d) |
The liveChatId resolution via Data API (liveBroadcasts.list) is always permitted regardless of rest_fallback_enabled; an unresolved ID is retried after at least 10 minutes.
API Surface
| Protocol | Operation | Permission |
|---|---|---|
| GraphQL | youtubeActiveStreams - live + upcoming broadcasts with a chat ID for the account | chat:read |
| REST | GET /v1/youtube/active-streams - same Redis-backed list | chat:read |
| GraphQL | youtubeMembershipTiers - the channel's membership tiers | see Memberships |
| REST | GET /v1/youtube/memberships/tiers | see Memberships |
There is no YouTube-specific WebSocket channel type. Chat messages and events
reach clients on the shared chat:{account_id} stream (chat:read +
feature:multichat) and events:{account_id} (events:read).
Neither active-streams operation carries a feature gate; both read the same
Redis-backed tracked state (lumio:youtube:active_streams:{account_id}) that
discovery publishes.
Events Supported
Chat + Moderation
| Event type | Description |
|---|---|
TEXT_MESSAGE_EVENT | Regular chat message |
TOMBSTONE | Silent removal (no-op) |
MESSAGE_DELETED_EVENT | Message deleted - marked in DB, broadcast via WebSocket |
MESSAGE_RETRACTED_EVENT | Message retracted - same handling as deleted |
USER_BANNED_EVENT | User banned - moderation log entry with ban type and duration |
Monetization
| Event | Lumio event type | Key fields |
|---|---|---|
SUPER_CHAT_EVENT | youtube:superchat | amount_micros, currency, amount_display_string, user_comment, tier |
SUPER_STICKER_EVENT | youtube:supersticker | Same + flat stickerName (sticker name, both ingest paths) and stickerUrl (image, InnerTube path only); the gRPC/REST path also keeps nested superStickerMetadata (stickerId, altText) |
Membership
| Event | Lumio event type | Key fields |
|---|---|---|
NEW_SPONSOR_EVENT | youtube:member | member_level_name, is_upgrade |
MEMBER_MILESTONE_CHAT_EVENT | youtube:member | member_level_name, member_month, user_comment |
MEMBERSHIP_GIFTING_EVENT | youtube:gift_membership | gift_memberships_count, gift_memberships_level_name |
GIFT_MEMBERSHIP_RECEIVED_EVENT | youtube:gift_membership_received | member_level_name, gifter_channel_id |
Gift memberships use the same bundling pattern as Twitch gift subs: the header gift_membership event collects recipient names from individual gift_membership_received events, then broadcasts with a giftRecipients list. Individual received events are stored in DB but excluded from the event panel via exclude_bundled.
Interactive
| Event | Lumio event type | Key fields |
|---|---|---|
POLL_EVENT (gRPC), showLiveChatActionPanelAction / updateLiveChatPollAction / closeLiveChatActionPanelAction (InnerTube) | youtube:poll | raw.poll_id, raw.status, raw.question, raw.options[] |
youtube:poll is live-only: it is broadcast on the account events channel and never written to the event history. YouTube reports a running poll again on every vote change, so the worker sends one frame per update and a final frame with status: "closed"; there is no separate end event.
Every frame is a full stream event (id, account_id, platform: "youtube", type, created_at, raw). All frames of one poll share the same id and created_at, so the dashboard chat shows one row per poll and treats vote updates as duplicates, the Events page updates its poll row in place, and the alert box keeps its live tally from every frame. All three read the poll through apps/web/src/lib/youtube-poll.ts (readYouTubePoll), which turns tally into vote counts and shares and falls back to vote_ratio when there are no counts. A new poll in the same stream gets a new id. The identity is kept in the stream task's memory: InnerTube polls are keyed on YouTube's liveChatPollId, gRPC polls (which carry no poll id) on the question, since YouTube runs one poll at a time.
Both transports produce the same raw shape:
| Field | Value |
|---|---|
poll_id | InnerTube liveChatPollId; the event id when the transport has none |
status | active, closed or unknown |
question | Poll question as plain text |
options[] | { option_text, tally, vote_ratio } - gRPC reports tally (vote count), InnerTube reports vote_ratio (0 to 1); the other is null |
The frame carries the poll only in raw; there are no top-level copies of status, question or options, and the raw InnerTube action is not forwarded. An InnerTube action panel that is not a poll, or a close for a poll the stream task never saw open, publishes nothing.
Chat Sending + Moderation
Chat sending and moderation use the user's login OAuth connection (not the channel connection). InnerTube is read-only.
| Operation | Endpoint | Token source |
|---|---|---|
| Send message | POST /youtube/v3/liveChat/messages | get_provider_token("google") |
| Ban / timeout | POST /youtube/v3/liveChat/bans | get_provider_token("google") |
| Delete message | DELETE /youtube/v3/liveChat/messages?id={message_id} | get_provider_token("google") |
All OAuth tokens are managed by the centralized Token Refresh Worker.
Quota Impact
| Operation | Units | Frequency |
|---|---|---|
| InnerTube chat polling | 0 | Per poll interval (~3 s per active stream) |
| InnerTube broadcast discovery | 0 | Every 60 s |
| InnerTube statistics (likes, views) | 0 | Every 60 s per broadcast |
| liveChatId resolution (Data API) | 1 per call (up to 50 IDs) | For broadcasts without a known ID; unresolved IDs retry after at least 10 min |
| REST broadcast discovery fallback | At least 2 | Every 60 s, one page each for active and upcoming (only when InnerTube fails + rest_fallback_enabled) |
| Chat sending | 200 | Per message sent |
| Moderation action | 200 | Per action |
| Channel enrichment | 1 | On demand, cached |
Key Files
| File | Purpose |
|---|---|
crates/lo-youtube-api/src/innertube/mod.rs | InnerTube get_live_chat polling + API key/continuation bootstrap |
crates/lo-youtube-api/src/innertube/parser.rs | Parse get_live_chat responses into InnerTubeChatEvent variants |
crates/lo-youtube-api/src/innertube/events.rs | InnerTubeChatEvent enum definition |
crates/lo-youtube-api/src/innertube/browse.rs | InnerTube browse (broadcast discovery, viewer counts, likes) + InnerTubeProxy pool and failover |
apps/innertube-proxy/ | Rust InnerTube proxy binary (/player, /updated-metadata), run on a trusted IP |
crates/lo-youtube-api/src/innertube/scraper.rs | Scrape API key + client version from youtube.com |
apps/api/src/services/innertube_credentials.rs | Central API key + client version resolver, Redis persistence, one-shot rotation retry |
apps/api/src/workers/innertube_credentials.rs | Shared 6 h credential refresher worker |
crates/lo-youtube-api/src/streaming.rs | gRPC streamList client (fallback) |
crates/lo-youtube-api/src/client.rs | REST Data API v3 client (fallback + moderation) |
apps/api/src/workers/youtube.rs | Multi-stream worker: broadcast discovery, per-stream task lifecycle, InnerTube chat loop |
apps/api/src/workers/youtube_poll.rs | youtube:poll envelope, per-poll identity tracker, InnerTube poll normalisation |
apps/api/src/graphql/youtube.rs | youtubeActiveStreams GraphQL query |
apps/api/src/routes/youtube_streams.rs | GET /v1/youtube/active-streams REST endpoint |
apps/api/src/graphql/channel_status.rs | channelStatus query (includes broadcastStatus, likeCount, totalViews, scheduledStart, liveChatId) |
apps/web/src/hooks/use-youtube-streams.ts | Frontend hook polling active streams |
apps/web/src/app/(main)/(app)/dashboard/chat/multichat.tsx | Broadcast sub-menu + reply context |