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
| 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 scraped from youtube.com | 24 h |
lumio:yt:innertube_version | InnerTube client version scraped from youtube.com | 24 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:
| 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.
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
| 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 and historical chat rows for 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. 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
timeoutMsserver 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.
| Setting | Default | Purpose |
|---|---|---|
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_boot | 2.20260731.00.00 | Stage-4 cold-boot client version; used only after override/cache/scrape all miss. Absent/empty → compiled DEFAULT_CLIENT_VERSION. Never a pin |
cache_ttl_seconds | 1 209 600 | Member + tier-badge entry TTL (14 d) |
refresh_interval_secs | 21 600 | Shared 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 everylumio:yt:member:*:{id}Redis entry across all accounts (plus any residuallumio:yt:refresh_lock:*:{id}debounce keys — no longer written and self-expiring in 60 s, but still swept defensively). 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— 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 asdeletedMessageCount, closing the former backfill gap. The genericPOST /v1/admin/privacy/chat/eraseendpoint (GraphQLeraseChatSubjectData) performs the same chat erasure for any subject on any platform — see Chat › Erasure.
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.rs | InnerTube chat ingestion, badge observations, and inline enrichment |
apps/api/src/services/innertube_credentials.rs | InnerTube API key + client version resolution |
apps/api/src/workers/innertube_credentials.rs | Shared 6 h credential refresher |
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 |