Skip to main content

Public Stats

Overview

Public Stats is the per-channel publicness layer for the future public Stats app. Channel listing is an opt-out / public-by-default model: every channel is listed unless its broadcaster opts out. The chatter leaderboard is the exception — it is opt-in (hidden until the broadcaster explicitly enables it, Art. 25(2) privacy-by-default), because it exposes viewer identities. Because the Stats app indexes non-customer channels too, publicness is keyed by channel identity (platform + platform_channel_id), not by account.

This backend surface ships two parts: the opt-out registry with the customer-facing dashboard toggle a first-party broadcaster uses to manage their own channels (see Managing publicness), and the public read model the Stats app consumes (see Public read model). The public app (apps/stats) renders the public channel and stream pages against that read model (see The public app). A broadcaster can also opt out without a Lumio account by typing a verified !optout in their own channel's chat — the crawler honours it (broadcaster badge alone) and writes opted_out_via = 'crawler_command'; see the crawler developer guide.

The public app (channel & stream pages)

The public Stats app (apps/stats, own origin stats.lumio.vision) is an unauthenticated, edge-cacheable Next.js app. It renders two public pages against the read model, server-side (SSR) with no session cookie:

  • /{channel} — channel page: a single top card (ZAF-866) that merges the channel identity — the name, the Lumio channel badge (shown when source = first_party), the Twitch partner / affiliate channel-type badge after the name when the channel carries one (ZAF-842; absent for none/null and non-Twitch platforms), a LIVE badge with the current viewer count when the channel is streaming right now (ZAF-795), and the handle — with the About box (bio / social links / team) as the card's lower region. Each About sub-field is independently optional; when bio, links and team are all absent the card collapses to the identity block (never an empty About shell). Directly beneath the card a content submenu (tab bar) switches the page body between four tabs, all rendered server-side and streamed in the same payload (the page stays fully edge-cacheable — the client only toggles which tab is mounted):
    • General — aggregate headline tiles (stream count, watchtime, peak/avg viewers, followers/subscribers, total messages, last streamed) and the About-box change-history timeline (ZAF-836).
    • Streams — the recent-streams list linking to each stream page.
    • Placements (badged Beta) — for Twitch channels that carry them, the ad-marked panels the broadcaster marked as advertising ("als Werbung gekennzeichnet"), grouped per panel (not one entry per artwork version), each showing its title, latest image, description, link, and observed window first_seen_at → last_seen_at. Each panel also carries a lifecycle status (active / removed): an active panel shows an "Active since" line and an ongoing badge, and a removed panel is badged Removed with its explicit removed_at timestamp (ZAF-892) so a taken-down placement is distinguished from a still-present one. When a panel's artwork changed over time the tab renders an old→new image slideshow from its image-change history — the distinct artwork versions (images: [{ image_url, first_seen_at, last_seen_at }], oldest-first), opening on the current artwork with the shown-span caption per version; a single version renders as a static image. The tab reads this richer per-panel lifecycle + image history via publicChannelPanelHistory (ZAF-892/ZAF-894 — see below). The heading and disclaimer state these are broadcaster self-declared markings that Lumio does not verify — never asserted as fact. Panel artwork is hotlinked straight from the Twitch CDN (static-cdn.jtvnw.net) with referrerPolicy="no-referrer" and never re-hosted. A channel with no ad-marked panels shows a muted empty state. Ad-detection over chat (matching chat links/keywords against panels) remains a separate track, pending a founder ruling.
    • Stats (badged Beta) — a follower-growth line chart and an average-viewers-over-time line chart, each with a selectable 30 / 90 / 180 / 365-day range (ZAF-891). The dated channel-scope series come from the publicChannelTrends read model; the page fetches the widest window server-side once and the range selector slices shorter windows client-side, so it stays edge-cacheable. A channel with no history in the window renders an honest "not enough history" empty state rather than a fabricated chart.
  • /{channel}/stream/{broadcastId} — stream page: the viewer-curve chart, duration, peak/avg viewers, message and unique-chatter counts, emote count, new-follower count, de-identified subscription and gifted-sub counts, the top-emotes list, the GIF count (shown ungated like the emote count — it is a plain number, not a third-party fetch) and the Top GIFs (ZAF-968 — which GIFs appeared in the public chat and how often; a content datum kept for 90 days then auto-deleted). The Top-GIFs images load from GIPHY only after the visitor grants the Functional consent category (ZAF-969/970); without that consent the card lists the most frequent GIFs by name/id + count as text and fires no request to GIPHY, and — only when the broadcaster opted the leaderboard in (show_top_chatters) — the per-channel chatter leaderboard. For source = external_crawler streams the follower / subscription / gifted-sub tiles render "—" ("not collected") rather than 0: under Regime B (ZAF-633) the crawler keeps aggregates only and never captures follower/sub/gift events, so those fields are legitimately unavailable, not zero (viewer, message, unique-chatter, emote and GIF counts are crawled and show real numbers). When a stream ran in a Twitch Shared Chat session, the stream summary's shared_chat_sources names the source channels whose chat appeared in the room (ZAF-819), and the stream page renders a Shared Chat indicator — a gradient banner with overlapping source-channel avatars and a per-channel list of "who these stats are coming from" with each source channel's shared-chat message count (ZAF-867), matching the live-chat Shared Chat styling. Each entry carries the source channel's display_name + avatar_url (ZAF-868, resolved from the crawler channel-profile store, falling back to the channel id when uncaptured). The channel page's recent-streams list marks each shared-chat broadcast with the same Shared Chat badge. Those shared-chat messages are attributed to their source channel, not folded into the host's message / unique-chatter counts (attribution by channel, never by viewer — aggregates only). The stream page also renders a stream-metadata change timeline card (ZAF-890) — the broadcast's title / category / tag changes old→new, most-recent first, mirroring the channel page's About-box change-history card (title and category as text diffs, tags as chips with added tags emphasised and removed tags struck through). It renders nothing when the stream is opted out or has no recorded metadata changes. See Stream-metadata change history below.

