IPC Protocol
The daemon protocol is length-delimited JSON over local IPC. Unix builds use Unix-domain sockets. Windows builds use Tokio named pipes. CLI, TUI, scripts, and MCP bridge through the same frame codec and request/response shape.
Transport
Section titled “Transport”| Platform | Transport | Notes |
|---|---|---|
| macOS | Unix-domain socket | Resolved under the app support/runtime path for the active instance. |
| Linux | Unix-domain socket | Prefers $XDG_RUNTIME_DIR, then /run/user/$uid, then a private /tmp/spotuify-$uid fallback. |
| Windows | Named pipe | Uses a \\.\pipe\... path and keeps the next pipe instance ready before handing the accepted stream to a task. |
The transport is intentionally below the protocol. A Windows client still sends the same JSON request envelope as a macOS or Linux client.
Envelope
Section titled “Envelope”{ "id": 1, "payload": { "type": "Request", "cmd": "playback-get" }}Core requests
Section titled “Core requests”spotuify status --format jsonspotuify devices --format jsonspotuify search "quiet storm" --format jsonspotuify queue --format jsonRepresentative request variants:
| Request | CLI surface |
|---|---|
ClientSeed |
TUI/event clients only; cached startup seed |
PlaybackGet |
spotuify status |
PlaybackCommand |
pause, resume, toggle, next, previous, seek, volume, shuffle, repeat |
DevicesList |
spotuify devices |
DeviceTransfer |
spotuify transfer |
Search |
spotuify search |
QueueGet |
spotuify queue |
QueueAdd |
spotuify queue add |
PlaylistsList |
spotuify playlists |
PlaylistTracks |
spotuify playlist tracks |
PlaylistAddItems |
spotuify playlist add |
PlaylistRemoveItems |
spotuify playlist remove |
PlaylistRemoveOccurrences / PlaylistRemoveOccurrencesPreview |
spotuify playlist remove-at; TUI playlist-detail Delete; MCP playlist_remove_occurrences |
ArtistAlbums |
spotuify artist albums |
FollowedArtists |
spotuify artist followed |
LibrarySave |
spotuify like, spotuify save |
ShowEpisodes |
spotuify show episodes |
EpisodeFeed |
spotuify episodes |
CoverArt |
TUI art fetch, spotuify refresh-media |
LyricsGet |
spotuify lyrics show, spotuify lyrics follow, spotuify refresh-media |
SubscribeEvents |
spotuify lyrics follow, TUI/event clients |
SetVizEnabled |
spotuify viz enable/disable |
SetVizStyle |
spotuify viz style <name>; TUI ctrl+v picker; MCP viz_style_set. Validates against VIZ_STYLES, writes viz.style, then broadcasts ClientPreferencesChanged |
ThemesList / SetTheme |
spotuify theme / theme list / theme <name> / theme path; TUI t picker; MCP themes_list / theme_set. Merges built-ins with <config_dir>/themes/*.toml, writes tui.theme, then broadcasts ClientPreferencesChanged carrying the RESOLVED colours so no client reads a theme file. The themes response carries active as a whole spec, not a name: the daemon keeps painting a theme whose file was deleted mid-session, so the active theme is not always in the list. SetTheme, SetVizStyle, and Reload share a write lane so two preference writes cannot leave the config file and the broadcast disagreeing |
Reload |
spotuify reload. Re-reads the config into runtime, then emits ConfigReloaded and ClientPreferencesChanged: clients toast and refetch diagnostics on the former but none re-seed preferences from it, so a hand-edited tui.theme / viz.style would otherwise never reach a running TUI |
ReminderCreate / RemindersList / ReminderCancel |
spotuify reminder ... |
NotificationsList / NotificationAct |
spotuify notifications ... |
PlaybackSpeedGet / PlaybackSpeedSet |
spotuify speed; TUI [ / ]; MCP playback_speed_get / playback_speed_set; macOS speed menu |
EqGet / EqSet |
spotuify eq; TUI E (editor) / Ctrl-e (next preset); MCP eq_get / eq_set; macOS EQ menu |
BookmarkCreate / BookmarksList / BookmarkUpdate / BookmarkDelete / BookmarkPlay |
spotuify bookmark ...; TUI B + screen 8; MCP bookmark_*; macOS Bookmarks |
CheckUpdate |
spotuify update, TUI/app update banners |
ClientSeed is deliberately client-specific. It hydrates event-driven clients from cached playback, queue, devices, recent items, and visualizer state. It must not trigger Spotify refreshes; live refreshes belong to daemon warm/sync loops or explicit CLI requests.
refresh-media is a CLI convenience over PlaybackGet, CoverArt, and a
force-refresh LyricsGet for the current track. It does not clear existing
client media while the new fetch is in flight.
Exact playlist occurrence requests use zero-based positions on the wire:
{ "cmd": "playlist-remove-occurrences-preview", "playlist": "spotify:playlist:example", "items": [ { "uri": "spotify:track:duplicate", "positions": [1, 4] } ]}The CLI presents those positions as one-based rows. Preview and write perform the same daemon-authoritative URI/position validation. For providers with playlist version tokens, the write records enough pre-state for position-aware undo.
lyrics follow is a watch client over existing protocol calls. It subscribes
to PlaybackChanged, fetches lyrics with LyricsGet on track change, and
advances the active lyric line locally from playback time.
ArtistAlbums returns the full discography in one response. The daemon tags
each album with album_group (album, single, compilation, or appears-on) and
in_library by intersecting against the cached saved-album set. Clients
section and filter from that single payload, so the “in library” toggle never
needs a refetch. FollowedArtists is cache-backed and falls back to a live
fetch when the cache is cold. See JSON Output for the
tagged row shape.
EpisodeFeed merges the first page of episodes from followed shows, caches the
feed for quick repeat reads, and supports --refresh when you want a live
re-fetch. CheckUpdate returns the cached GitHub release observation and an
upgrade hint for the current install method; the background daemon loop refreshes
that observation on startup and every few hours.
Analytics requests
Section titled “Analytics requests”spotuify analytics top --kind tracks --format jsonspotuify analytics import lastfm --user your-lastfm-user --from 2024-01-01 --format jsonspotuify analytics import status 018f... --format jsonRepresentative request variants:
| Request | CLI surface |
|---|---|
AnalyticsEvents |
spotuify analytics events |
AnalyticsTop |
spotuify analytics top |
AnalyticsHabits |
spotuify analytics habits |
AnalyticsSearch |
spotuify analytics search |
AnalyticsRediscovery |
spotuify analytics rediscovery |
AnalyticsRebuild |
spotuify analytics rebuild |
AnalyticsPrune |
spotuify analytics prune |
AnalyticsImport |
spotuify analytics import lastfm and the --target lastfm compatibility alias |
AnalyticsImportStatus |
spotuify analytics import status |
AnalyticsImportUnresolved |
spotuify analytics import unresolved |
AnalyticsImportUndo |
spotuify analytics import undo |
Last.fm import requests carry optional credentials and date bounds:
{ "type": "analytics-import", "target": "last_fm", "username": "your-lastfm-user", "api_key": "lastfm-api-key", "from_ms": 1704067200000, "to_ms": 1735689600000, "apply": false}Use apply: false for preview. The daemon resolves config/env defaults when username or api_key are omitted.
Admin requests
Section titled “Admin requests”spotuify daemon status --format jsonspotuify doctor --format jsonspotuify cache status --format jsonspotuify reindex --format jsonRepresentative request variants:
| Request | CLI surface |
|---|---|
GetDaemonStatus |
spotuify daemon status |
GetDoctorReport |
spotuify doctor |
Reindex |
spotuify reindex |
CacheStatus |
spotuify cache status |
Sync |
spotuify sync |
LogsTail |
spotuify logs tail |
Reload |
spotuify reload |
Reconnect |
spotuify reconnect |
Response shape
Section titled “Response shape”{ "Ok": { "data": { "kind": "Playback", "playback": {} } }}Analytics import responses are wrapped in ResponseData variants over IPC. The CLI unwraps these payloads for --format json.
{ "kind": "AnalyticsImportSummary", "summary": { "run_id": "018f...", "provider": "lastfm", "username": "your-lastfm-user", "dry_run": true, "fetched": 1200, "stored": 0, "duplicates": 0, "resolved": 1138, "promoted": 0, "unresolved": 62, "started_at_ms": 1735689600000, "finished_at_ms": 1735689660000 }}Import status, unresolved, and undo responses use:
AnalyticsImportRunStatus { status }AnalyticsImportUnresolved { entries }AnalyticsImportUndoSummary { summary }Errors are typed:
{ "Error": { "message": "no active device", "kind": "provider", "code": "provider", "retryable": false }}Error kinds:
authinvalid_requestnetworkproviderrate_limitedunsupportedinternalEvents
Section titled “Events”The daemon broadcasts state changes so clients do not have to poll forever.
spotuify events streams the same broadcast to stdout as JSONL, which is the
easiest way to see the contract below in motion:
spotuify events --kind playback-changed --once --timeout 10Each line carries the daemon’s own fields plus _received_at_ms (the
underscore keeps the envelope out of the protocol’s namespace). Lines are
re-serialised rather than forwarded byte-for-byte, so key order and number
formatting are the CLI’s; field values, including those of an event the CLI
cannot decode, are the daemon’s.
shutdown-requestedplayback-changedqueue-changeddevices-changedplaylists-changedlibrary-changedsearch-updatedsearch-pagesearch-completesearch-failedevent-stream-laggedsync-startedsync-finishedmutation-finishedanalytics-import-progressrate-limitedauth-errormutation-acceptedmutation-finalizedschema-compatplayer-readyplayer-degradedprovider-policyprovider-policy-clearedpremium-requiredsession-disconnectedplayer-failedlisten-qualifiedoperation-recordedoperation-undoneconfig-reloadedclient-preferences-changedspectrum-frameviz-source-changedreminder-duereminders-changedbookmarks-changedeq-changedupdate-availableauth-migration-recommendedprovider-policy is the current provider-tagged local-playback restriction
event. premium-required remains in the roster only so clients can decode
events from released older daemons.
Compatibility rules
Section titled “Compatibility rules”Adding an event kind or field does not bump protocol_version. A daemon
therefore always outlives some of its clients, and two rules keep the older
client alive:
- An unknown event tag decodes, it does not fail. Clients decode a kind
they don’t know into a fallback (
DaemonEvent::Unknownin Rust,.unknownin Swift) that keeps the raw frame, log it, and carry on reading. Before this, one unknown tag failed the frame, killed the connection, and sent the client into a reconnect loop. - A new field on an existing event must be optional. Extra fields are
ignored for free; a missing required field is a decode error. In Rust that
means
#[serde(default)](andskip_serializing_ifwhere the field is optional on the wire); in Swift,decodeIfPresent.
The fallback records why it fired, because the two cases call for opposite reactions:
| Reason | Meaning | What a client does |
|---|---|---|
unknown-tag |
A kind absent from the roster: a daemon newer than this client | Log at debug, ignore. Nothing was lost. |
undecodable-known-tag |
A kind the client knows, in a shape it could not read (rule 2 broken) | Warn, and treat push state as stale: the TUI refreshes, MCP still invalidates the resources that tag invalidates. |
Never default a missing required field into a plausible value. An
auth-migration-recommended whose flag failed to decode must degrade, not
recommend the wrong command.
The event roster is a two-way contract: DaemonEvent::all_kind_labels() in
spotuify-protocol is compared against
clients/macos/Tests/SpotuifyKitTests/Fixtures/event-kinds.json, and the macOS
test bundle checks its own decoder against the same fixture, so a kind added on
either side fails until the other catches up.
Import progress events are daemon-owned and broadcast to subscribers:
{ "type": "analytics-import-progress", "run_id": "018f...", "provider": "lastfm", "username": "your-lastfm-user", "phase": "resolving", "fetched": 1200, "stored": 800, "resolved": 760, "promoted": 760, "unresolved": 40, "message": "resolving Last.fm scrobbles"}