Skip to main content

YouTube Member Badges

Overview

Lumio renders YouTube custom-member-tier badges in the multichat — the artwork that shows up next to a member's name (e.g. Member (6 months) / Diamantclub (3 months)) along with a localized tooltip showing the tier name plus the membership duration.

YouTube's official Data API v3 does not expose the badge image URLs (only the level displayName), and our gRPC live-chat stream carries only isChatSponsor boolean flags. The image URLs live on YouTube's internal InnerTube endpoint that powers the YouTube web frontend itself. Lumio harvests them from chat poll responses and feeds them into a per-account cache.

Architecture

InnerTube chat poll

│ Chat events + badge observations

YouTube chat worker ─── reads/writes ───▶ MembershipBadgeCache (LRU + Redis)

│ enriched badges JSON

platform_chat_messages.badges + WebSocket broadcast

The YouTube chat worker (apps/api/src/workers/youtube.rs) owns both live chat ingestion and badge observation for each active broadcast. On every member message it looks up the member in the cache (LRU first, then Redis) and merges image_url, tier_name, duration_value, duration_unit, and info(=member_months) into the subscriber entry of platform_chat_messages.badges. Other entries (broadcaster / moderator / verified) are not touched. The same InnerTube poll response carries authorBadges[].liveChatAuthorBadgeRenderer observations; the worker upserts those observations into the MembershipBadgeCache and maintains the inventory map keyed by raw tooltip for the read-endpoint. On cache miss it fires a rate-limited refresh trigger.

The cache lives only in Redis (no DB schema). Inline-enrichment writes the resolved fields into the badges JSON column at INSERT time, so chat history scrollback renders correctly without additional cache lookups — identical to how Twitch subscriber badges are handled today.

Cache Schema

KeyShapeTTL
lumio:yt:member:{account_id}:{member_channel_id}JSON CachedMember (tooltip, tier_name, duration, badge_url, display_name, avatar_url, last_seen_at)14 d sliding
lumio:yt:tier_badges:{account_id}JSON map keyed by raw tooltip → TierBadgeEntry (tier_name, badge_url, duration, sort_order, first_seen_at, last_seen_at)14 d sliding
lumio:yt:continuation:{account_id}:{live_chat_id}next-poll continuation token1 h
lumio:yt:innertube_keyInnerTube API key scraped from youtube.com24 h
lumio:yt:innertube_versionInnerTube client version scraped from youtube.com24 h

Cleanup is TTL-driven; no cleanup worker.

Tooltip Parsing

InnerTube is requested with hl=en so tooltips arrive in English and the parser is locale-deterministic. Format observed in production:

Tooltiptier_nameduration_valueduration_unit
Member (1 month)Member1month
Member (6 months)Member6months
Member (2 years)Member2years
Diamantclub (3 months)Diamantclub3months
New memberNew membernullnull
Tier (Gold) (3 months)Tier (Gold)3months

Frontend localizes via next-intl — a German viewer sees Member · 6 Monate while an English viewer sees Member · 6 months for the same persisted record.

Polling

Badge observations arrive in the same InnerTube chat poll response as chat events. The response includes a timeoutMs server hint (~10 000 ms in practice); the worker honors that hint with a 1 s minimum and 3 s default when the hint is absent.

When the worker sees a member it hasn't cached yet, that member simply renders without custom badge artwork until the next poll carrying their authorBadges upserts it into the cache. There is no separate observer worker and no on-demand refresh queue — the lumio:yt:refresh_pending set and its per-member refresh_lock debounce were removed once badge harvesting moved inline into the chat worker.

Schema-Drift Resilience

Every InnerTube response goes through serde(default) + Option<…> deserialization, so missing or renamed fields decay to defaults rather than hard-erroring. If a chat response lacks badge observations, the chat message is still processed and rendered with whatever is already in the cache. Top-level InnerTube response failures count against the normal chat transport failure cascade; after 3 failures within 60 seconds, the worker falls through to the configured gRPC or REST fallback.