It also renders the cross-channel browse surfaces over the Form-B read model (see Cross-channel browse & search below for the protocol and field set):

  • /rankings/{metric} — the top-bar metric nav. One route per ranking, each its own edge-cacheable URL: watchtime, viewers, follower-gain, peak, and live (live channels, most-recent first). A tab bar switches metric; a platform filter and a Live only toggle refine the list; a ?category= filter (arriving from a Games card) scopes it to one game. Bare /rankings redirects to /rankings/watchtime; an unknown metric is a clean 404.
  • /games — the Games directory: distinct categories with per-category channel / stream / peak-viewer / watchtime aggregates and a live-stream count. Each card drills into /rankings/watchtime?category=… for that game.
  • /search — channel search: a debounced box driving ?search=, matching on handle + display name across every opted-in channel (results reuse the ranking list in Watchtime order). Empty query shows a prompt, not an unbounded dump; the top-bar search field submits here as a plain GET form (no client JS needed).

The founder's separate Followers (absolute total) tab is intentionally not shipped: the Form-B read model exposes no non-account-keyed follower total, so it is deferred to the crawler follower-capture work — Follower Gain is the shipped follower ranking. Every browse route is cookieless, server-rendered, and carries all filter/page state in the URL, so each combination is its own cacheable URL; when the read model is unavailable the page shows a calm unavailable state.

Field discipline (§5 visibility matrix). The pages render only what the read model exposes, which is the §5 public column: donations, raw platform metrics, and subscriber / gifter identities are not modelled server-side and can never reach the page. Subscription and gift data appear as de-identified counts only.

Platform resolution. The read model is keyed by (platform, channel). The /{channel} URL takes the platform from an optional ?platform= query param and defaults to twitch; stream links carry the resolved platform forward.

