Skip to main content

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_control runs 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 the spotify:* event attributed to the user (triggered_by, username). The extension action dispatcher uses the same control and event payloads. services::spotify_playlists creates, renames, deletes (unfollows) and edits playlist tracks and records the spotify:playlist_* event. The spotify:volume check stays in each protocol's parser and runs before the volume value is read.
  • Crate (crates/lo-spotify-api/src/) -- SpotifyClient wraps 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. Its SpotifyClient::refresh_token() is #[deprecated] and reserved for the initial OAuth code exchange, which has no DB row to read yet - internal callers must use oauth::get_fresh_connection_token instead, so refreshes cannot race the Token Refresh Worker.
  • Worker (apps/api/src/workers/spotify.rs) -- Polls the Now-Playing endpoint at PLAYING_POLL_INTERVAL_SECS (5 s) / IDLE_POLL_INTERVAL_SECS (30 s), honours a Retry-After on 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 using crypto::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_connections row 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 current connection_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:track is 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:repeat are emitted by the playback-control mutation (GraphQL spotifyPlayback / REST POST /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:track instead.
  • 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 typeraw field(s)Shown as
spotify:shuffleenabled (bool)"On" / "Off" — the value is a plain boolean; Smart Shuffle is a read-only player state and never a control-event value
spotify:repeatmode ("off" | "context" | "track")"Off" / "All" / "One"
spotify:volumeoldVolume, 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.

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"); other failures become INTERNAL_SERVER_ERROR with detail scrubbed on the wire.

Because a scrubbed 5xx carries no cause to the browser, the cause is recorded server-side instead, in three places:

  • error_from_response logs the upstream status, path, Retry-After and (truncated) body for every non-2xx Spotify response.
  • parse_json logs 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_error logs the full ApiError on 5xx, mirroring what the REST error_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 methodSpotify endpoint
create_playlistPOST /me/playlists
get_playlistGET /playlists/{id}?fields=…,items(total),…
delete_playlistDELETE /me/library?uris=spotify:playlist:{id}
add_to_playlistPOST /playlists/{id}/items (body {uris})
get_playlist_tracksGET /playlists/{id}/items (reads items[].item, falls back to the deprecated track)
remove_from_playlistDELETE /playlists/{id}/items (body {items: [{uri}]})
searchGET /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.
  • 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.

QueryFeaturePermissionDescription
spotifyStatefeature:musicspotify:readCombined state: now playing, queue, devices, playlists (fetched in parallel via tokio::join!)
spotifySearch(query, limit?)feature:musicspotify:readSearch Spotify tracks (default and maximum limit: 10; larger values are clamped)
spotifyManualStatus-spotify:readManual-connect status (active, remainingSeconds)

GraphQL Mutations​

MutationFeaturePermissionDescription
spotifyPlayback(input: SpotifyPlaybackInput!)feature:musicspotify: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:musicspotify:queueAdd a track to the Spotify queue
spotifyTransferPlayback(deviceId: String!)feature:musicspotify:deviceTransfer playback to a different device
spotifyPlayPlaylist(input: SpotifyPlayPlaylistInput!)feature:musicspotify:playlistStart 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:musicspotify:playlistCreate a new Spotify playlist
spotifyRenamePlaylist(id, name)feature:musicspotify:playlistRename a Spotify playlist
spotifyDeletePlaylist(id)feature:musicspotify:playlistDelete (unfollow) a Spotify playlist: removes spotify:playlist:{id} from the user's Spotify library
spotifyAddToPlaylist(playlistId, trackUris: [String!]!)feature:musicspotify:playlistAdd tracks to a Spotify playlist
spotifyRemoveFromPlaylist(playlistId, trackUri)feature:musicspotify:playlistRemove a track from a Spotify playlist
startSpotifyManual-spotify:workerStart manual Spotify connect (30 min TTL). Returns SpotifyManualStatus.
stopSpotifyManual-spotify:workerStop manual Spotify connect. Returns Boolean.

REST Endpoints​

All paths live under /v1/spotify. Bodies are snake_case.

MethodPathPermissionDescription
GET/v1/spotify/statespotify:readCombined now-playing / queue / devices / playlists state
POST/v1/spotify/playbackspotify:playbackPlayback control: play, pause, next, previous, skip_to, seek, volume, shuffle, repeat. The volume action additionally requires spotify:volume
GET/v1/spotify/queuespotify:readCurrent Spotify queue
POST/v1/spotify/queuespotify:queueAdd a track URI to the queue
GET/v1/spotify/devicesspotify:readList available playback devices
POST/v1/spotify/devicesspotify:deviceTransfer playback to a device
GET/v1/spotify/playlistsspotify:readList user playlists
POST/v1/spotify/playlistsspotify:playlistPlaylist action, selected by action: play, create, rename, delete, add_track, remove_track
GET/v1/spotify/searchspotify:readSearch Spotify tracks (query, optional limit; default and maximum 10)
GET/v1/spotify/manual-connectspotify:readManual-connect status (+ remaining seconds)
POST/v1/spotify/manual-connectspotify:workerStart manual Spotify polling (30 min TTL)
DELETE/v1/spotify/manual-connectspotify:workerStop 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.

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​

ChannelGateFeature
spotify:{account_id}spotify:readnone 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 TypeTrigger
spotify:trackWorker-detected track change (worker-only; not a control action)
spotify:playPlay action
spotify:pausePause action
spotify:skipNext or skip_to action
spotify:previousPrevious action
spotify:seekSeek action
spotify:volumeVolume change (includes old/new volume)
spotify:shuffleShuffle toggle
spotify:repeatRepeat mode change
spotify:queue_addTrack added to queue
spotify:devicePlayback transferred to different device
spotify:playlistPlaylist started playing

Permissions​

PermissionDescription
spotify:readRead now playing state, queue, devices, playlists, search tracks
spotify:playbackControl playback (play, pause, next, previous, seek, shuffle, repeat)
spotify:volumeChange playback volume (gates the volume slider in the dashboard and popout player)
spotify:queueAdd tracks to the queue
spotify:playlistManage and play playlists
spotify:deviceTransfer playback to another device
spotify:workerStart/stop the manual Spotify polling worker

Database​

Spotify uses the Connections module tables for credential/token storage:

TableDatabaseDescription
app_credentialsPostgreSQLEncrypted client_id and client_secret per platform per account
channel_connectionsPostgreSQLEncrypted access_token, refresh_token, expires_at, platform_channel_id, scopes

Events generated by Spotify actions are stored in:

TableDatabaseDescription
platform_eventsTimescaleDBSpotify events (spotify:play, spotify:skip, etc.) with enriched raw JSON containing track info and triggering user

Data Flow​

  1. User connects Spotify via the Connections module (OAuth flow).
  2. build_client_from_ctx() loads the account's Spotify credentials and calls oauth::get_fresh_connection_token for a decrypted, already-fresh access token. It performs no refresh of its own.
  3. User performs a playback action (e.g., skip) via the dashboard.
  4. The SpotifyClient method is called against the Spotify Web API.
  5. The control event is enriched with the triggering user and, for non-track-changing actions, current track data. A skip/previous omits track data on purpose -- the new title is reported by the worker's next spotify:track (see Event emission).
  6. An event is asynchronously inserted into TimescaleDB and broadcast via Redis pub/sub.
  7. Overlay "Now Playing" widget receives the event via WebSocket and updates the display.

Key Files​

PathDescription
apps/api/src/graphql/spotify.rsGraphQL queries and playback/playlist mutations
apps/api/src/graphql/channel_status.rsspotifyManualStatus, startSpotifyManual, stopSpotifyManual
apps/api/src/routes/spotify.rsREST handlers under /v1/spotify
apps/api/src/services/spotify_control.rsShared playback/queue controls and spotify:* event recording
apps/api/src/services/spotify_playlists.rsShared playlist create/rename/delete/track edits
apps/api/src/routes/channel_status.rsREST handlers for /v1/spotify/manual-connect
apps/api/src/workers/spotify.rsAdaptive Now-Playing polling worker
crates/lo-spotify-api/src/Spotify Web API client
apps/api/src/db/spotify.rsCredential retrieval helpers
apps/api/src/crypto.rsToken encryption/decryption
apps/api/src/oauth.rsToken refresh persistence