Spotify / Music
Overview
The Spotify module integrates with the Spotify Web API to provide "Now Playing" overlays and a full dashboard music player. It supports playback control (play, pause, next, previous, seek, volume, shuffle, repeat), queue management, playlist browsing and playback, device selection and transfer, and track search. Spotify credentials are stored encrypted per-account. Access tokens are kept fresh by the centralized Token Refresh Worker - request handlers read a ready-to-use token via oauth::get_fresh_connection_token and never refresh inline. All playback actions are logged as events in TimescaleDB and broadcast via Redis pub/sub for real-time overlay updates. The Now-Playing worker polls adaptively: every 5 seconds while a track is playing and every 30 seconds when paused, idle, or after an error.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/spotify.rs) -- Queries for combined Spotify state and track search. Mutations for playback control, queue management, playlist playback, and device transfer. - Crate (
crates/lo-spotify-api/src/) --SpotifyClientwraps the Spotify Web API with methods for now_playing, queue, devices, playlists, search, play, pause, next, previous, seek, volume, shuffle, repeat, add_to_queue, transfer_playback, play_playlist. ItsSpotifyClient::refresh_token()is#[deprecated]and reserved for the initial OAuth code exchange, which has no DB row to read yet - internal callers must useoauth::get_fresh_connection_tokeninstead, so refreshes cannot race the Token Refresh Worker. - Worker (
apps/api/src/workers/spotify.rs) -- Polls the Now-Playing endpoint atPLAYING_POLL_INTERVAL_SECS(5 s) /IDLE_POLL_INTERVAL_SECS(30 s), honours aRetry-Afteron rate limits, and backs off to the idle interval after 5 consecutive errors. - Credentials -- Stored in PostgreSQL via
db::spotify::get_spotify_credentials(). Client ID, client secret, access token, and refresh token are all encrypted at rest usingcrypto::encrypt/decrypt. Connection is managed through the Connections module (Spotify as a platform). - Event Logging -- Every playback action emits an event (e.g.,
spotify:play,spotify:pause,spotify:skip) to TimescaleDB and broadcasts it via Redis pub/sub asynchronously.
Worker lifecycle
The Now-Playing worker (apps/api/src/workers/spotify.rs) is not always running -- it is started and stopped by the Channel Status relay to match stream activity:
- Runs while any of the account's channels is online or a manual connect is active (a 30-minute Redis TTL -- see Channel Status). This is intentional: the worker only polls Spotify when someone is actually streaming or has opted in for a listening session.
- Stops at stream end and when the 30-minute manual-connect window expires. A 60-second sweep tears down any worker whose account is neither online nor in a manual connect.
- Comes back without an API restart. When a channel next goes online, or a manual connect starts, the relay re-evaluates and starts the worker again. The config (which connection to poll) is resolved from the live
channel_connectionsrow on demand -- there is no boot-time snapshot -- so a Spotify connection linked, re-authorized, or disconnected-and-reconnected after the API booted is picked up with its currentconnection_id, and an account with no Spotify connection simply does not start one. If the connection row is deleted while the worker runs, it exits cleanly on its next poll.
Event emission: worker vs. control path
Two independent producers write Spotify events, and they are not symmetric:
spotify:trackis emitted only by the worker, when it detects a track change on a poll. It is deduplicated per playback occasion (track id + the instant playback started,timestamp - progress_ms), so the same title restarted (repeat-one, replay, skip-back) gets its own line while two dense polls of one continuous play collapse to a single row.spotify:skip/spotify:play/spotify:pause/spotify:previous/spotify:seek/spotify:volume/spotify:shuffle/spotify:repeatare emitted by the playback-control mutation (GraphQLspotifyPlayback/ RESTPOST /v1/spotify/playback), independently of whether the worker is running.- A skip / previous control event deliberately carries no track fields: Spotify's now-playing endpoint keeps returning the previous track for about a second after the change, so naming a track there would name the one just skipped away from. The new title is named by the following worker-emitted
spotify:trackinstead. - A shuffle / repeat change made inside the Spotify app itself emits no event. The worker only ever emits
spotify:track; every control event (spotify:shuffle,spotify:repeat,spotify:volume, …) is produced by the playback-control mutation, so toggling shuffle or cycling repeat in the native Spotify client — rather than through Lumio — leaves no line in the feed. This is expected behaviour, not a gap.
Control-event raw fields
Each control event stores the value it changed in its raw blob, so surfaces can show what was set, not just that something was set. The dashboard events feed and the inline chat event line both read these (via the shared resolveEventDetails resolver):
| Event type | raw field(s) | Shown as |
|---|---|---|
spotify:shuffle | enabled (bool) | "On" / "Off" — the value is a plain boolean; Smart Shuffle is a read-only player state and never a control-event value |
spotify:repeat | mode ("off" | "context" | "track") | "Off" / "All" / "One" |
spotify:volume | oldVolume, newVolume (percent) | 40% → 75% |
An event that lacks a well-formed value (a legacy row written before these fields, or a foreign payload) renders without the extra segment rather than guessing a default.
Frontend
- Dashboard music player with now playing display, playback controls, queue view, playlist browser, and device selector.
- Overlay widget displays current track info, album art, and progress bar.
- Next.js API proxy routes call GraphQL internally.
API
GraphQL Queries
Every Spotify query and mutation in apps/api/src/graphql/spotify.rs is gated by
FeatureGuard::new("feature:music").and(PermissionGuard::new(<permission>)); the
REST twins call auth.require_permission(…) + require_feature(…, "feature:music", …).
The three manual-connect operations live in channel_status.rs / routes/channel_status.rs
and carry the permission only - no feature:music gate on either protocol.
| Query | Feature | Permission | Description |
|---|---|---|---|
spotifyState | feature:music | spotify:read | Combined state: now playing, queue, devices, playlists (fetched in parallel via tokio::join!) |
spotifySearch(query, limit?) | feature:music | spotify:read | Search Spotify tracks (default limit: 20) |
spotifyManualStatus | - | spotify:read | Manual-connect status (active, remainingSeconds) |
GraphQL Mutations
| Mutation | Feature | Permission | Description |
|---|---|---|---|
spotifyPlayback(input: SpotifyPlaybackInput!) | feature:music | spotify:playback (+ spotify:volume for the volume action) | Control playback. Actions: play (optional track URI), pause, next, previous, seek (position in ms), volume (0-100), shuffle (bool), repeat (off/context/track). The volume branch performs an inline require_permission(SPOTIFY_VOLUME) check, matching REST. |
spotifyQueue(uri: String!) | feature:music | spotify:queue | Add a track to the Spotify queue |
spotifyTransferPlayback(deviceId: String!) | feature:music | spotify:device | Transfer playback to a different device |
spotifyPlayPlaylist(input: SpotifyPlayPlaylistInput!) | feature:music | spotify:playlist | Start playing a playlist. Input fields: uri plus optional deviceId, name, coverUrl, trackCount, playlistUrl. When deviceId is provided it is forwarded to Spotify as ?device_id= so the target device is activated in the same call (avoids a transfer+play race where Spotify returns "No active Spotify device"). |
spotifyCreatePlaylist(name: String!, public: Boolean) | feature:music | spotify:playlist | Create a new Spotify playlist |
spotifyRenamePlaylist(id, name) | feature:music | spotify:playlist | Rename a Spotify playlist |
spotifyDeletePlaylist(id) | feature:music | spotify:playlist | Delete (unfollow) a Spotify playlist |
spotifyAddToPlaylist(playlistId, trackUris: [String!]!) | feature:music | spotify:playlist | Add tracks to a Spotify playlist |
spotifyRemoveFromPlaylist(playlistId, trackUri) | feature:music | spotify:playlist | Remove a track from a Spotify playlist |
startSpotifyManual | - | spotify:worker | Start manual Spotify connect (30 min TTL). Returns SpotifyManualStatus. |
stopSpotifyManual | - | spotify:worker | Stop manual Spotify connect. Returns Boolean. |
REST Endpoints
All paths live under /v1/spotify. Bodies are snake_case.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/spotify/state | spotify:read | Combined now-playing / queue / devices / playlists state |
POST | /v1/spotify/playback | spotify:playback | Playback control: play, pause, next, previous, skip_to, seek, volume, shuffle, repeat. The volume action additionally requires spotify:volume |
GET | /v1/spotify/queue | spotify:read | Current Spotify queue |
POST | /v1/spotify/queue | spotify:queue | Add a track URI to the queue |
GET | /v1/spotify/devices | spotify:read | List available playback devices |
POST | /v1/spotify/devices | spotify:device | Transfer playback to a device |
GET | /v1/spotify/playlists | spotify:read | List user playlists |
POST | /v1/spotify/playlists | spotify:playlist | Start playing a playlist |
GET | /v1/spotify/search | spotify:read | Search Spotify tracks (query, optional limit) |
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 |
The five playlist-management mutations (spotifyCreatePlaylist,
spotifyRenamePlaylist, spotifyDeletePlaylist, spotifyAddToPlaylist,
spotifyRemoveFromPlaylist) have no REST twin - POST /v1/spotify/playlists
only starts playlist playback. Use GraphQL for playlist management.
Shuffle writes are boolean (shuffle: true / false in GraphQL playback input,
or the REST playback body's snake_case equivalent). The state read from Spotify
can still be off, on, or smart: Smart Shuffle is reported by Spotify's
player state, but Spotify does not expose a public Web API endpoint that lets
Lumio enable it.
WebSocket
| Channel | Gate | Feature |
|---|---|---|
spotify:{account_id} | spotify:read | none at the WebSocket layer |
channel_gate_for("spotify") maps the channel to ChannelGate::Permission("spotify:read")
and channel_feature_for returns None for it, so the plan check happens on the
REST/GraphQL paths rather than at subscribe time. The channel has no entry in
channel_broadcast_permission_for, so clients cannot broadcast on it - playback
state is published server-side by the Spotify worker only.
Event Types Generated
spotify:track is produced by the worker on a detected track change; every
other row below is produced by the control mutation (see Event emission
above). skip/previous control events carry no track fields.
| Event Type | Trigger |
|---|---|
spotify:track | Worker-detected track change (worker-only; not a control action) |
spotify:play | Play action |
spotify:pause | Pause action |
spotify:skip | Next or skip_to action |
spotify:previous | Previous action |
spotify:seek | Seek action |
spotify:volume | Volume change (includes old/new volume) |
spotify:shuffle | Shuffle toggle |
spotify:repeat | Repeat mode change |
spotify:queue_add | Track added to queue |
spotify:device | Playback transferred to different device |
spotify:playlist | Playlist started playing |
Permissions
| Permission | Description |
|---|---|
spotify:read | Read now playing state, queue, devices, playlists, search tracks |
spotify:playback | Control playback (play, pause, next, previous, seek, shuffle, repeat) |
spotify:volume | Change playback volume (gates the volume slider in the dashboard and popout player) |
spotify:queue | Add tracks to the queue |
spotify:playlist | Manage and play playlists |
spotify:device | Transfer playback to another device |
spotify:worker | Start/stop the manual Spotify polling worker |
Database
Spotify uses the Connections module tables for credential/token storage:
| Table | Database | Description |
|---|---|---|
app_credentials | PostgreSQL | Encrypted client_id and client_secret per platform per account |
channel_connections | PostgreSQL | Encrypted access_token, refresh_token, expires_at, platform_channel_id, scopes |
Events generated by Spotify actions are stored in:
| Table | Database | Description |
|---|---|---|
platform_events | TimescaleDB | Spotify events (spotify:play, spotify:skip, etc.) with enriched raw JSON containing track info and triggering user |
Data Flow
- User connects Spotify via the Connections module (OAuth flow).
build_client_from_ctx()loads the account's Spotify credentials and callsoauth::get_fresh_connection_tokenfor a decrypted, already-fresh access token. It performs no refresh of its own.- User performs a playback action (e.g., skip) via the dashboard.
- The
SpotifyClientmethod is called against the Spotify Web API. - The control event is enriched with the triggering user and, for non-track-changing actions, current track data. A
skip/previousomits track data on purpose -- the new title is reported by the worker's nextspotify:track(see Event emission). - An event is asynchronously inserted into TimescaleDB and broadcast via Redis pub/sub.
- Overlay "Now Playing" widget receives the event via WebSocket and updates the display.
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/spotify.rs | GraphQL queries and playback/playlist mutations |
apps/api/src/graphql/channel_status.rs | spotifyManualStatus, startSpotifyManual, stopSpotifyManual |
apps/api/src/routes/spotify.rs | REST handlers under /v1/spotify |
apps/api/src/routes/channel_status.rs | REST handlers for /v1/spotify/manual-connect |
apps/api/src/workers/spotify.rs | Adaptive Now-Playing polling worker |
crates/lo-spotify-api/src/ | Spotify Web API client |
apps/api/src/db/spotify.rs | Credential retrieval helpers |
apps/api/src/crypto.rs | Token encryption/decryption |
apps/api/src/oauth.rs | Token refresh persistence |