Localization. The app is bilingual (English + German) but carries no locale in the path — there is no /de/ or /en/ prefix for any locale, and /{channel} / /{channel}/stream/{broadcastId} are the only public paths. The active language is resolved exactly like the main webapp: from the functional locale cookie (the visitor's remembered choice), falling back to the browser's Accept-Language for a first-time visitor, then English. The language switcher writes that cookie server-side and re-renders the page — the choice is remembered across visits without ever changing the URL.

Empty & opt-out states. A channel or stream that is opted out (or unknown) resolves to GraphQL null / REST 404 and the app renders a clean not-found page — never a crash, and never a partial payload with hidden fields. Sections with no data yet (e.g. a crawled channel not yet covered by chat capture) render a calm empty state rather than disappearing or erroring.

Stats for non-connected (foreign) channels are gathered by the Public Stats Crawler, which writes source = 'external_crawler' rows into the same channel_history* tables. Those rows carry no Lumio "connected channel" badge and, until a channel is verified-claimed, hold only non-personal aggregates (no per-username data).

Signed-in layer: Watchlist

The Stats app is public first, but a thin authenticated layer sits on top of it: Sign In / Connect your Lumio ID account and a personal Watchlist (favourite-streamer tracking). The public/authenticated split is per-function, not per-page — every page stays publicly reachable, and only individual controls (the Watchlist star, the personal Watchlist view) require a signed-in Lumio ID. This keeps the public origin cookieless and edge-cacheable.

Sign in. Stats has no auth mechanism of its own. Login reuses the central Lumio ID app (NextAuth) exactly like the web and admin apps: the Sign-In control redirects to id.lumio.vision/login with a redirect_uri back to Stats, and after login the shared lumio-token session cookie (set on the .lumio.vision domain) is read by the Stats proxy routes. No popout / overlay tokens are involved.

Cacheability is preserved. The public /{channel} and stream pages are still rendered server-side with no session read — the signed-in chrome (avatar menu, Watchlist state) is resolved client-side after hydration via dedicated dynamic proxy routes, so the cached SSR HTML is identical for every visitor:

RouteMethodPurpose
/api/sessionGETProbe the current viewer (me) for the chrome. Signed-out → { user: null }.
/api/auth/logoutPOSTInvalidate the session and clear the shared cookies.
/api/watchlistGET / POST / DELETEList / add / remove the viewer's favourited channels.

Watchlist. A signed-in viewer taps Watch on any channel page to add it to their Watchlist; the /watchlist view lists their favourited channels with a link to each public channel page and an inline remove control. A signed-out tap routes to the Lumio-ID login and returns to the channel. Persistence is per-user and lives entirely behind the authenticated proxy routes above — distinct from the cookieless public read path. The /watchlist page is force-dynamic and noindex (a personal surface, never cached or crawled).

Backend persistence

The Watchlist persistence API (GraphQL myWatchlist / addToWatchlist / removeFromWatchlist, REST GET/POST/DELETE /v1/watchlist, and the stats_watchlist table) is a per-user authenticated surface tracked separately from the public read model. See the API reference for the endpoint shapes.

The registry

public_channel_settings is a carve-out registry — absence of a row means the channel is public but its chatter leaderboard is hidden (channel listing opt-out, leaderboard opt-in). A row records:

FieldMeaning
platform, platform_channel_idChannel identity the row is keyed on (UNIQUE).
account_idThe owning account for a first-party channel; NULL for a crawled channel. ON DELETE SET NULL — an account dissolving never re-publishes or drops a channel's opt-out.
is_publicfalse = opted out (hidden from the Stats app). Default true (opt-out).
show_top_chattersThe chatter leaderboard is opt-in: hidden until the broadcaster explicitly enables it (Art. 25(2) privacy-by-default). Default false, independent of is_public.
opted_out_by / opted_out_viaProvenance of an active opt-out (who / how — dashboard for the toggle, crawler_command for a verified !optout). opted_out_by is the Lumio user id for dashboard and the broadcaster's Twitch user id for crawler_command. Cleared to NULL on opt-in.

Managing publicness

A first-party broadcaster manages their own connected channels from the dashboard (OAuth-proven ownership). The channel is always resolved to the caller's own connected channel for the platform — the channel id is never taken from the client, so an account can only change a channel it has connected.

  • GraphQLchannelPublicSettings (read), setChannelPublicSettings (write).
  • RESTGET /v1/public-stats/channels, PUT /v1/public-stats/channels/{platform}.

Both protocols are identical in fields, permissions, validation, and errors.

Public read model

The Stats app consumes an unauthenticated, cache-friendly read surface over the signed-off field-visibility matrix. It carries no per-user data — every caller sees the same response for a given channel/stream, so it caches cleanly at the edge. Two reads:

  • Channel page — aggregate over a channel's streams, matched by the stable (platform, platform_channel_id) and addressed by the URL handle (channel_login): stream count, total streamed duration, peak/avg viewers, total messages, the channel's follower / subscriber totals (current values; unavailable metrics render as "—", see §5), a live status (is_live + the current stream's title/category/start/current viewers when live; see §Live status), a recent-streams list, the ad-marked panel timeline (broadcaster-marked advertising panels — title/image/text/link over time; see §5), and the About box (bio, social links, team; see §5).
  • Stream page — one stream by its public key (platform, broadcast_id), its session-level public aggregates, and its viewer-curve timeseries.

Exposed on all three protocols with identical fields, validation, and errors:

ProtocolChannel pageStream page
GraphQLpublicChannelStats(platform, channel)publicStreamStats(platform, broadcastId)
RESTGET /v1/public-stats/channels/{platform}/{channel}GET /v1/public-stats/streams/{platform}/{broadcast_id}
WebSocketpublic-stats:{platform}:{broadcast_id} (bootstrap snapshot on subscribe)

The whole surface is gated by the system:public_stats kill-switch flag — an infrastructure flag (no plan pairing) that turns the public read model on/off globally across all three protocols. Because the path is account-less, the flag is checked with no account; flipping it off returns "not available" on REST/GraphQL and rejects the WS subscribe with FEATURE_DISABLED.

Field visibility (§5)

The read model serves only the public-safe field set and enforces the registry on every read:

  • Served: stream title/category, start/end/duration, viewer curve (peak/avg + the per-minute viewer_count series), total messages, unique chatters, emote count + top emotes, new-follower count, de-identified paid/ gifted sub counts (numbers only), and the source provenance badge (first_party renders the "Lumio channel" badge; external_crawler renders none). First-party and crawled rows flow through the same resolvers/types, distinguished only by source.
  • Ad-marked panels (channel page only): the timeline of Twitch panels the broadcaster marked as advertising — each version's title, image URL, text, link, and firstSeenAt/lastSeenAt period. This is a narrow served slice of panel data, by founder ruling (ZAF-794/ZAF-798): only panels whose title, description, or link URL carries a self-declared ad/sponsorship signal (werbung/anzeige/ad/ads/advert…/sponsor…/gesponsert/partner, or an affiliate/tracking link) are captured — every other panel is dropped at crawl time and never persisted. The classification is self-declared: the UI presents these as panels the broadcaster marked as advertising, never as an assertion that they are advertising. Only image URLs are stored (hotlinked from Twitch's CDN, never re-hosted). Non-ad panels are provably absent — the matching is word-boundary (so Adventskalender/download never match ad). Each panel carries a lifecycle status (active/removed) + explicit removedAt (ZAF-892): the crawler reconciles the channel's current ad-marked set every observation and marks a panelId removed when it disappears (or is edited so it is no longer ad-marked — the disclosure ended), clearing it if the panel reappears.
  • Placements tab — panel lifecycle + image-change history (channel page, ZAF-892): publicChannelPanelHistory(platform, channel) returns the ad-marked panels grouped by panel, each with its latest content, full observed span, lifecycle (status + removedAt), and an image slideshowimages: [{ imageUrl, firstSeenAt, lastSeenAt }], the old→new artwork timeline (distinct image URLs over time, oldest-first). The image versions come from the same content_hash versioning that already records a new row on any artwork change, so no extra store is needed. Same empty-state discipline (absent channel → null, no panels → []), opt-out gating, and hotlinked-artwork rule as the flat panel timeline. It powers the frontend Placements-tab slideshow + removed badges.
  • Follower / subscriber counts (channel page only, ZAF-796): the channel's current follower total and subscriber total — channel-level aggregates (a broadcaster publishes them on their own channel page; no viewer identity), stored current-values-only in public_channel_metrics keyed by the same (platform, platform_channel_id) identity. Availability is per-platform and per-metric, and an unavailable metric is served as null and rendered as "—", never 0: the Twitch follower total is captured from the public GQL surface; a Twitch subscriber count is not public (obtainable only with the broadcaster's own OAuth), so it is always "—" for crawled channels; a YouTube subscriber count is public via the Data API and is populated once a YouTube crawl path exists (until then, "—").
  • About box (channel page only, ZAF-799; rendered as the lower region of the consolidated identity top card since ZAF-866): the broadcaster's bio / description, their configured social links, and their team membership(s), stored current-values-only in public_channel_profile keyed by the same (platform, platform_channel_id) identity. Served under the §5 approval (ZAF-793 plan, accepted 2026-08-27) with three conditions that are part of the ruling, not styling: (1) the bio is plain text, republished verbatim and inert — no HTML, no markdown, no link auto-embedding, no enrichment — and follows refresh-and-overwrite: each crawl overwrites the stored value (including to empty), so a shortened or deleted bio is never retained. (2) social links are served as label + raw URL and rendered rel="nofollow noopener ugc", target="_blank" with no unfurl, preview, or shortener resolution. (3) team is structured display name(s) only. Each field is independently optional: description is null when absent, and social_links / team are empty lists (never null); a channel with all three absent renders no About region, collapsing the top card to its identity block (ZAF-866). The channel page shows the current values; the versioned change history of these three fields is a separate read — see About-box change history below.
  • Channel type (broadcaster_type, ZAF-841/ZAF-842/ZAF-860): the Twitch partner / affiliate status, rendered as an icon badge after the channel name (a verified-style mark for partner, a lighter marker for affiliate — mirroring the main web app's partner/affiliate language). It is exposed on both read models: the channel-page model (PublicChannelStats) and the cross-channel browse / rankings / search rows (PublicChannelBrowseItem, ZAF-860), so the same badge renders after the name on the channel page and in every browse-list row. On the browse path the type is not a Timescale aggregate column — it is stitched from public_channel_profile with one batched lookup per page (keyed by channel identity), not a per-row join. Stored current-values-only on public_channel_profile (the same About-box row) and captured from the same undocumented Twitch GQL user node (roles { isPartner isAffiliate }) — the same public partner/ affiliate badge Twitch shows logged-out visitors, no viewer identity. Values are "partner" / "affiliate", or null when the channel is neither (Twitch's none case), not captured, or a non-Twitch platform ⇒ no badge is rendered (never a "none" label). Like every profile field it is refresh-and-overwrite: a partner→affiliate→none demotion propagates on the next crawl, so no stale partner badge is retained. "Twitch DJ" is a separate program and is not a broadcaster_type — it is not exposed per channel by any Twitch API field, so the badge is only ever partner/affiliate.
  • Chatter leaderboard (top_chatters, usernames + counts) is served only when the channel opted the leaderboard in (show_top_chatters = true); otherwise it is absent (null), never an empty list.
  • Never served: donations, subscriber/gifter identities, revenue splits, and the raw platform_metrics / platform_breakdown JSONB. These are not modelled on the public types, so they cannot leak.

