Skip to main content

Stream History

Overview

Stream History records every stream session — a run that can span several simultaneous platform broadcasts ("multistream") — and turns the raw event and chat data already collected in TimescaleDB into per-session reports: viewer curves, follower/subscriber growth, chat, emote and GIF totals, donations, and a per-platform breakdown.

The feature is platform-neutral by design: adding a platform (Kick, Trovo, a new one) never changes the schema or the API. Core metrics are fixed fields; anything platform-specific rides in a platform_metrics JSON object with namespaced keys.

Stream History is gated behind the feature:stream_history feature flag (enabled for the Free and Pro plans) and the history:* permissions.

Session list & filtering

The list is paginated (25 sessions per page by default, newest first) and filterable. perPage / per_page overrides the page size and is clamped to 1..=100. The channelHistory query / GET /v1/history endpoint accept optional, freely combinable filters — from/to (a startedAt range), sessionType (single/multi), platform (a session with a broadcast on that platform), search (case-insensitive substring over the session or any stream title), and category (session or stream category). total always counts the filtered set so pagination stays correct. The dropdown values come from channelHistoryFilterOptions / GET /v1/history/filter-options, which returns the distinct platforms and categories actually present in the account's history — never a hard-coded list.

The platform, category and type dropdowns each carry a search box pinned at the top of the open menu (auto-focused, case-insensitive substring match over the option labels), so an account with many distinct categories can narrow the list by typing instead of scrolling. It shows a "no matches" empty state when nothing matches and clears when the menu closes.

The list is also sortable: sortBy / sort_by chooses the column (started_at, duration_secs, peak_viewers, avg_viewers, total_messages, unique_chatters, new_followers) and sortDir / sort_dir the direction (asc/desc). The default stays started_at descending (newest first). The field is enum-validated — an unknown sortBy/sortDir is rejected with the same error on both protocols. The three nullable stat columns (duration_secs, peak_viewers, avg_viewers) sort NULLS LAST, so a still-live session whose finals are not yet computed never floats to the top.

Permissions

PermissionGrants
history:readView the session list, reports and time-series stats
history:shareCreate, extend and revoke shareable report links
history:exportExport reports (CSV / PDF / TXT / JSON)
history:deleteDelete stored sessions

Owner and Administrator hold all four; Moderator holds history:read + history:export; Viewer holds none.

Reports

A report is built from a session's denormalized aggregates, its per-platform stream rows (the stream table — one row per platform broadcast, each with its own title), and its 60-second time-series samples.

Shared Chat sources