API Surface

REST

MethodPathPurpose
GET/v1/youtube/memberships/tiersList observed tier badges for the authenticated user's account. Requires an authenticated, account-scoped identity; the handler applies no resource:action guard and no feature guard of its own.
DELETE/v1/admin/privacy/youtube/member/{member_channel_id}Erase all cached references and historical chat rows for a member channel (GDPR Art. 17). Requires the admin permission admin:privacy-erase, checked against the caller's admin roles.

GraphQL

OperationTypeGuard
youtubeMembershipTiersQueryFeatureGuard("feature:multichat") and PermissionGuard("chat:read")
eraseYoutubeMemberData(input: EraseYoutubeMemberDataInput!)Mutationadmin permission admin:privacy-erase

Response payloads are structurally identical — same fields, same semantics, different case (snake_case vs. camelCase per Lumio convention). GET /v1/youtube/memberships/tiers returns { "tiers": [ … ] }; eraseYoutubeMemberData and its REST twin both return erased_key_count / erasedKeyCount, deleted_message_count / deletedMessageCount, and affected_account_ids / affectedAccountIds. The tier query is the one place where the two protocols differ on entry conditions: GraphQL enforces feature:multichat + chat:read, REST enforces only account-scoped authentication.

WebSocket

There is no membership-specific WebSocket channel. Enriched badges reach the frontend inline on chat messages over the account chat channel (chat:{account_id}, gated on chat:read + feature:multichat), because the worker merges the resolved badge fields into platform_chat_messages.badges at INSERT time.

InnerTube — What It Is, Why We Use It

InnerTube is YouTube's internal/private API consumed by every official YouTube client (web, iOS, Android, TV). It carries the badge image URLs that the Data API does not expose. Auth uses a public WEB-client API key embedded in youtube.com's JavaScript bundle — no OAuth.

Lumio's exposure is bounded:

  • One bootstrap GET per broadcast (live-chat embed page) extracts the initial continuation token.
  • One POST per polling cycle, paced by YouTube's timeoutMs server hint.
  • The hot path (chat-message rendering, history reads) never hits InnerTube — it only reads from the Redis cache populated by the YouTube worker.

Configuration

Settings live in the API config under [youtube.innertube_observer] (see apps/api/config/default.toml). Each setting also accepts an LUMIO__YOUTUBE__INNERTUBE_OBSERVER__* ENV override.

The InnerTube API key and client version are resolved at runtime in the order override (if set) → Redis cache → fresh youtube.com scrape → cold-boot constant, cached in Redis (24 h TTL) and refreshed every 6 h so Google's periodic rotations are picked up without a restart. In addition to that timed refresh, a chat poll that comes back 400/403 (the signal that Google rotated the key/version out from under us) triggers an immediate one-shot re-resolve: the Redis cache is bypassed, a fresh value is scraped from youtube.com and written back to Redis (so parallel workers benefit), and the poll is retried exactly once with the fresh credentials. If the fresh scrape matches the failing value the retry is skipped — there is never a hot loop against youtube.com. A 403 caused by quotaExceeded is a quota problem, not a credential one, so it is excluded from this path. The two *_override settings below are an emergency pin only — leave them empty so auto-rotation stays in charge. If an override is set, the API logs a single WARN the first time the credential is resolved (innertube: rotation disabled by pin (…) - unset to resume auto-rotation; the API key value is redacted) so a forgotten pin never silently disables rotation. The two *_cold_boot settings configure the Stage-4 last-resort value used only after override, cache, and scrape all miss; because they are consulted last they can never pin the credential. default.toml ships them set to the current cold-boot values so a fresh deployment starts from a known-good credential; a cleared or absent field falls back to the compiled default.