Live status (ZAF-795)

The channel page carries an is_live flag and, when live, a live_stream summary (broadcast id, self-reported title/category, started_at, and the current viewer count). The data already exists — the Twitch crawler's StatsPoller sweeps currently-live streams every 60s and writes a channel_history_stats viewer sample per live channel; no new external fetch is made.

Liveness is derived from observation freshness, not ended_at: a channel is live iff it has a still-open stream row whose most-recent viewer sample is newer than a 5-minute window. This matters because the crawler never sets ended_at on channel_history_streams — it simply stops emitting samples when Twitch drops the channel from the live sweep. An ended_at IS NULL check would therefore mark every ever-crawled channel permanently live; the freshness window instead clears correctly for both first-party and crawled channels when the stream ends (up to one window later). Served identically on GraphQL (isLive / liveStream) and REST (is_live / live_stream); the channel page has no WebSocket bootstrap.

About-box change history (ZAF-836)

The three About-box fields — bio, social links, and team — are broadcaster-published, freely accessible profile data. By founder ruling (ZAF-791) they are not personal data under the §5 minimisation regime, so in addition to the current-values row the crawler keeps a versioned change history: every time it detects that a field differs from the stored value, it records one timeline entry with the old value, the new value, the change type (bio / link / team), and the detection time. This is the same timeline treatment the ad-marked panels get (firstSeenAt → lastSeenAt).

  • Read surface — a paginated, most-recent-first timeline. GraphQL query publicChannelProfileHistory(platform, channel, limit, offset) (primary) and REST GET /v1/public-stats/channels/{platform}/{channel}/profile-history?limit=&offset= return the identical field set (parity); limit defaults to 50 and is capped at 200. It is gated exactly like the channel view — the system:public_stats kill-switch plus the opt-out check — so an opted-out or unaddressable channel returns GraphQL null / REST 404. The channel page reads it via publicGql (SSR); there is no WebSocket surface (the public-stats WS bootstrap carries only the per-stream snapshot).
  • Value shapeoldValue / newValue are opaque JSON interpreted by changeType: a JSON string for bio, a JSON array of { url, label } / { name, display_name } for link / team. null on the old side marks the initial capture (no prior value); null on the bio new side marks a deletion.
  • No §5 minimisation, no time cap, no audit event. Because these are public data, the history carries the full old→new values (no minimisation) and is kept indefinitely — the 90-day raw-sample retention (ZAF-689) does not apply. Writing these rows is crawler telemetry over public data, not a security- or lifecycle-relevant action, so it deliberately emits no audit event. The only erasure path is opt-out (below).

