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. - Services -- GraphQL and REST parse their input and call the same services.
services::spotify_control::run_dashboard_controlruns a playback or queue control (play, pause, next / skip_to, previous, seek, volume, shuffle, repeat, queue add), adds the current track where it is known, and records thespotify:*event attributed to the user (triggered_by,username). The extension action dispatcher and the automation Spotify step use the same control and event payloads; their events carrysource: "extension"orvia: "automation"with theautomation_idinstead of a user (see Automations → Spotify and moderation steps).services::spotify_playlistscreates, renames, deletes (unfollows) and edits playlist tracks and records thespotify:playlist_*event. Thespotify:volumecheck stays in each protocol's parser and runs before the volume value is read. - 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. One poller per account also records pause/play made in the Spotify app and keeps the playback state for overlay and widget bootstraps (see Pause/play from the Spotify app). - 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.spotify:pause/spotify:playalso come from the worker when a pause or resume happens in the Spotify app itself. Those carrysource: "spotify_app"and notriggered_by(see below). A pause or play sent through Lumio is recorded once, by the control event.- 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 / volume / seek change made inside the Spotify app itself emits no event. Apart from
spotify:trackand the pause/play flips, 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.
Pause/play from the Spotify app and the playback state
Every API replica runs a Now-Playing worker per account (there is no leader). Only one of them, the holder of the Redis lock lumio:spotify:flip-lock:{account_id} (SET NX, refreshed on each poll, 90 s TTL, released when the worker stops), does the following on each successful poll:
- Playback state. It stores the polled
NowPlayingStateas JSON inlumio:spotify:state:{account_id}with a 90 s TTL (three idle intervals), and deletes the key when Spotify reports no playback session. The overlay and widget WebSocket bootstraps send it asspotify(see WebSocket → Example Channels). When no worker runs, the key expires and the bootstrap has nospotifyfield. - Flips. It compares the poll with the previous one. When
is_playingchanged and the track did not, it emitsspotify:pauseorspotify:play. The first poll after start has nothing to compare and emits nothing. A track change isspotify:track, not a flip. When the lock moves to another replica, the new holder starts from the stored state, so a flip during the hand-over is still recorded. - No duplicates of Lumio controls. Every play and pause sent through Lumio (dashboard REST/GraphQL, MCP, automations, extension actions and playlist play) first sets
lumio:spotify:control:{account_id}toplayorpausefor 10 s, and deletes it again when Spotify rejects the request. A flip in the marked direction is not emitted, because the control event already recorded it.
A flip event has the control-event shape (action, track_id, track_name, artist, album, album_art, song_url) plus source: "spotify_app". It is stored in platform_events like other Spotify events, with external_id spotify:{play|pause}:{track_id}:{10-second poll bucket}.
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.
Queue and playlist events name the track
The server looks up what a queue or playlist event is about after the change succeeded, with the same handling on GraphQL, REST, MCP, automations and extension actions:
| Event type | raw field(s) |
|---|---|
spotify:queue_add | uri, plus the queued track's track_id, track_name, artist, album, album_art, song_url (read with GET /tracks/{id}) |
spotify:playlist_add | playlistId, playlistName, the first added track's trackUri, trackName, artist, and trackCount (all added tracks) |
spotify:playlist_remove | playlistId, playlistName, trackUri, trackName, artist |
spotify:playlist_delete | playlistId, playlistName (read before the unfollow) |
spotify:playlist_create / spotify:playlist_edit | playlistName (the new name) |
The lookups are best effort. When one fails, or the queued URI is not a track (a podcast episode), the event is still recorded, without the looked-up fields (spotify:queue_add then carries only uri), and the control itself still succeeds. Rows stored before these fields existed keep their old raw.
The dashboard reads these fields as one sentence that names who acted and what changed, on the Events page, the events popout, the Cockpit events tile and the home page's activity list: "cruex added “Midnight City – M83” to the queue", "… to the playlist “Stream-Mix”", "… removed “Midnight City – M83” from “Stream-Mix”", "… deleted the playlist “Stream-Mix”". The inline chat line (Cockpit, Chat page, chat popout, OBS dock) shows the event label in front, so beside the label it names only what the label does not, in the chat's order label · name · detail: "Queue · cruex · “Midnight City – M83”", "Added to Playlist · cruex · “Midnight City – M83” · “Stream-Mix”" (extra tracks: "… and 3 more"), "Removed from Playlist · cruex · “Midnight City – M83” · “Stream-Mix”", "Playlist Deleted · cruex · “Stream-Mix”". With the chat's event label turned off, a line with an actor reads the full sentence. A queued track shows its cover and a copy-link button; report and recommend stay on spotify:track. Each missing lookup drops only its part (for example "added “Midnight City – M83” to a playlist", or "Added to Playlist · cruex · “Midnight City – M83”" in the chat). A row without any of these fields, such as an older row, keeps the plain actor line. One resolver (apps/web/src/lib/spotify-action.ts) feeds both surfaces.
Error handling
spotify_error_to_api (apps/api/src/routes/spotify.rs) maps Spotify errors to the shared ApiError taxonomy for REST and GraphQL. Both protocols return the same code and client-facing message. Upstream 401 becomes UNAUTHORIZED; an upstream 429 becomes RATE_LIMIT_EXCEEDED with the message Spotify rate limit reached. Try again shortly. and Spotify's wait in seconds (at least 1). REST returns HTTP 429, Retry-After, and details.retry_after_secs; GraphQL returns extensions.code and extensions.retryAfterSecs. This mapping applies to Spotify reads and mutations that call the Spotify Web API. A 404 from /me/player/* becomes NOT_FOUND ("no active device"). A 403 from /me/player/* means Spotify does not allow the command in the current playback context; it becomes HTTP 409 with SPOTIFY_PREMIUM_REQUIRED ("Spotify Premium is required for this action.") when Spotify's reason is PREMIUM_REQUIRED, otherwise SPOTIFY_ACTION_RESTRICTED ("Spotify does not allow this action right now."), and is not logged as a server error. A 403 from any other Spotify endpoint (missing scope, app not allow-listed) and all other failures become INTERNAL_SERVER_ERROR with detail scrubbed on the wire.
The web player (apps/web/src/hooks/use-spotify-player.ts) switches on the code, never on the message: for SPOTIFY_ACTION_RESTRICTED / SPOTIFY_PREMIUM_REQUIRED from playback, queue or device transfer it shows a translated error toast (music.playerRefused.*), takes an optimistic play/pause back and re-reads the state; a refused playlist start shows the translated text in the start form. A 403 keeps the permission toast.
Because a scrubbed 5xx carries no cause to the browser, the cause is recorded server-side instead, in three places:
error_from_responselogs the upstream status, path,Retry-Afterand (truncated) body for every non-2xx Spotify response.parse_jsonlogs the endpoint, the serde error and the offending payload when a 2xx body does not match the expected shape —reqwest's own.json()reports only "error decoding response body".to_graphql_errorlogs the fullApiErroron 5xx, mirroring what the RESTerror_response()already did.
Spotify Web API endpoints and Development Mode
Spotify connections use the customer's own Spotify app (app_credentials), which runs in Spotify's Development Mode. Since Spotify's February 2026 Web API changes (enforced for existing Development Mode apps from 2026-03-09), SpotifyClient uses only the current endpoints:
SpotifyClient method | Spotify endpoint |
|---|---|
get_track | GET /tracks/{id} (single track; only the batch GET /tracks was removed) |
create_playlist | POST /me/playlists |
get_playlist | GET /playlists/{id}?fields=…,items(total),… |
delete_playlist | DELETE /me/library?uris=spotify:playlist:{id} |
add_to_playlist | POST /playlists/{id}/items (body {uris}) |
get_playlist_tracks | GET /playlists/{id}/items (reads items[].item, falls back to the deprecated track) |
remove_from_playlist | DELETE /playlists/{id}/items (body {items: [{uri}]}) |
search | GET /search with limit at most 10 |
The removed endpoints (POST /users/{id}/playlists, DELETE /playlists/{id}/followers, /playlists/{id}/tracks) return errors for Development Mode apps. The playlist count is read from items.total, with the deprecated tracks.total as fallback. Development Mode tracks no longer carry external_ids, so playlist tracks usually have no ISRC (see Copyright Manager).
Spotify puts a literal null in its items arrays for entries the caller may not see (a market-unavailable search hit, a removed playlist track). All item collections are therefore deserialized as Vec<Option<T>> and flattened, so one such entry skips a row instead of failing the whole response.
Frontend
- Dashboard and popout music player with now playing display, playback controls, queue view, playlist browser, and device selector. On a Spotify 429, a warning banner counts down to the next read; the player keeps its last known state, pauses polling, and refreshes once when the wait ends. See the Music guide.
- A control flips the player at once (play/pause, the volume slider), but the player adds no event row of its own. The
spotify:*row in the chat, the Events list and the start page's Activity is the server's control event, which arrives over the window's socket and names the user who sent it. In a token popout that is the token's bound user. So each action shows once, with the old and new volume Spotify reported. - The
system-spotify-now-playingoverlay widget displays the current track, artist, optional album art and optional progress bar. It is installed from the Extension Store when needed rather than installed for every account. Its manifest declaresevents:read; the layer listens forspotify:track,spotify:pauseandspotify:playand applies its saved Show Album Art, Show Progress Bar and Hide When Paused settings. - 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). A part that fails with an upstream 5xx is empty and named in unavailable; a 401 or 429 still fails the whole query |
spotifySearch(query, limit?) | feature:music | spotify:read | Search Spotify tracks (default and maximum limit: 10; larger values are clamped) |
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: removes spotify:playlist:{id} from the user's Spotify library |
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, with unavailable naming parts Spotify could not deliver (same rules as spotifyState) |
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 | Playlist action, selected by action: play, create, rename, delete, add_track, remove_track |
GET | /v1/spotify/search | spotify:read | Search Spotify tracks (query, optional limit; default and maximum 10) |
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) map to the create, rename, delete,
add_track and remove_track actions of POST /v1/spotify/playlists.
Both protocols call the same SpotifyClient method with the same permission
(spotify:playlist) and the same feature:music gate.
A playlist id must be a Spotify id: exactly 22 letters and digits. Any other
id (for example one containing /, ?, #, , or .) is refused before
Lumio calls Spotify, with VALIDATION_ERROR and the message
Validation error: Invalid Spotify playlist id. The rename, delete,
add-track and remove-track operations apply this check identically on
GraphQL, REST and the MCP playlist tools (services::spotify_playlists::validate_playlist_id).
SpotifyClient also percent-encodes the id as a single URL path segment, so
an id can never address a different Spotify endpoint. Over MCP, a delete
also requires that the playlist Spotify returns for the ownership check is
the requested playlist.
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. WebSocket clients can only
subscribe, unsubscribe, and ping; they cannot publish on any channel. Playback
state is published server-side by the Spotify worker.
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, or a resume in the Spotify app (worker, source: "spotify_app") |
spotify:pause | Pause action, or a pause in the Spotify app (worker, source: "spotify_app") |
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 (names the queued track) |
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 |
Redis keys of the Now-Playing worker and the controls:
| Key | Value | TTL | Writer |
|---|---|---|---|
lumio:spotify:state:{account_id} | NowPlayingState JSON | 90 s | the flip-lock holder, each poll |
lumio:spotify:flip-lock:{account_id} | the holding worker's id | 90 s | the Spotify workers |
lumio:spotify:control:{account_id} | play / pause | 10 s | every play/pause control path |
lumio:spotify:current:{account_id} | Artist - Title for the bot {song} variable | 120 s | every worker, while playing |
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.
- An open, installed "Now Playing" widget with
events:readreceives the relevant track or playback event through the extension host's WebSocket feed and updates its display. After an OBS reload, the overlay or widget bootstrap carries the stored playback state asspotify.
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/services/spotify_control.rs | Shared playback/queue controls and spotify:* event recording |
apps/api/src/services/spotify_playlists.rs | Shared playlist create/rename/delete/track edits |
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 |