SettingDefaultPurpose
api_key_override(empty)Emergency pin for the InnerTube API key; set only if auto-detection fails after a Google rotation
client_version_override(empty)Emergency pin for the InnerTube client version; set only if auto-detection fails
api_key_cold_boot(current key, shipped)Stage-4 cold-boot API key; used only after override/cache/scrape all miss. Absent/empty → compiled DEFAULT_INNERTUBE_API_KEY. Never a pin
client_version_cold_boot2.20260731.00.00Stage-4 cold-boot client version; used only after override/cache/scrape all miss. Absent/empty → compiled DEFAULT_CLIENT_VERSION. Never a pin
cache_ttl_seconds1 209 600Member + tier-badge entry TTL (14 d)
refresh_interval_secs21 600Shared InnerTube credential refresher interval (6 h)
cold_boot_alert_webhook_url(empty)Discord webhook for the Stage-4 cold-boot fall-through alert. Unset by default — a fall-through is still WARN-logged. Reuse the existing InnerTube health channel; do not create a second one
cold_boot_alert_after_failures1Alert only after this many consecutive refresh cycles observe a cold-boot fall-through. Edge-triggered — one alert per incident; a healthy cycle resets the streak. 0 is treated as 1

InnerTube proxy routing is configured one level up, under [youtube] (not innertube_observer): innertube_proxy_urls (a host list the API fails over between on transport error, non-2xx, or a LOGIN_REQUIRED body), innertube_proxy_url (a back-compat single-host alias folded into that list), and the shared innertube_proxy_token. Leave them unset for local development and the API calls YouTube directly. Both a URL without a token and a token without a URL log a warning and disable the proxy. See InnerTube Proxy.

Privacy & GDPR

Member-data (channel ID, display name, avatar URL, observed tier) lives only in Redis with a 14-day sliding TTL. Lawful basis: legitimate interest under Art. 6(1)(f) for providing the multichat to streamers — no profiling, no cross-account tracking, no third-party sharing.

Subject rights:

  • Art. 17 (Erasure) — the DELETE /v1/admin/privacy/youtube/member/{id} endpoint clears every lumio:yt:member:*:{id} Redis entry across all accounts (plus any residual lumio:yt:refresh_lock:*:{id} debounce keys — no longer written and self-expiring in 60 s, but still swept defensively). It does not touch the emote catalog: an emote shortcode plus its public CDN URL is cross-channel branding reference data, not personal data of the erased member, so it falls outside Art. 17 (founder ruling, 2026-08-19 — ZAF-580). The catalog is also shared — an emote the subject hosts is stored under other broadcasters' chats — so deleting it would blank those channels' history rather than erase the member's data. The endpoint writes both a global and a per-account audit log row (event_type = "youtube_member_erasure").
  • PII in platform_chat_messages — historical messages carry the member's display name, avatar URL, and the enriched badge/emote JSONB at write time. As of ZAF-237 the member-erasure endpoint also hard-deletes those rows (platform = 'youtube', user_id = memberChannelId) and reports the count as deletedMessageCount, closing the former backfill gap. The generic POST /v1/admin/privacy/chat/erase endpoint (GraphQL eraseChatSubjectData) performs the same chat erasure for any subject on any platform — see Chat › Erasure.

Key Files

FilePurpose
crates/lo-youtube-api/src/innertube/{mod,types,scraper,parser}.rsInnerTube schema + scraper + tooltip parser
crates/lo-youtube-api/src/innertube/browse.rsProxy host pool and multi-host failover for InnerTube requests
crates/lo-chat/src/youtube_badges.rsMembershipBadgeCache (LRU + Redis) + enrich_youtube_member_badge()
apps/api/src/workers/youtube.rsInnerTube chat ingestion, badge observations, and inline enrichment
apps/api/src/services/innertube_credentials.rsInnerTube API key + client version resolution
apps/api/src/workers/innertube_credentials.rsShared 6 h credential refresher
apps/api/src/services/youtube_memberships.rsRead + erasure service
apps/api/src/routes/youtube_memberships.rsREST endpoints
apps/api/src/graphql/youtube_memberships.rsGraphQL query + mutation
apps/web/src/components/chat-badges.tsxLocalized tooltip render