Stream-metadata change history (ZAF-890)

A live broadcast's title, category, and content tags change during a stream. Like the About box these are broadcaster-published, freely accessible stream metadata — by founder ruling (ZAF-791 / ZAF-794) not personal data under the §5 minimisation regime — so alongside the current values on the stream summary the crawler keeps a versioned change history per broadcast: every time it detects that the title, category, or tag set differs from the last recorded value, it records one timeline entry with the old value, the new value, the change type (title / category / tags), and the detection time. This is the stream-scoped sibling of the About-box change history, keyed by the broadcast rather than the channel.

  • Read surface — a paginated, most-recent-first timeline. GraphQL query publicStreamMetadataHistory(platform, broadcastId, limit, offset) (primary) and REST GET /v1/public-stats/streams/{platform}/{broadcast_id}/metadata-history?limit=&offset= return the identical field set (parity); limit defaults to 50 and is capped at 200. It is gated exactly like the stream view — the system:public_stats kill-switch plus the opt-out check — so an opted-out or unknown stream returns GraphQL null / REST 404.
  • Value shapeoldValue / newValue are opaque JSON interpreted by changeType: a JSON string for title / category, a JSON array of tag strings for tags. null on the old side marks the initial capture (no prior value); null on the new side marks an emptied field (title/category cleared, or all tags removed).
  • Tags are captured from Helix Get Streams tags[] (fail-open: an absent field reads as no tags) and stored in the broadcaster's configured order.
  • UI — the stream page renders this timeline as a change-history card (mirroring the About-box card): each entry shows the change type, its detection time, and a before→after comparison — title / category as plain inert text, tags as non-interactive chips with added tags emphasised and removed tags struck through. A null fetch (opted out / unknown) or an empty history renders no card at all.
  • No §5 minimisation, no time cap, no audit event. As public data the history carries the full old→new values and is kept indefinitely (the 90-day raw-sample retention, ZAF-689, does not apply); writing the rows is crawler telemetry over public data, so it deliberately emits no audit event. The only erasure path is opt-out (below).

