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.
New Redis keyspaces and invalidation paths follow the data design standard.
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"
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); 7-day safety TTL, refreshed on every online write and on the startup rebuild |
lumio:channel_status:{account_id} | Pub/Sub | Status change notifications |
The online set uses SADD/SREM for idempotency - duplicate events have no effect. Its TTL only bounds a missed offline transition: Twitch goes live from a single EventSub event and is not polled, so the TTL has to outlast a multi-day stream rather than a poll interval.
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 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
A channel in its dissolution grace period never gets a Spotify worker: the
relay checks dissolution_scheduled_for before it starts one and fails closed
on a database error. All platform workers of such a channel are stopped when
the dissolution is scheduled; see
Accounts › Suspension.
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 (and none for a channel in its dissolution grace period)
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.