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 mitgliedschaft-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

MethodPathPermissionPurpose
GET/v1/youtube/memberships/tierschat:readList observed tier badges for the authenticated user's account
DELETE/v1/admin/privacy/youtube/member/{member_channel_id}admin:privacy-eraseErase all cached references and historical chat rows for a member channel (GDPR Art. 17)

GraphQL

OperationTypePermission
youtubeMembershipTiersQuerychat:read
eraseYoutubeMemberData(input)Mutationadmin:privacy-erase

REST and GraphQL responses are structurally identical — same fields, same semantics, different case (snake_case vs. camelCase per Lumio convention).

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)

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 additionally DELs lumio:yt:channel_emotes:{id} — a no-op when the data subject is a pure viewer (they own no emotes), but it ensures the channel-emote cache is wiped if the subject happens to be a broadcaster who hosted custom emotes. 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-chat/src/youtube_badges.rsLRU + Redis cache + 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