The channel page's "Stats" tab renders two dated, channel-scope time series that the single-value aggregates (avgViewers, peakViewers, followerCount) cannot express: a follower-growth line with a selectable 30 / 90 / 180 / 365-day window, and an average-viewers-over-time line.

  • Read surface — GraphQL query publicChannelTrends(platform, channel, range) (primary) and REST GET /v1/public-stats/channels/{platform}/{channel}/trends?range= return the identical field set (parity), gated exactly like the channel view (the system:public_stats kill-switch plus the opt-out check) → an opted-out or unaddressable channel returns GraphQL null / REST 404. range is one of DAYS_30 | DAYS_90 | DAYS_180 | DAYS_365; an unknown value → error / 400. The result carries followerSeries ({ date, followerCount }) and avgViewerSeries ({ date, avgViewers }), each an oldest-first list of day points with date = YYYY-MM-DD (UTC). Either series is an empty list when the channel has no history in the window, so the frontend renders its existing "not enough history" state rather than a fabricated chart. Read via publicGql (SSR); no WebSocket surface.
  • Two sources, two mechanisms. The follower series comes from dated snapshots the crawler writes into public_channel_follower_history — one row per detected follower-count change (it piggybacks the public_channel_metrics write-amplification guard, so an unchanged poll writes nothing), and the read returns the last value per day. The average-viewers series is a read-model-only projection of the existing channel_history_stats_1h continuous aggregate (ZAF-681), day-bucketed and scoped to the channel's own sessions (resolved via channel_history_streams — the aggregate itself is session-keyed); the per-day value is the exact sample-weighted mean. Because that aggregate is kept indefinitely (the privacy-promised viewer-count curve), the average-viewers line covers the full 365-day window even past the 90-day raw-sample retention.
  • Aggregate counts only, no viewer identities. A follower total is a low-sensitivity, broadcaster-published aggregate; the viewer series carries only counts. Neither surfaces any viewer identity (aggregates-only privacy tripwire).
  • No §5 minimisation, no time cap, no audit event. Like the sibling change timelines (About box, ad panels), the follower-growth snapshots are kept indefinitely — a growth chart aged out is pointless — with opt-out as the sole erasure path, and writing them emits no audit event (crawler telemetry over public data).

Placements tab — panel lifecycle + image-change history (ZAF-892)

The Placements tab reads a richer, per-panel view of the ad-marked panels than the flat channel-page timeline: each panel's lifecycle and its image-change history, so the frontend can render an old→new artwork slideshow and badge a removed placement.

  • Read surface — GraphQL query publicChannelPanelHistory(platform, channel) (primary) and REST GET /v1/public-stats/channels/{platform}/{channel}/panel-history return the identical field set (parity), most-recently-active panel first. Gated exactly like the channel view — the system:public_stats kill-switch plus the opt-out check — so an opted-out or unaddressable channel returns GraphQL null / REST 404, and a channel with no ad-marked panels returns an empty list [] (never an empty shell). The channel page reads it via publicGql (SSR); there is no WebSocket surface.
  • Shape — per panel: panelId, latest content (title / description / imageUrl / linkUrl), full observed span (firstSeenAt / lastSeenAt), lifecycle (status = active/removed + removedAt), and images: [{ imageUrl, firstSeenAt, lastSeenAt }] — the distinct image versions over time, oldest-first (the slideshow). Artwork stays hotlinked from the Twitch CDN, never re-hosted.
  • Lifecycle detection — the crawler emits the channel's full current ad-marked set on every valid observation (even empty), so the writer can mark a panelId removed_at when it disappears from the set (taken down, or edited so it is no longer ad-marked — the disclosure ended) and clear it on re-observation; the marker is kept coherent across a panel's versions. The image versions themselves are already persisted (a new artwork lands as a new content_hash version row), so no separate history table is needed.
  • No §5 minimisation, no time cap, no audit event — same regime as the flat panel timeline: broadcaster-published sponsorship-disclosure data, kept indefinitely, opt-out as the only erasure path, and no audit event (crawler telemetry over public data).

Opt-out enforcement

Opt-out is enforced two-sided. On the read side, a channel with is_public = false in the registry is not served — the channel and stream reads return nothing: GraphQL null, REST 404, and no WS bootstrap. A channel with no registry row is served (public-by-default) with its leaderboard hidden (opt-in default).

