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
| Permission | Grants |
|---|---|
history:read | View the session list, reports and time-series stats |
history:share | Create, extend and revoke shareable report links |
history:export | Export reports (CSV / PDF / TXT / JSON) |
history:delete | Delete 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 chart —
peak,low(excluding the near-zero first/last sample), and amedian"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_metricsnamespaced),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.
Shareable links
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
401with 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)
| Operation | Permission |
|---|---|
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.
| Method | Path | Permission |
|---|---|---|
GET | /v1/history | history:read |
GET | /v1/history/filter-options | history:read |
GET | /v1/history/reports/{id} | history:read |
GET | /v1/history/reports/{id}/stats | history: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-links | history:share |
POST | /v1/history/reports/{id}/shared-links | history: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.