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 via a sibling worker and feeds them into a per-account cache.

Architecture

gRPC chat stream InnerTube observer
│ │
│ ChatMessage │ poll_live_chat
▼ ▼
YouTube chat worker ─── reads ───▶ MembershipBadgeCache (LRU + Redis)
│ ▲
│ enriched badges JSON │ writes
▼ │
platform_chat_messages.badges + WebSocket broadcast

Two parallel workers per active live broadcast:

  • YouTube chat worker (apps/api/src/workers/youtube.rs) — primary firehose. On every is_chat_sponsor=true 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. On cache miss it fires a rate-limited refresh trigger.
  • InnerTube observer worker (apps/api/src/workers/youtube_innertube_observer.rs) — sibling task scoped to the same broadcast. Polls youtubei/v1/live_chat/get_live_chat on an adaptive cadence (60 s / 120 s / 300 s as the stream matures), parses the authorBadges[].liveChatAuthorBadgeRenderer block, upserts into the MembershipBadgeCache, and also maintains an inventory map keyed by raw tooltip for the read-endpoint.

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 (extracted from the watch page)24 h sliding
lumio:yt:refresh_lock:{account_id}:{member_channel_id}per-member trigger debounce60 s
lumio:yt:refresh_pendingSET of account_ids with at least one pending on-demand refreshnone (SREM'd by observer)

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.

Adaptive Polling

Stream agePolling cadence
0–5 min60 s
5–30 min120 s
30 min – ∞300 s

The InnerTube response includes a timeoutMs server-hint (~10 000 ms in practice). The observer reconciles it with the adaptive cadence and uses whichever is longer (server hint as a floor) — so we never poll faster than YouTube tells us is safe but also never slower than our own pacing.

The chat worker can SADD an account into lumio:yt:refresh_pending when it sees a new member it hasn't cached yet. The observer checks the set between adaptive ticks and pulls one extra time, rate-limited via on_demand_min_interval.

Watchdog (Schema-Drift Resilience)

Every InnerTube response goes through serde(default) + Option<…> deserialization, so missing or renamed fields decay to defaults rather than hard-erroring. Top-level response failures (HTTP non-2xx, JSON parse failures, missing actions[]) are tracked in a sliding 1-hour window — when the failure rate climbs past parse_error_threshold (default 25 %) the observer pauses for one hour and emits a warn-level tracing alert. The chat worker still serves whatever is in the cache during the pause; the only user- visible degradation is that newly-arriving member badges fall back to the generic SVG until the observer resumes.

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 to 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; it is widely used by mature open-source projects (yt-dlp, NewPipe, pytchat, Agash/YTLiveChat, kusaanko/YouTubeLiveChat). 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 both the current API key and the initial continuation token.
  • One POST per polling cycle — at the configured adaptive cadence.
  • The hot path (chat-message rendering, history reads) never hits InnerTube — it only reads from the Redis cache populated by the observer.

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.

SettingDefaultPurpose
api_key_fallbackwell-known WEB keyCold-boot fallback; auto-rotated at runtime via watch-page scrape
poll_interval_first_5min60Polling cadence (s) for the first 5 min
poll_interval_5_to_30min120Polling cadence (s) between 5 and 30 min
poll_interval_after_30min300Polling cadence (s) after 30 min
on_demand_min_interval60Min interval (s) for on-demand triggers per account
parse_error_threshold0.25Watchdog trip threshold
cache_ttl_seconds1 209 600Member + tier-badge entry TTL (14 d)

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} and lumio:yt:refresh_lock:*:{id} Redis entry across all accounts. 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.badges — historical messages still contain the member's display name and avatar URL at write time. Backfill of those rows is out of MVP scope and tracked as a follow-up; in the Auskunftsbescheid this limitation is disclosed.

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_innertube_observer.rsSibling poll worker
apps/api/src/workers/youtube.rsInline enrichment in both gRPC and REST chat paths
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