On the capture side, the crawler also stops collecting the channel and erases what it already collected: it drops the channel from its roster (no further JOIN, poll, or write) and deletes that channel's already-captured source='external_crawler' rows from the history tables, every ad-marked panel version row (public_channel_panels), its follower/subscriber metrics row (public_channel_metrics), every follower-growth snapshot row (public_channel_follower_history), its About-box profile row (public_channel_profile), every About-box change-history row (public_channel_profile_history), and every stream-metadata change-history row for its broadcasts (public_stream_metadata_history) — serviceable deletion. These crawled data tables live in TimescaleDB (relocated from main Postgres, or authored there directly for the change/history tables, per the founder ruling ZAF-791/ZAF-843/ZAF-836/ZAF-890/ZAF-891); only the public_channel_settings opt-out registry stays in the main DB. So opting out both hides the channel and removes its crawled data — see the crawler developer guide.

Ad-panel timeline retention. Unlike the raw viewer/chat history (90-day sample retention, ZAF-689), the ad-marked panel timeline (public_channel_panels) is kept indefinitely: a sponsorship-disclosure record with no timeline is pointless, and the data is broadcaster-published commercial disclosure. The erasure path is opt-out (above) — an is_public = false deletes every version row. A panel that changes content adds a new version row; an unchanged panel on re-crawl only refreshes its lastSeenAt (guarded), so the table holds roughly one row per real change, not one per crawl. The panel lifecycle marker (removed_at, ZAF-892) is a column on the same table — it is set/cleared by the crawler's per-observation reconciliation (a genuine change is a write; an unchanged state is not) and erased with the row on opt-out, so it inherits the same indefinite / opt-out-only retention and needs no separate store or enforcer step.

The About-box change history (public_channel_profile_history, ZAF-836) and the stream-metadata change history (public_stream_metadata_history, ZAF-890) follow the same retention model: kept indefinitely (public broadcaster-published data, founder ruling ZAF-791 — no §5 minimisation and no 90-day cap), with opt-out as the sole erasure path. Because a history row is written only when a field actually changes (an unchanged re-crawl records nothing), the tables grow with real edits, not with crawl frequency.

GIF content data (Top GIFs) — 90-day retention, consent-gated display (ZAF-965/968/969). The Top GIFs capture is the exception to the indefinite-retention model above: it stores a content datum (the GIPHY id + image URL of the GIFs that appeared in a broadcast's public chat, with a frequency count and no sender reference — no chatter, message, or identity), so by the ZAF-965 founder decision it is bounded to a 90-day retention, aligned with the raw-sample window (ZAF-689), then auto-deleted, with opt-out as an additional erasure path. Rendering the Top GIFs loads the image directly from media*.giphy.com (the visitor's IP + user agent reach GIPHY, US); because the Stats app is public (no contract with the visitor), the embed is consent-gated — it loads only after the visitor grants the Functional consent category, honouring the cookies.mdx "loads only after consent" promise. Before consent the Top-GIFs card renders a text-only fallback (GIF name/id + occurrence count) and mounts no <img>, preconnect, or preload against the GIPHY domain, so the gate is real and not cosmetic; the GIF URL is then loaded verbatim (no proxy, no resize — Twitch requirement ZAF-960 D2). Capture + retention are implemented in ZAF-968; the privacy/cookies texts and the retention figure are governed by ZAF-969 (apps/web/content/{de,en}/legal/privacy.mdx content class (D) and …/cookies.mdx §"Lumio Stats"); the consent-gated public display lands in ZAF-970 (apps/stats — the gif_count stat tile and the TopGifs card on the stream page).

Cross-channel browse & search (Form B)

Beyond the single-channel and single-stream reads above, the Stats app browses and ranks across all channels — the top-bar metric nav (Watchtime, Viewers, Follower Gain, Peak, Live), a Games directory, and channel search. These queries aggregate channel_history_streams grouped by the frozen channel identity (platform, platform_channel_id) — never by account — so they index first-party and crawled channels uniformly (Form B). They are backed by dedicated cross-channel partial indexes on channel_history_streams (category, live-now, peak/avg-viewers rankings, recency, and a pg_trgm GIN index on the handle/display name for substring search).

Two reads, exposed on GraphQL + REST with identical fields, filters, pagination, and errors. WebSocket is deliberately omitted — a browse/search directory has no live-push consumer (the same justified single-protocol omission as the emote directory):

