Channel Status
Overview
The channel status module tracks live stream status per platform for each account. It stores whether a channel is online, along with metadata such as stream title, category, viewer count, and stream start time. Status is updated by platform workers (Twitch, YouTube, Kick) and includes a manual Spotify connect feature for controlling the Spotify polling worker independently of stream status.
Architecture
Platform Workers (Twitch/YouTube/Kick)
|
+--> db::channel_status::set_online / set_offline / update_stream_data
|
v
channel_status table (PostgreSQL)
^
|
Dashboard UI
|
v
Next.js API Proxy
|
v
GraphQL (ChannelStatusQuery / ChannelStatusMutation)
|
+--> db::channel_status::get_status
+--> services::channel_status (manual connect via Redis)
Status Updates
Platform workers poll for stream status changes and update the database:
- Going online --
set_online()upserts a row withis_online = TRUEand metadata (title, category, viewer count, start time). - Going offline --
set_offline()setsis_online = FALSEand clearsstarted_at. - Data updates --
update_stream_data()updates viewer count, title, and category for channels that are already online.
Spotify Manual Connect
The Spotify worker normally polls only when a channel is online. The manual connect feature allows users to start Spotify polling independently, using a Redis key with a 30-minute TTL. This is useful for pre-stream setup or listening sessions without going live.
- Start -- Sets a Redis key and publishes a synthetic status change event via Redis pub/sub.
- Stop -- Deletes the Redis key and publishes a status change event.
- Query -- Returns whether manual connect is active and the remaining seconds.
Spotify Worker Lifecycle (relay)
The channel-status relay (apps/api/src/workers/channel_status_relay.rs) owns the Spotify worker's start/stop lifecycle. It subscribes to lumio:channel_status:* and, on every status change, evaluates whether the worker should run for that account:
- Should run when any channel is online or a manual connect is active. On a
should-runtransition the relay resolves the worker config from the livechannel_connectionsrow on demand (resolve_spotify_config) and starts the worker with the connection's currentconnection_id. There is no boot-time config snapshot, so a Spotify connection linked, re-authorized, or disconnected-and-reconnected after the API booted starts polling on the next stream-start or manual connect -- without an API restart. An account with no Spotify connection resolves nothing and is simply not started. - Should not run (no channel online, no manual connect) tears the worker down. A separate 60-second poll also stops any worker whose manual connect has expired.
This is why stopping the worker at stream end / manual-connect expiry is safe: it always restarts from the DB on the next trigger. Note the emission asymmetry: only the worker produces spotify:track; spotify:skip/play/pause/… come from the playback-control path and do not depend on the worker running.
API
GraphQL Queries
| Query | Args | Returns | Permission |
|---|---|---|---|
channelStatus | -- | [ChannelStatus] | connections:read |
channelSchedule | from: DateTime, to: DateTime | [ScheduledStream!]! | connections:read |
spotifyManualStatus | -- | SpotifyManualStatus | spotify:read |
channelStatusreturns all platform statuses for the active account.channelSchedulereturns the stream plan of the account's connected channels — see Stream plan.spotifyManualStatuschecks the Redis TTL key and returns active status with remaining seconds.
GraphQL Mutations
| Mutation | Args | Returns | Permission |
|---|---|---|---|
startSpotifyManual | -- | SpotifyManualStatus | spotify:worker |
stopSpotifyManual | -- | Boolean | spotify:worker |
REST Endpoints
All paths live under /v1.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/channel-status | connections:read | All platform statuses for the active account |
GET | /v1/channels/schedule?from=&to= | connections:read | Stream plan of the connected channels (twin of channelSchedule, body { "data": [ScheduledStream] } in snake_case) |
GET | /v1/spotify/manual-connect | spotify:read | Manual-connect status + remaining seconds |
POST | /v1/spotify/manual-connect | spotify:worker | Start manual Spotify polling (30 min TTL) |
DELETE | /v1/spotify/manual-connect | spotify:worker | Stop manual Spotify polling |
GraphQL Types
type ChannelStatus {
platform: String!
broadcastId: String!
isOnline: Boolean!
broadcastStatus: String
streamTitle: String
category: String
viewerCount: Int
likeCount: Int
totalViews: Int
startedAt: DateTime
scheduledStart: DateTime
liveChatId: String
updatedAt: DateTime!
}
type ScheduledStream {
platform: String! # youtube | twitch | kick
title: String! # "" when the platform has none
category: String
startsAt: DateTime!
endsAt: DateTime # null when the platform has no end (YouTube always)
isRecurring: Boolean!
url: String! # YouTube watch page, Twitch/Kick channel page
source: String! # youtube_broadcast | twitch_schedule | kick_schedule
}
type SpotifyManualStatus {
active: Boolean!
remainingSeconds: Int
}
Stream plan
channelSchedule(from, to) / GET /v1/channels/schedule?from=&to= lists the planned broadcasts of the account's connected channels, starting inside from..to and sorted by startsAt. Both protocols share the guard (connections:read), the validation and the messages:
fromdefaults to now,totofrom+ 30 days (RFC 3339).Bad request: `to` must be after `from`whento <= from;Bad request: The window from `from` to `to` must not exceed 60 dayswhen the window is longer. CodeBAD_REQUESTon both protocols; REST answers400.
| Platform | Source | source | Notes |
|---|---|---|---|
| YouTube | channel_status rows the YouTube worker writes for discovered public scheduled broadcasts (broadcast_status = "upcoming", scheduled_start) | youtube_broadcast | One entry per scheduled broadcast with a known start time; a chat ID is not required. url is the watch page; no end time; never recurring. A broadcast that went live becomes a live row and drops out. |
| Twitch | Helix GET /schedule (official, no scope) with the channel connection's token | twitch_schedule | Recurring segments arrive expanded. Cancelled occurrences (canceled_until) and segments inside the vacation window are dropped. No schedule (404) is an empty plan. |
| Kick | web.kick.com/api/v1/channels/{channel_id}/calendar/events?from_utc=&to_utc= — undocumented, the data behind the "Schedule" tab on kick.com; no token | kick_schedule | The official Kick API has no schedule endpoint. Recurring events arrive expanded (event_type = RECURRING). The kick.com channel id is looked up from the slug (channel_login). A format change or an unknown channel is an empty plan. |
Twitch and Kick are fetched for the next 61 days and cached per channel in Redis (lumio:channel_schedule:{platform}:{channel_id}) for 10 minutes; a failed fetch is cached as an empty plan for 2 minutes. Entries later than 61 days from the fetch are not returned. Every failure (platform down, no usable token, reconnect required, Redis unavailable) only empties that channel — it never fails the list. Tokens refresh only through get_fresh_connection_token.
While a platform is live, the plan entry covering the current time on that platform is left out (startsAt <= now < endsAt; without an end, any past start). There is no WebSocket channel for the plan: clients reload it on page load and on the platform online/offline events. Reading the plan emits no audit event. The counter channel_schedule_fetches_total{platform, outcome} (cache_hit, ok, no_token, no_slug, error) is exposed on the internal /metrics.
Permissions
| Permission | Description |
|---|---|
connections:read | View channel status and the stream plan |
spotify:read | View the Spotify manual connect status |
spotify:worker | Start/stop Spotify manual connect |
Database
Table: channel_status
| Column | Type | Description |
|---|---|---|
account_id | UUID (PK, FK) | Account ID |
platform | TEXT (PK) | Platform identifier |
broadcast_id | TEXT (PK) | Broadcast/stream identifier (per-broadcast rows for multi-stream) |
is_online | BOOLEAN | Whether the channel is currently live |
broadcast_status | TEXT NULL | "live", "upcoming", or NULL (non-YouTube platforms) |
stream_title | TEXT | Current stream title |
category | TEXT | Current stream category/game |
viewer_count | INT | Current viewer count |
like_count | INT NULL | Current like count (YouTube only) |
total_views | BIGINT NULL | Lifetime view count (YouTube only) |
started_at | TIMESTAMPTZ | When the stream started |
scheduled_start | TIMESTAMPTZ NULL | When an upcoming broadcast is scheduled to start |
live_chat_id | TEXT NULL | YouTube live chat ID for this broadcast |
updated_at | TIMESTAMPTZ | Last status update |
Primary key: (account_id, platform, broadcast_id) -- one status row per broadcast per platform per account, using upsert on conflict.
DB Functions
| Function | Description |
|---|---|
set_online | Upsert with is_online = TRUE and metadata (title, viewer count, like count, total views, scheduled start, live chat ID) |
set_offline | Set is_online = FALSE, clear started_at |
upsert_upcoming | Upsert an upcoming broadcast (is_online = FALSE) with title and scheduled start when available; the live chat ID may be absent until resolution succeeds |
delete_broadcast | Delete a specific broadcast row (used when a broadcast disappears from discovery) |
get_status | Get all statuses for an account, ordered by platform |
is_any_online | Check if any platform is online (DB fallback) |
update_stream_data | Update viewer count, title, category for online channels |
get_all_online_accounts | Boot recovery: all accounts with online channels, grouped |
Redis Keys
| Key | TTL | Description |
|---|---|---|
lumio:spotify_manual:{account_id} | 30 min | Manual Spotify connect active flag |
lumio:channel_schedule:{platform}:{channel_id} | 10 min (failed fetch: 2 min) | Cached Twitch/Kick stream plan |
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/channel_status.rs | GraphQL queries and mutations |
apps/api/src/db/channel_status.rs | Database operations |
apps/api/src/services/channel_status.rs | Manual connect logic (Redis TTL, pub/sub) |
apps/api/src/services/channel_schedule.rs | Stream plan: sources, window, cache |