Each stream row carries shared_chat_sources — the Twitch Shared Chat source attribution (ZAF-819/868). It is a list of the channels that contributed shared-chat messages to that broadcast, each entry an aggregate { platform_channel_id, message_count, display_name, avatar_url } resolved at read time from the crawler channel-profile store (display_name / avatar_url are null when the channel's identity was never captured — the UI falls back to the id). The list is empty (null) for a normal broadcast; only the public-stats crawler path populates it today.

The web report renders it as a Shared Chat strip beneath the relevant stream row — a purple badge plus one avatar-and-name chip per source channel with its message count — matching the live-chat shared-chat styling. It is channel-level only (broadcaster identities, never individual viewers). The field is exposed on GraphQL (HistoryStreamDetail.sharedChatSources), the REST report DTO, and the shared-report DTO, so authenticated reports and shared links render the same indicator.

Metrics & monetization

Monetization categories never mix. Each is surfaced as its own metric on the report cards, the per-platform sections, the exports and the summary e-mail:

  • Donations are StreamElements tips only and are labelled "Donations (StreamElements)" everywhere so they are not confused with platform-native monetization.
  • Bits (Twitch), Super Chats and Super Stickers (YouTube) are their own counts (Super Chats/Stickers also carry an account-only value); they are never folded into donations.
  • Raids — incoming raids are counted (raids) together with the audience they brought (raid viewers), for Twitch and Trovo.

Platform-aware terminology: on YouTube, followers are shown as Subscribers and paid subs as Members (de: Abonnenten / Mitglieder). Per-platform sections always use the platform-correct term; a single-platform YouTube session also reads Subscribers/Members in the headline cards and the e-mail prose, while a multi-platform session keeps neutral aggregate labels.

Chart markers

The server computes chart markers once and ships them in the report DTO, so the web charts, the summary e-mail and the PDF export all render identical values:

  • Viewer chartpeak, low (excluding the near-zero first/last sample), and a median "settles at" line (robust against a raid spike), per platform and for the cross-platform total.
  • Follower/sub chart — cumulative, so its markers are the net gain over the session and the steepest rise (largest jump between two samples).

Long sessions are downsampled with LTTB (Largest-Triangle-Three-Buckets), which preserves peaks and troughs far better than naive decimation.

Export

A finalized report can be downloaded in four formats, all rendered from the same report DTO (so the numbers match the report page), behind history:export:

  • CSV — a ZIP with summary.csv (flat key/value, platform_metrics namespaced), streams.csv, timeseries.csv, top_chatters.csv, top_emotes.csv, top_gifs.csv.
  • TXT — a human-readable summary for a Discord/forum paste.
  • JSON — the full DTO (pretty-printed, schema_version, platform_metrics).
  • PDF — a print-themed one-pager: title, summary table and the embedded viewer + follower charts (light theme) with the same peak / median / steepest-rise markers as the report page.

CSV cells are hardened against spreadsheet formula injection: a value that would otherwise begin with =, +, -, @, a tab or a carriage return — a chatter display name, an emote name, a stream title or category — is written with a leading apostrophe, so Excel and LibreOffice show it as text instead of evaluating it. Plain numbers (negatives like -42 included) are untouched.

Exports are platform-neutral: adapter-specific numbers ride in the namespaced platform_metrics, so a new platform needs no export changes. Only finalized sessions export (a live one returns 409). See GET /v1/history/reports/{id}/export?format= in the REST reference.

Stream summary email

When a session is finalized (its grace window closes), Lumio sends the account owner a summary of the stream. It carries a small viewer-trend chart — rendered server-side as an inline PNG in the dark Lumio e-mail theme, sharing the same plotters pipeline and the same peak marker + median "settles at" line as the report — followed by a generic section list (audience, growth, and one section per platform, built the same platform-neutral way as the rest of the feature, so a new platform appears with no template change), a short written recap, and a button to the full report. The growth section surfaces bits, Super Chats/Stickers and raids as their own rows (never mixed into donations, which are labelled StreamElements tips), and uses the platform-aware Subscribers/Members terminology for single-platform YouTube sessions.

The summary is sent only when all of these hold:

  • the session lasted at least 5 minutes (test streams are skipped),
  • e-mail delivery is enabled for the deployment (email.enabled), and
  • the owner has not opted out (below).

Opt-out

The summary reuses the standard notification-preference system under the stream_summary notification type, with three channels — off, in-app only, or in-app + e-mail (the default). Owners change it in Settings → Notifications. The in-app notification is delivered whenever the channel is not off, independent of whether e-mail is on.

A report can be shared via a time-limited token link (lm_share_*). Durations are 1h / 6h / 24h / 7d / 30d, with up to 20 active links per report. Links may optionally carry:

  • a password (argon2-hashed; a wrong password returns 401 with no existence leak), and
  • an export toggle (allow_export, default off): whether anonymous viewers may download the report exports.

The plaintext token is shown exactly once at creation. Public viewers open the read-only page at /share/history/{token}, which resolves the report through GET /v1/history/shared/{token}; the endpoint is anonymous and IP-rate-limited, and returns the report plus its time-series samples so the public page renders the same charts as the owner's. A password-protected link shows a password prompt first; the password is sent in the X-Share-Password request header, never in the URL or query string, so it does not end up in access logs. A wrong password, an expired link, and a revoked link are indistinguishable (all 401). When allow_export is on, public viewers can also download the report via GET /v1/history/shared/{token}/export?format= in the same four formats, passing the same header.

Owners list a report's links (active and historical) via historySharedLinks / GET /v1/history/reports/{id}/shared-links, all behind history:share.

Live updates

While a session is live it appears in the list with a live badge, and the WebSocket history:{account_id} channel emits history:session_started, history:session_updated and history:session_ended events for live refresh. channel_gate_for maps the channel to ChannelGate::Permission("history:read") and channel_feature_for to feature:stream_history, so both must hold to subscribe.

API

Stream History is exposed on all three protocols with identical fields, guards, validation and errors.

GraphQL (primary)

OperationPermission
channelHistory(page, perPage, from, to, sessionType, platform, search, category, sortBy, sortDir): HistorySessionPage!history:read
channelHistoryFilterOptions: HistoryFilterOptions!history:read
channelHistoryReport(id: UUID!): HistoryReportGql!history:read
channelHistoryStats(sessionId: UUID!, platform: String): [HistoryStatSample!]!history:read
historySharedLinks(sessionId: UUID!): [HistorySharedLink!]!history:share
exportHistoryReport(sessionId: UUID!, format: String!): HistoryReportExport!history:export
createHistorySharedLink(sessionId: UUID!, durationSecs: Int!, password: String, allowExport: Boolean): CreateHistorySharedLinkPayload!history:share
extendHistorySharedLink(linkId: UUID!, durationSecs: Int!): HistorySharedLink!history:share
revokeHistorySharedLink(linkId: UUID!): Boolean!history:share
deleteChannelHistory(id: UUID!): Boolean!history:delete

exportHistoryReport does not stream bytes over GraphQL: it applies the same guard, feature gate and 400 (bad format) / 404 (not found) / 409 (still live) validation as REST, then returns the authenticated REST download URL (GET /v1/history/reports/{id}/export?format=…), so both protocols share one error picture.

REST

Every owner route below is additionally gated on feature:stream_history.

MethodPathPermission
GET/v1/historyhistory:read
GET/v1/history/filter-optionshistory:read
GET/v1/history/reports/{id}history:read
GET/v1/history/reports/{id}/statshistory:read
GET/v1/history/reports/{id}/export?format=history:export
DELETE/v1/history/reports/{id}history:delete (returns 204)
GET/v1/history/reports/{id}/shared-linkshistory:share
POST/v1/history/reports/{id}/shared-linkshistory:share (returns 201)
PATCH/v1/history/shared-links/{id}history:share
DELETE/v1/history/shared-links/{id}history:share (returns 204)
GET/v1/history/shared/{token}anonymous, IP-rate-limited
GET/v1/history/shared/{token}/export?format=anonymous, IP-rate-limited, requires allow_export

REST params and payloads are snake_case (session_type, sort_by, sort_dir, per_page, duration_secs, allow_export, started_at, peak_viewers, platform_metrics). These routes carry no utoipa::path annotations, so they are not present in apps/api/openapi.json — the table above is the contract.

WebSocket

The history:{account_id} channel, as described under Live updates.

See the GraphQL, REST and WebSocket references for details.