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 everyis_chat_sponsor=truemessage it looks up the member in the cache (LRU first, then Redis) and mergesimage_url,tier_name,duration_value,duration_unit, andinfo(=member_months) into thesubscriberentry ofplatform_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. Pollsyoutubei/v1/live_chat/get_live_chaton an adaptive cadence (60 s / 120 s / 300 s as the stream matures), parses theauthorBadges[].liveChatAuthorBadgeRendererblock, upserts into theMembershipBadgeCache, 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
| Key | Shape | TTL |
|---|---|---|
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 token | 1 h |
lumio:yt:innertube_key | InnerTube API key (extracted from the watch page) | 24 h sliding |
lumio:yt:refresh_lock:{account_id}:{member_channel_id} | per-member trigger debounce | 60 s |
lumio:yt:refresh_pending | SET of account_ids with at least one pending on-demand refresh | none (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:
| Tooltip | tier_name | duration_value | duration_unit |
|---|---|---|---|
Member (1 month) | Member | 1 | month |
Member (6 months) | Member | 6 | months |
Member (2 years) | Member | 2 | years |
Diamantclub (3 months) | Diamantclub | 3 | months |
New member | New member | null | null |
Tier (Gold) (3 months) | Tier (Gold) | 3 | months |
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 age | Polling cadence |
|---|---|
| 0–5 min | 60 s |
| 5–30 min | 120 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
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET | /v1/youtube/memberships/tiers | chat:read | List observed tier badges for the authenticated user's account |
DELETE | /v1/admin/privacy/youtube/member/{member_channel_id} | admin:privacy-erase | Erase all cached references to a member channel (GDPR Art. 17) |
GraphQL
| Operation | Type | Permission |
|---|---|---|
youtubeMembershipTiers | Query | chat:read |
eraseYoutubeMemberData(input) | Mutation | admin: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.
| Setting | Default | Purpose |
|---|---|---|
api_key_fallback | well-known WEB key | Cold-boot fallback; auto-rotated at runtime via watch-page scrape |
poll_interval_first_5min | 60 | Polling cadence (s) for the first 5 min |
poll_interval_5_to_30min | 120 | Polling cadence (s) between 5 and 30 min |
poll_interval_after_30min | 300 | Polling cadence (s) after 30 min |
on_demand_min_interval | 60 | Min interval (s) for on-demand triggers per account |
parse_error_threshold | 0.25 | Watchdog trip threshold |
cache_ttl_seconds | 1 209 600 | Member + 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 everylumio:yt:member:*:{id}andlumio:yt:refresh_lock:*:{id}Redis entry across all accounts. It additionallyDELslumio: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
| File | Purpose |
|---|---|
crates/lo-youtube-api/src/innertube/{mod,types,scraper,parser}.rs | InnerTube schema + scraper + tooltip parser |
crates/lo-chat/src/youtube_badges.rs | LRU + Redis cache + enrich_youtube_member_badge() |
apps/api/src/workers/youtube_innertube_observer.rs | Sibling poll worker |
apps/api/src/workers/youtube.rs | Inline enrichment in both gRPC and REST chat paths |
apps/api/src/services/youtube_memberships.rs | Read + erasure service |
apps/api/src/routes/youtube_memberships.rs | REST endpoints |
apps/api/src/graphql/youtube_memberships.rs | GraphQL query + mutation |
apps/web/src/components/chat-badges.tsx | Localized tooltip render |