ProtocolChannel browseGames directory
GraphQLpublicChannelBrowse(platform, category, metric, live, search, page, limit)publicGames(platform, search, page, limit)
RESTGET /v1/public-stats/browseGET /v1/public-stats/games
  • metric ranks the channel list: watchtime (Σ avg-viewers·duration, the default), viewers (avg), peak, follower_gain (Σ new-followers), messages, or recent. live filters to channels streaming now; category filters to a game; search is a case-insensitive substring match on the channel handle + display name. Pagination is page-based (page 1-based, limit clamped 1..=100, plus total / total_pages).
  • Live statusis_live, the live filter, and the Games live_stream_count are derived from observation freshness, not ended_at: a stream counts as live only while it is still open and its most-recent channel_history_stats viewer sample is inside a 5-minute window. The Twitch crawler never sets ended_at on crawled rows (it just stops sampling once the channel drops from the live sweep), so an ended_at-only check would mark every ever-crawled channel permanently live and make the live filter return the whole crawled set. Freshness clears correctly for first-party and crawled channels alike, and keeps browse consistent with the channel-page live badge (ZAF-833).
  • Games returns distinct categories with per-category channel count, stream count, live-stream count, peak viewers, and watchtime.
  • Field set is the public-safe browse subset only: identity, the source badge, viewer/watchtime/message aggregates, follower gain, category, and live status. Absolute follower total is intentionally not a browse metric — it lives only on the account-keyed viewer-curve samples, not on a Form-B / account-optional row, so ranking all channels by it would silently drop crawled channels; it can be added once a non-account-keyed follower count exists.
  • Opt-out is enforced identically: a channel with is_public = false is excluded from the browse and from the Games counts. The opted-out identity set is read from the registry (main DB) and excluded from the history aggregate (TimescaleDB) — no cross-DB join.
  • The whole surface is gated by the same account-less system:public_stats kill-switch; the REST responses carry an edge-cache Cache-Control header.

Watchlist (per-user)

Signed-in visitors can favourite channels into a personal Watchlist. Unlike the public read model, this is a per-user, authenticated surface: it is scoped to the signed-in Lumio-ID viewer (the shared lumio-token session), holds no account/RBAC permission (a favourite is a personal preference, like a session or notification setting), and is not cached (per-user, session-bound).

Rows live in the primary DB table stats_watchlist, keyed by (user_id, platform, platform_channel_id). On add, the channel URL handle is resolved to the stable platform_channel_id via the same public read-model resolution the channel page uses, and channel_login / channel_display_name are denormalized onto the row so the list renders without a per-item read-model lookup.

Exposed on GraphQL + REST with identical fields, validation, and errors:

ProtocolListAddRemove
GraphQLmyWatchlistaddToWatchlist(platform, channel)removeFromWatchlist(platform, platformChannelId)
RESTGET /v1/watchlistPOST /v1/watchlist { platform, channel }DELETE /v1/watchlist { platform, platform_channel_id }

Semantics:

  • Auth: authenticated viewer only. Unauthenticated calls return UNAUTHENTICATED on GraphQL (the Stats frontend maps this to "signed out") and 401 on REST. There is no plan gate — it is a free logged-in feature.
  • Add validates the platform (Unsupported platform: …400), resolves the handle (unresolvable → Channel not found404), then performs an idempotent upsert (re-adding refreshes the denormalized login/display name). A per-user cap of 500 channels is enforced; a new add past the cap returns a 400 "Watchlist limit reached" (an idempotent re-add of an already-present channel still succeeds at the cap).
  • Remove is idempotent — it returns success (true) even when the row was already absent.
  • No WebSocket leg: a watchlist is a single user's own list, mutated only by that user's own action, with no real-time push semantics — the three-protocol rule's WS leg is deliberately N/A here.
  • No audit event: a favourite / unfavourite is personal bookkeeping with no security consequence.

Permissions

PermissionGrantsDefault roles
public-stats:readView the account's channel publicness settingsOwner, Administrator, Moderator
public-stats:editToggle publicness / leaderboard suppressionOwner, Administrator

Gated behind the feature:public_stats_page feature flag (Free and Pro plans). The flag is fail-closed: a plan with no paired plan_features row resolves to disabled.

Audit

Every dashboard publicness change emits an account-scoped audit event, chosen from the resulting state:

  • public_stats:channel_opted_out — a channel was hidden (is_public → false).
  • public_stats:channel_opted_in — a channel was re-listed (is_public → true).

The channel identity and the resulting leaderboard-suppression state are recorded in the event metadata.

The !optout command path deliberately does not emit these account-scoped events — its actor is a Twitch platform user proven by a chat badge (no Lumio user_id) and a crawled channel has no account_id, so an account-scoped event has no valid keys. Provenance is recorded on the registry row instead (opted_out_via = 'crawler_command', opted_out_by, updated_at). A system-scoped mirror event is a coordinated follow-up (it needs the audit emitter extracted into a shared crate). See the crawler developer guide.