Channel Status
The channel status system tracks which streaming channels are currently online. It is a generic utility that any feature can consume — currently used by the Spotify worker to avoid unnecessary API requests and event storage when offline.
Architecture
PostgreSQL (durable state anchor) + Redis (live snapshot + pub/sub)
channel_status is written to PostgreSQL only on real state transitions
(online / offline / upcoming / live_chat_id / title / category change). The
high-frequency live counters — viewer_count, YouTube like_count /
total_views — live in the Redis snapshot and the 60-second
stream-history sample instead of writing to
PostgreSQL every poll cycle. PostgreSQL keeps only its irreducible jobs: startup
recovery, the YouTube broadcast lifecycle (upcoming / live_chat_id), and the
durable current-state anchor.
Database
The channel_status table in PostgreSQL stores the current state per account per platform:
channel_status (
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
platform VARCHAR(50) NOT NULL, -- "twitch", "youtube", "kick", "trovo"
broadcast_id VARCHAR(255) NOT NULL DEFAULT 'default', -- per-broadcast id (multi-stream)
is_online BOOLEAN NOT NULL DEFAULT FALSE,
broadcast_status VARCHAR(20), -- "live", "upcoming", or NULL
stream_title TEXT,
category TEXT,
viewer_count INTEGER,
like_count INTEGER, -- YouTube only
total_views BIGINT, -- YouTube only
started_at TIMESTAMPTZ,
scheduled_start TIMESTAMPTZ, -- upcoming broadcast start time
live_chat_id TEXT, -- YouTube live chat ID
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (account_id, platform, broadcast_id)
)
idx_channel_status_account_online is a partial index on (account_id) WHERE is_online = TRUE, so "does this account have any online channel?" is a single index probe.
like_count / total_views are refreshed by the YouTube worker from two quota-free sources: InnerTube (primary, every 60 s per live broadcast) and, for whatever InnerTube does not deliver, the public channel Atom feed (at most every 15 minutes per channel). See Broadcast Statistics. These per-poll counter refreshes update only the Redis snapshot (via the set_online poll-refresh path), not the Postgres columns — so the Postgres viewer_count / like_count / total_views are frozen at the last transition. update_stream_data() (the Twitch poll path) is likewise Redis-only for viewer_count; a genuine stream_title / category change is still persisted, as a rare transition.
The dashboard read endpoints therefore overlay the live counters at read time — see Reading live counters.
Redis
Three Redis structures per account:
| Key | Type | Purpose |
|---|---|---|
lumio:channel_status:{account_id}:{platform} | String (JSON) | Cached status per platform (24h safety TTL) |
lumio:channel_online:{account_id} | SET | Set of online platform names (SADD/SREM/SCARD) |
lumio:channel_status:{account_id} | Pub/Sub | Status change notifications |
The online set uses SADD/SREM for idempotency — duplicate events have no effect.
Manual Connect
| Key | Type | Purpose |
|---|---|---|
lumio:spotify_manual:{account_id} | String | Manual override, TTL 30 minutes |
Rust API
The channel status service (apps/api/src/services/channel_status.rs) exposes a platform-agnostic interface:
// Core operations — every write path takes both pools plus the Redis client and
// pub/sub handle, because a transition also drives the stream-history session
// hook (TimescaleDB) and publishes a status change.
set_online(pool, tsdb, redis, pubsub, account_id, platform, &OnlineMetadata) -> Result<(), ChannelStatusError>
set_offline(pool, tsdb, redis, pubsub, account_id, platform) -> Result<(), ChannelStatusError>
set_broadcast_offline(pool, tsdb, redis, pubsub, account_id, platform, broadcast_id) -> Result<(), ChannelStatusError>
update_stream_data(...) // Twitch poll path, Redis-only for viewer_count
// Reads
get_status_with_live_counters(pool, tsdb, redis, account_id) -> Result<Vec<db::ChannelStatusRow>, ChannelStatusError>
is_any_online(redis, account_id) -> bool // Redis SCARD, no DB hit
is_platform_online(redis, account_id, platform) -> bool
get_viewer_count(...)
// Manual connect
is_manual_connect_active(redis, account_id) -> bool
manual_connect_remaining(redis, account_id) -> Option<i64>
start_manual_connect(...) / stop_manual_connect(...)
// Recovery
rebuild_redis_from_db(...) // startup: repopulate the snapshot + online set
verify_online_status(...)
// Raw row access (apps/api/src/db/channel_status.rs)
db::channel_status::get_status(pool, account_id) -> Result<Vec<ChannelStatusRow>, sqlx::Error>
db::channel_status::is_any_online(pool, account_id) -> Result<bool, sqlx::Error>
db::channel_status::get_all_online_accounts(pool) -> Result<Vec<(Uuid, Vec<String>)>, sqlx::Error>
Note the two is_any_online variants: the service one takes the Redis client and returns a plain bool (fail-open, no DB round trip); the db one takes the Postgres pool and returns a Result. Consumers on the hot path should use the service/Redis variant.
Integration with Platform Adapters
Platform adapters call the channel status module directly when detecting stream status changes:
// In the Twitch EventSub worker:
"stream.online" => {
channel_status::set_online(&db, &redis, account_id, "twitch", metadata).await?;
// ... then proceed with normal event processing
}
"stream.offline" => {
channel_status::set_offline(&db, &redis, account_id, "twitch").await?;
// ... then proceed with normal event processing
}
The same pattern applies to YouTube, Kick, and Trovo adapters.
Worker Lifecycle
A channel_status_relay background worker subscribes to lumio:channel_status:* via PSUBSCRIBE and reacts to status changes by starting/stopping the Spotify worker through the WorkerManager.
A separate 60-second poll task checks for expired manual connect keys.
Status change → channel_status_relay → WorkerManager → start/stop Spotify worker
Decision logic:
SCARD(online_set) > 0 OR manual_key exists?
├── YES + Worker not running → start_spotify_worker()
├── YES + Worker running → no-op
├── NO + Worker running → stop_spotify_worker()
└── NO + Worker not running → no-op
Server Start Recovery
On startup, the API server:
- Reads
channel_statusfrom PostgreSQL for all accounts - Rebuilds Redis keys (cache + online sets)
- Starts Spotify workers only for accounts with at least one online channel
GraphQL API
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 SpotifyManualStatus {
active: Boolean!
remainingSeconds: Int
}
type Query {
channelStatus: [ChannelStatus!]!
spotifyManualStatus: SpotifyManualStatus!
}
type Mutation {
startSpotifyManual: SpotifyManualStatus!
stopSpotifyManual: Boolean!
}
All queries/mutations use account_id from AuthContext.
REST API
| Method | Path | Description |
|---|---|---|
GET | /v1/channel-status | Status for authenticated account |
GET | /v1/spotify/manual-connect | Manual connect status + remaining time |
POST | /v1/spotify/manual-connect | Start manual connect (30min TTL) |
DELETE | /v1/spotify/manual-connect | Stop manual connect |
All routes support popout token auth.
Reading live counters
Because the live counters are frozen in Postgres after the slim-down, both read
endpoints (channelStatus and GET /v1/channel-status) go through one shared
service function, get_status_with_live_counters, which overlays the fresh
values onto each online row:
- the Redis snapshot (
lumio:channel_status:{account_id}:{platform}) — the authoritative live value; - failing that (a cold cache after a Redis loss), the most recent
channel_history_statssample (≤ 60 s stale).
GraphQL and REST share the same function, so the two protocols never drift.
WebSocket Events
channel_status_relay re-publishes every status change onto the account's event channel (lumio:events:{account_id}, built by channels::events). The envelope is { "type": ..., "data": ... }, where type is derived from is_online in the payload:
channel:online—data={ platform, is_online: true, stream_title, category, viewer_count, started_at }channel:offline—data={ platform, is_online: false, stream_title: null, category: null, viewer_count: null, started_at: null }
The field is category, not game_name — the column was renamed in 20260326000004_rename_game_name_to_category.
Adding New Consumers
To use channel status in a new feature:
- Import
api::services::channel_status - Call
is_any_online(redis, account_id)for a fast boolean check (RedisSCARD, fail-open) - Or
PSUBSCRIBE lumio:channel_status:*(or subscribe to a single account'slumio:channel_status:{account_id}) for reactive updates
The service itself needs no change to add a read-only consumer. A consumer that must also start or stop a worker on transitions belongs in channel_status_relay, next to the Spotify evaluation — that is where the PSUBSCRIBE loop and the WorkerManager handle already live.