ProfileService
Overview
The ProfileService is a unified platform user enrichment system that combines database records with real-time platform API data. It provides a single get_profile() method that returns a UnifiedProfile for any platform user, transparently handling caching, enrichment staleness, and API failure protection via a per-account+platform circuit breaker.
Architecture
API / Chat Worker
|
v
ProfileService.get_profile(account_id, platform, platform_user_id, credentials?)
|
+-- 1. Redis cache check (lumio:user_profile:{account}:{platform}:{user})
| Hit? --> Return cached UnifiedProfile
|
+-- 2. Database lookup (platform_users table)
| Found? --> UnifiedProfile::from_db(row)
| Not found? --> UnifiedProfile::empty(platform, user_id)
|
+-- 3. Enrichment check (enriched_at older than the platform's interval, or never?)
| |
| +-- Circuit breaker open? --> Cache with 15min TTL, return DB data
| |
| +-- No credentials? --> Cache with 30min TTL, return DB data
| |
| +-- Cold-start lock (SETNX) --> loser waits 250ms and re-reads the cache
| |
| +-- Enrich via platform API
| |
| +-- Success --> Apply enrichment, persist to DB, reset failure counter, cache with 4h TTL
| |
| +-- Failure --> Record failure (circuit breaker), cache with 30min TTL
|
v
UnifiedProfile
Cold-Start Lock
Before enriching, the service takes a Redis SETNX lock on
{cache_key}:lock with a 5-second TTL. On a hot stream many concurrent lookups
of the same chatter would otherwise all miss the cache and stampede the platform
API. The loser of the race sleeps 250 ms and re-reads the cache; if the cache is
still empty it proceeds with its own enrichment rather than returning an empty
profile. The lock is deleted as soon as the enrichment attempt returns, whether
it succeeded or failed.
Multi-Platform Enrichment
Each platform has a dedicated enrichment method with different data coverage:
| Platform | Avatar | Bio/Description | Account Age | Broadcaster Type | Follower Status | Follower Count |
|---|---|---|---|---|---|---|
| Twitch | Yes | Yes | Yes | Yes | Yes (requires scope) | Yes |
| YouTube | Yes | Yes | Yes | -- | -- | -- |
| Kick | Yes | Yes | -- | -- | -- | Best-effort |
Follower Status is whether the user follows your channel; Follower Count is how many users follow them.
Twitch provides the richest enrichment through the Helix API:
- User info (
GET /users): avatar, description, broadcaster type, account creation date. A failure here fails the enrichment. - Follower check (
GET /channels/followers?broadcaster_id=<your channel>&user_id=<user>): requiresplatform_channel_idin credentials; non-fatal if the scope is missing. - Follower count (
GET /channels/followers?broadcaster_id=<user>&first=1→total): the user's own follower count, stored asfollower_count. Needs no extra scope - without moderator rights on the user's channel Helix still returnstotal. Non-fatal: a failure leaves the stored value untouched.
YouTube uses the YouTube Data API to fetch channel snippet data (thumbnails, description, published date). The channels call requests only snippet, so no subscriber count is stored.
Kick makes two independent calls to its public API, concurrently: GET /public/v1/users?id=<user> (avatar) and GET /public/v1/channels?broadcaster_user_id=<user> (channel_description → bio; blank counts as no bio). Each failure is logged at warn with its HTTP status and ignored, so a failing user lookup no longer discards the bio. The enrichment only fails - and counts toward the circuit breaker - when both calls fail (e.g. a revoked token).
The official Kick API exposes no follower count, and its channel_description is often empty even when the channel's "About" panel on kick.com shows a bio - that text is the user's bio. When the channel call returned the channel's slug, the enricher makes a third, best-effort call to Kick's undocumented website API, GET https://kick.com/api/v2/channels/<slug>, and reads followers_count and user.bio. The bio is used only when the official channel_description is empty; a non-empty official description always wins. That endpoint has no contract and sits behind Cloudflare bot protection, so the Kick follower count - and the bio fallback - may be absent:
- No OAuth token is sent (it is a public website endpoint), and the call has a 5 s timeout.
followers_countorfollowersCountis accepted, as a number or a numeric string (Kick currently serves a string). Any other shape counts as "no count".- A blank
user.biocounts as no bio. - Any failure - a
403challenge, a timeout, a changed response - is logged and leavesfollower_countand the bio fallback empty (previously stored values are kept). It never fails the enrichment and never counts toward the circuit breaker, which only guards the official API.
Caching Strategy
All profiles are cached in Redis under the key lumio:user_profile:{account_id}:{platform}:{platform_user_id}.
| Scenario | TTL | Description |
|---|---|---|
| Enrichment succeeded | 4 hours (CACHE_TTL_SUCCESS = 14400s) | Full profile with fresh API data |
| Enrichment failed, or no credentials supplied | 30 minutes (CACHE_TTL_DB_FALLBACK = 1800s) | Shorter TTL to re-check sooner |
| Circuit breaker open | 15 minutes (CACHE_TTL_CIRCUIT_OPEN = 900s) | Minimal TTL during API outage |
| Enrichment still fresh | 4 hours | No re-enrichment needed |
Enrichment Staleness
Enrichment is triggered when enriched_at is None (never enriched) or older than the platform's configured interval - 1 day for Twitch and Kick, 14 days for YouTube by default (see Enrichment Intervals). With the interval set to 0 the staleness check reduces to "has this profile ever been enriched?", so only first-time enrichment runs automatically.
Circuit Breaker
The circuit breaker protects against cascading failures when a platform API is down or rate-limited. It operates per account+platform combination.
Configuration
| Parameter | Value | Description |
|---|---|---|
| Failure threshold | 5 | Failures within the window to trip the breaker |
| Failure window | 60 seconds | Rolling window for counting failures |
| Cooldown | 300 seconds (5 min) | How long the circuit stays open |
Mechanism
- Each enrichment failure increments a Redis counter (
lumio:circuit_count:{account}:{platform}). - The counter expires after the failure window (60s) if no further failures occur.
- When the counter reaches 5, a cooldown key (
lumio:circuit:{account}:{platform}) is set with a 5-minute TTL. - While the cooldown key exists,
is_circuit_openreturnstrueand enrichment is skipped. - A successful enrichment calls
record_success, which clears the failure counter so a near-trip state does not carry forward. - After the cooldown expires, enrichment attempts resume.
UnifiedProfile
The UnifiedProfile struct combines database fields with enrichment data:
Database Fields
platform,platform_user_id,username,display_nameavatar_url,color(chat color)is_mod,is_sub,is_vip,badgesmessage_count,first_seen_at,last_seen_atis_banned,banned_at,banned_by,ban_reason,ban_type,timeout_expires_atuser_treatment,treatment_updated_at,treatment_updated_by
Enrichment Fields
description-- User bio from platform APIbroadcaster_type-- Twitch-specific (partner, affiliate, etc.)is_follower,followed_at-- Follower relationship to the channelaccount_created_at-- When the platform account was createdfollower_count-- The user's own follower count (Twitch; Kick best-effort via an undocumented endpoint;NULLon YouTube). Stored inplatform_users.follower_count; the enrichment upsert usesCOALESCE, so a failed lookup never erases a known countenriched_at-- Timestamp of last successful enrichment
Enrichment Merge
apply_enrichment() only overwrites fields that have Some values in the enrichment data, preserving existing database values for fields the platform does not provide.
Credentials
The ChannelCredentials struct is provided by the caller and contains decrypted OAuth tokens:
struct ChannelCredentials {
client_id: String, // Platform app client ID
client_secret: String, // Platform app client secret
access_token: String, // Channel's OAuth access token
refresh_token: Option<String>, // Channel's OAuth refresh token
platform_channel_id: Option<String>, // Broadcaster ID (for follower checks)
}
Credentials are loaded and decrypted by the API layer (apps/api) from the app_credentials and channel_connections tables, keeping the crypto module decoupled from lo-chat.
Manual Refresh
Users with the chat:refresh_user permission can manually trigger a profile re-enrichment from the refresh control in the user info card's header (apps/web/src/components/user-card/user-card-header.tsx, gated in apps/web/src/components/info-user-modal.tsx), which is rendered only when the viewer holds that permission. This evicts the Redis cache entry, resets the row's enriched_at via reset_enriched_at, and forces an immediate enrichment call against the platform API.
API
| Protocol | Operation | Feature | Permission |
|---|---|---|---|
| GraphQL | refreshPlatformUserProfile(platform: String!, platformUserId: String!): GqlUnifiedProfile! | feature:multichat | chat:refresh_user |
| REST | POST /v1/chat/users/{platform}/{platform_user_id}/refresh | feature:multichat | chat:refresh_user |
Rate Limiting
Manual refresh is rate-limited to once every 10 minutes per (account, platform, user) triple. The limit is enforced via a Redis SETNX cooldown key - lumio:user_profile:{account_id}:{platform}:{platform_user_id}:refresh_cooldown - with a 600-second TTL.
Both protocols return the same code with the remaining seconds, in their own envelope:
- REST: HTTP
429with{ "error": "REFRESH_COOLDOWN", "retry_after_seconds": <n> } - GraphQL: an error with message
Refresh cooldown activeand extensionscode: "REFRESH_COOLDOWN",retryAfterSeconds: <n>
Platform Support
Manual refresh is available for the supported chat platforms:
| Platform | Enrichment triggered |
|---|---|
| Twitch | Full enrichment (avatar, bio, account age, broadcaster type, follower status, follower count) |
| YouTube | Avatar, bio, account creation date |
| Kick | Avatar, bio, follower count (best-effort, undocumented endpoint) |
Enrichment Intervals
Auto-enrichment intervals are configurable via the [profile] TOML section:
[profile]
# YouTube (days). 0 = disabled (manual refresh only). Default: 14.
youtube_enrichment_interval_days = 14
# Twitch, Kick (days). 0 = disabled (manual refresh only). Default: 1.
enrichment_interval_days = 1
ENV overrides: LUMIO__PROFILE__YOUTUBE_ENRICHMENT_INTERVAL_DAYS, LUMIO__PROFILE__ENRICHMENT_INTERVAL_DAYS.
| Platform | Default interval | Config key |
|---|---|---|
| Twitch | 1 day | enrichment_interval_days |
| Kick | 1 day | enrichment_interval_days |
| YouTube | 14 days | youtube_enrichment_interval_days |
Setting an interval to 0 disables automatic re-enrichment for that platform - profiles are only enriched on first view and via the manual refresh button. First-time enrichment (user never enriched before) always runs regardless of the interval setting.
YouTube's longer default interval reflects the YouTube Data API quota cost per enrichment call. Manual refresh still resets the enriched_at timestamp regardless of platform.
Key Files
| File | Purpose |
|---|---|
crates/lo-chat/src/profile_service.rs | ProfileService implementation, enrichment, cold-start lock, circuit breaker |
crates/lo-chat/src/platform_users.rs | Database operations for platform_users table, including reset_enriched_at |
apps/api/src/graphql/chat.rs | refreshPlatformUserProfile mutation |
apps/api/src/routes/chat.rs | POST /v1/chat/users/{platform}/{platform_user_id}/refresh |
apps/web/src/components/info-user-modal.tsx | User info card; renders the manual refresh control only with chat:refresh_user |
crates/lo-twitch-api/ | Twitch Helix API client (used for Twitch enrichment) |
crates/lo-youtube-api/ | YouTube Data API client (used for YouTube enrichment) |
crates/lo-kick-api/ | Kick API client (used for Kick enrichment) |