SendSpin + Music Assistant — Metadata and Artwork Flow
SendSpin + Music Assistant — Metadata and Artwork Flow
Status: working reference, 2026-04-22. Based on observed on-device log captures and a read of the MA server source at C:/codeprojects/server/music_assistant/providers/sendspin/ plus the aiosendspin library at /tmp/aiosendspin/.
Audience: SendSpinDroid maintainers diagnosing “metadata lags the audio” complaints, “widget shows wrong artwork” complaints, or reasoning about where a fix belongs (client, server, or protocol).
1. Actors
- MA server — Python,
music_assistant/providers/sendspin/player.py+aiosendspinlibrary. Owns the queue, knows the true “current track.” Produces metadata and artwork. - SendSpin WebSocket protocol — text JSON messages (
server/state,stream/start, etc.) + binary frames (audio, artwork, visualizer). Defined inaiosendspin. - SendSpinClient (Android) —
android/app/src/main/java/com/sendspindroid/sendspin/SendSpinClient.kt. Terminates the WebSocket, parses frames, invokes callback interface. - PlaybackService (Android) —
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt. Holds the MediaSession, dispatches metadata to Android media surfaces. OwnscurrentArtwork: Bitmap?. _playbackState: MutableStateFlow<PlaybackState>— app-internal state, observed by the Compose UI for in-app rendering.- Android MediaSession — via
forwardingPlayer.updateMetadata(artwork=bitmap, artworkUri=uri, ...). Drives lock-screen widget, notification shade media, Android Auto, Bluetooth AVRCP. - MA command channel (separate WebSocket) —
MaCommandClient.kt, used for queue fetch and player-level commands. Independent of the SendSpin WebSocket.
2. Flow at connect (first track)
Client Server
|-- TCP + WS upgrade ----------------------------->|
|-- client/hello (artwork: channels=[{source:album,...}]) ----->|
|<--- server/hello --------------------------------|
|<--- server/state (full metadata: title/artist/album/artwork_url/progress/...) |
|<--- server/state (controller: commands, volume, muted) ----|
|<--- group/update (playback_state: "stopped" | "playing") --|
|<--- stream/start (artwork: channels config, with codec/sample rate/header) --|
|<--- binary type 8 (channel 0 = album artwork, JPEG payload) -|
|<--- binary type 4 (audio chunks, start streaming) ----------|
...
Client handling for each inbound message:
| Message | SendSpinClient dispatch |
PlaybackService handler |
Side effects |
|---|---|---|---|
server/state metadata |
onMetadataUpdate(title, artist, album, artworkUrl, ...) |
_playbackState.withMetadata(...), sendSpinPlayer.updateMediaItem(...), fetchArtwork(url) if URL changed |
UI (app) sees new fields; MediaSession gets title/artist; Coil starts URL fetch |
stream/start |
onStreamStart(codec, ...) |
Decoder release + recreate; SyncAudioPlayer reuse-or-create |
Audio pipeline prepared |
| Binary type 8 (channel 0, payload non-empty) | onArtwork(channel=0, payload) → callback.onArtwork(payload) — channel argument dropped |
Decode JPEG → currentArtwork = scaled; updateMediaSessionArtwork(bitmap) |
MediaSession artwork set |
| Binary type 8 (channel 0, payload empty) | onArtwork(channel=0, payload=empty) → callback.onArtworkCleared() |
currentArtwork = null; updateMediaMetadata(...) |
MediaSession artwork cleared |
| Binary type 4 | onAudioChunk(timestamp, data) |
Submit to decoder; queue PCM; AudioTrack.write | Audio output |
Fetched-URL path (client-side only):
PlaybackService.fetchArtwork(url)
↓ serviceScope.launch(Dispatchers.IO)
↓ Coil ImageLoader.execute(request) [cached if URL seen before]
↓ on SuccessResult
↓ mainHandler.post: currentArtwork = scaled
↓ updateMediaSessionArtwork(scaled)
Both the binary path and the URL path write to the same currentArtwork sink. Last write wins.
3. Flow at track change (same session, server advances the queue)
This is the “metadata lags the audio” case. Here’s what MA’s server actually does.
3a. MA server internals
The track-change trigger is SendspinPlayer._on_player_media_updated() at providers/sendspin/player.py:782. This fires when MA’s queue controller has set a new current_media on the player.
def _on_player_media_updated(self) -> None:
if self.synced_to is not None:
return # Only leader sends metadata
if self.state.current_media is None:
metadata_role.set_metadata(Metadata()) # Clear
return
self.mass.create_task(self.send_current_media_metadata())
send_current_media_metadata() (player.py:795) then:
- Looks up the queue and queue_item by id (line 802-809).
await self._send_album_artwork(queue_item)— line 813.await self._send_artist_artwork(queue_item)— line 814.- Builds a full
Metadataobject with all fields (line 837-850), includingartwork_url=current_media.image_url. metadata_role.set_metadata(metadata)— line 854.
3b. Server-side delta suppression
set_metadata() in aiosendspin/server/roles/metadata/group.py:111-114 says:
Only sends updates for fields that have changed.
And state.py:106:
Build a SessionUpdateMetadata containing only changed fields compared to last.
This is why client logs show partial metadata on every track change. The server always builds the full Metadata object, but the role’s send layer emits only a delta. Within a single album, typical deltas are {title, progress} because artist/album/artwork_url are unchanged.
3c. Server-side artwork dedupe (the important part)
_send_album_artwork(queue_item) at player.py:721:
artwork_url = None
if current_item.image is not None:
artwork_url = self.mass.metadata.get_image_url(current_item.image)
if artwork_url != self.last_sent_artwork_url:
# Image changed, resend the artwork
self.last_sent_artwork_url = artwork_url
if artwork_url is not None and current_item.media_item is not None:
image_data = await self.mass.metadata.get_image_data_for_item(
current_item.media_item
)
...
Dedupe rule: if get_image_url(current_item.image) hasn’t changed since the last track, the server does not re-send binary album art. Same for artist (_send_artist_artwork at player.py:749, matches same pattern on last_sent_artist_artwork_url).
3d. Server-side inconsistency: URL vs binary source
Look carefully at player.py:
- Line 730 (dedupe check):
get_image_url(current_item.image)— queue-item-level image. - Line 736 (binary data fetch):
get_image_data_for_item(current_item.media_item)— media-item-level image. - Line 842 (metadata URL sent to client):
current_media.image_url— current-media-level image.
These three are resolved from three different source objects and are not guaranteed to agree. In typical library playback they converge, but in some queue contexts (radio, file-on-remote-provider, dynamic playlists) they can point to different images. This is the most plausible explanation for the observed “app UI shows correct album cover, widget shows unrelated playlist/context image” case: the binary image_data path picked up a queue-item image (e.g. the playlist’s cover) while the artwork_url in metadata resolved to the actual track’s album cover.
Worth confirming by capturing the raw MA player state during the bug repro; the fix may be on MA’s side (unify the three resolution paths) or on our side (prefer URL).
3e. Track-change timeline on the wire
Ordering based on log observation (10:36:45.880 to 10:36:46.170 in b51oxqsjr.output):
t=0: MA queue advances to new track (internal)
t+?: _on_player_media_updated() fires
t+?: send_current_media_metadata() scheduled as task
t+?: _send_album_artwork() runs — NO binary send if URL unchanged
t+?: _send_artist_artwork() runs — NO binary send if URL unchanged
t+?: set_metadata() builds delta and sends over the wire as server/state
t+?: stream/start sent over the wire (codec config)
t+?: binary audio chunks start arriving
Observed client-side inter-message gap:
server/state (new title, partial) → stream/start = ~290 ms
Critically, the audio transition is driven by a separate mechanism (the queue player’s corrected_elapsed_time, buffer consumption, etc.) that can be offset from when _on_player_media_updated fires. Audio can already be playing the new track by the time the server emits the new-title metadata — this is the reported “metadata lags the audio” UX.
4. Why the lock-screen widget stays stale while the app UI is fresh
Two separate data sources feed the two surfaces:
| Surface | Data source | Result |
|---|---|---|
| App UI (mini-player, now-playing screen) | Observes _playbackState.artworkUrl via Compose, fetches via Coil |
Always the current URL’s image. Because artworkUrl is preserved across partial metadata updates and the URL is track-accurate, the app UI displays correct art. |
| Lock screen / notification / Android Auto | Reads the Bitmap from MediaSession via mediaMetadata.artworkData |
Shows whatever bitmap we last set via forwardingPlayer.updateMetadata(artwork=...). That bitmap is currentArtwork, which is the most recent arrival from either binary-artwork OR URL-fetch — whichever wrote last. |
If the URL fetch happens once (at connect, when lastArtworkUrl changes from null to the first URL) and then never re-runs because subsequent tracks share the same URL (same album), and the binary artwork also doesn’t re-send (MA’s dedupe), then currentArtwork never updates. If the first binary image MA sent was actually a playlist-context image (the server-side inconsistency above), that wrong image persists as the lock-screen art for the whole same-album sequence.
5. Client-side metadata data sources (with latency characteristics)
| Source | Freshness trigger | Latency on track change | Completeness |
|---|---|---|---|
SendSpin server/state metadata |
Delta from MA when _on_player_media_updated fires |
Whatever gap MA has between queue advance and that callback. Observed sub-second but not reliably instantaneous. | Partial (delta). Client merges via withMetadata. |
MA player_queues/queue_items |
Fetched once on connect via MaCommandClient.kt. Can be re-fetched on demand. |
Zero marginal latency if locally cached AND we know the current index. Any network fetch adds round-trip. | Full (all track fields per item). |
| MA event stream (command WebSocket) | Pushed by MA, separate from SendSpin WebSocket | Unknown — needs investigation. Likely fires at or before _on_player_media_updated. |
Depends on event type. |
| Binary audio chunk header | Arrives with audio | Real-time with audio. | Only timestamp, no track identity. |
Two plausible avenues to reduce the UX lag, given these sources:
- Subscribe to MA’s command-channel events that fire at queue advance, and use those to kick a metadata refresh in parallel with the SendSpin
server/statearrival. Likely earliest signal. - On any queue-index change (from any source), immediately derive metadata from the local queue cache. No server round-trip. Instant UI update. Needs the local queue to be kept fresh across track changes, which the app already does via
populatePlayerQueue().
6. Observed client-side bugs (orthogonal to server latency)
These are actual client-code issues surfaced during this session’s investigation. Each is a candidate for a small follow-up PR independent of the larger flow redesign.
B1: onArtwork drops the channel parameter
SendSpinClient.kt:444:
override fun onArtwork(channel: Int, payload: ByteArray) {
if (payload.isEmpty()) {
callback.onArtworkCleared()
} else {
callback.onArtwork(payload) // channel discarded
}
}
Our client/hello requests only channel 0 (source=album), so in current deployments this is dormant. But if the client ever requests multiple channels (e.g. to show artist art on a secondary UI element), or if a server mis-sends on a non-configured channel, we would blindly overwrite the album bitmap with whatever arrived. Fix: propagate channel and route album vs non-album to different sinks.
B2: Last-writer-wins between binary and URL artwork
Both paths target currentArtwork. No priority logic. When the server’s binary image is stale or inconsistent with the URL (see §3d), whichever arrived last wins. In practice the binary path usually wins because URL fetch takes longer.
Fix candidates:
- Maintain
urlArtwork: Bitmap?andbinaryArtwork: Bitmap?as separate fields; prefer URL when available. This formalizes URL as the authoritative source. - On title change, unconditionally re-fetch the URL even if URL is unchanged (cheap via Coil cache, overrides stale binary).
B3: MainActivity: Metadata update: <title> / / log is misleading
The log emits incoming parameters (which are empty-string for unchanged fields) rather than the resulting _playbackState values. Harmless UX but noisy when reading logs. Minor.
B4: Encrypted prefs fallback silently loses auth tokens
Not metadata/artwork, but found while reading other parts of the log: when EncryptedSharedPreferences decryption fails (seen after device keystore reset), UserSettings.initialize falls back to plain prefs with only a W log line. Saved MA credentials on the device are effectively lost without UI notification. Future-work item.
7. Open questions — now with answers
Original questions retained for traceability. Research findings follow each.
Q1. Does MA’s command-channel emit a track-advance event before server/state?
Yes. MA publishes QUEUE_UPDATED on its command-channel WebSocket, and the event fires substantially earlier than the SendSpin server/state broadcast.
Timeline on track advance, cross-referenced to MA source:
T+0ms Player detects current_media changed
— models/player.py:1232 update_state()
T+0ms PLAYER_UPDATED fires on command channel
— controllers/players/controller.py:1770
T+500ms on_player_update fires, _update_queue_from_player detects current_item_id change
— controllers/player_queues.py:1134
T+500ms QUEUE_UPDATED fires on command channel (full PlayerQueue payload)
— controllers/player_queues.py:1790
T+1000ms _on_player_media_updated invoked (1-second debounce)
— providers/sendspin/player.py:1232
T+1000ms+ send_current_media_metadata() scheduled; binary artwork fetched async; server/state broadcast
QUEUE_UPDATED payload includes the full new PlayerQueue object with current_item.media_item populated (title, artist, album, image as MediaItemImage). No follow-up fetch required. Forwarded to command-channel clients immediately via controllers/webserver/websocket_client.py:468.
Client infrastructure to consume this already exists but is never wired up:
MaApiTransport.EventListenerinterface:MaApiTransport.kt:170-175- Multiplexer dispatches to it:
MaCommandMultiplexer.kt:186(eventListener?.onEvent(json)) - But
MaCommandMultiplexer.eventListener: MaApiTransport.EventListener? = null(line 61) is never set anywhere in the app. NosetEventListener(...)call.
Verdict — Q1 is directly actionable. Registering an EventListener in MusicAssistantManager that filters for type == "queue_updated", extracts data.current_item, and pushes to _playbackState gets the client a ~1 second lead on the SendSpin server/state broadcast for every track change. That closes the “metadata lags audio” complaint without any server-side change.
Q2. Can we compute the current queue-item id from audio-chunk timestamps?
Not researched in depth this session. Deferred — Q1’s fix (subscribing to queue_updated) renders Q2 unnecessary for normal flows. Keep as fallback for pure-SendSpin deployments without MA command-channel access.
Q3. On cross-album track changes, is binary artwork ordered vs server/state?
Binary always arrives first, on the same sequential task. No race, no window where old binary + new URL coexist.
From providers/sendspin/player.py:795-854, send_current_media_metadata() is one coroutine:
await _send_album_artwork(queue_item) # includes fetch + encode + binary send
await _send_artist_artwork(queue_item) # same shape
metadata_role.set_metadata(metadata) # synchronous, fires LAST
The set_metadata call is not awaited — it’s a plain method that dispatches the server/state over the WebSocket. It is guaranteed to execute after both artwork sends resolve.
Edge case: if _on_player_media_updated fires twice within the 1-second debounce window on separate tasks, the two send_current_media_metadata coroutines could interleave during the to_thread(Image.open, ...) await, producing out-of-order frames. Low probability; not the primary bug.
Q4. What resolves current_item.image vs current_media.image_url to different sources?
Three different image fields are read by three different call sites in providers/sendspin/player.py:
| Path | Call site | Resolution | Used for |
|---|---|---|---|
| A | player.py:730 — get_image_url(current_item.image) |
Queue item’s snapshot .image field (set once at QueueItem.from_media_item, never updated). Walks Track.image property which prefers album.image via ItemMapping. |
Dedupe check: should we re-send binary? |
| B | player.py:736 — get_image_data_for_item(current_item.media_item) |
Calls get_image_url_for_item(media_item) which iterates media_item.metadata.images for THUMB, falls back to album, then artist. _prepare_next_item (controllers/player_queues.py:1594-1602) can prepend album.image to this list, potentially making it diverge from queue_item.image. |
Fetch binary JPEG bytes for channel 0. |
| C | player.py:842 — current_media.image_url |
PlayerMedia.image_url, set from queue_item.image in _create_player_media (controllers/player_queues.py:1828-1854, line 1851). Same underlying queue_item.image as Path A, at a different resolution. |
artwork_url field in server/state metadata. |
Important: the direction of divergence depends on the track’s data shape at queue-construction time vs after _prepare_next_item.
For the specific bug the user reported — “app UI shows correct Chicago album cover from URL, lock-screen widget shows unrelated pixel-character image from binary” — the likely resolution was:
queue_item.image(Paths A, C) →Track.imageproperty →album.imageof theItemMapping→ correct Chicago album cover URL (because at enqueue the library track’s album was present).media_item.metadata.images[0](Path B) → THUMB from the provider-returned metadata, which for tracks sourced through a playlist often contains the playlist context image rather than the album cover. Binary send fetches this image’s bytes → wrong.
In other deployments the opposite direction is possible: queue_item.image snapshotted something stale while _prepare_next_item enriched media_item.metadata.images with the correct album cover. Either way, the two paths can disagree, and which is “correct” varies per track.
Canonical fix upstream would unify all three call sites to a single resolver. The most semantically correct candidate is get_image_url_for_item(media_item) (the one Path B uses), applied consistently to the dedupe URL and the artwork_url metadata field — but only after verifying that path produces the track album cover reliably for non-library / non-enriched tracks (see the “other direction” caveat above).
Q5. Does the SendSpin spec require artwork_url == binary channel 0 content?
No. The spec is silent; the two streams are architecturally independent.
From aiosendspin/server/roles/artwork/v1.py:37-38:
“Unlike player, artwork streams are independent of playback — they start on connect and don’t clear on pause/stop.”
(“Independent” describes artwork-vs-audio, not artwork-vs-metadata, but the architectural pattern is the same: separate role families, separate group roles, no shared state.)
- Metadata role: owns
artwork_urlas an opaque string. No validation./tmp/aiosendspin/aiosendspin/models/metadata.py. - Artwork role: owns binary channels. Images passed directly as
PIL.Image, encoded and sent. Never consultsartwork_url./tmp/aiosendspin/aiosendspin/server/roles/artwork/group.py. - Conformance suite (
conformance/src/conformance/scenarios.py): testsserver-initiated-metadataandserver-initiated-artworkas separate scenarios with unrelated fixture data. No scenario asserts cross-channel consistency.
Verdict — spec-silent, MA behavior is an inadvertent app-level bug. The protocol permits the divergence; no reasonable client expects it.
Responsibility split:
- Upstream (MA): real fix is to unify the three image resolution paths — see Q4. Report as a bug upstream.
- Client-side workaround: since there’s no protocol contract to rely on, the client should formalize its preference. The two research agents disagreed on which path is “usually right” — one said binary, one said URL — consistent with the observation that the divergence direction varies per track. Practically we have user evidence that in the specific observed case URL was correct and binary was wrong, so “prefer URL when both available” is the pragmatic client default. That’s fix candidate B2 from §6.
- Spec clarification: worth adding a non-normative SHOULD statement that servers “ensure
artwork_urlinserver/stateand the binary payload on the album artwork channel refer to the same image for the same track.” Prevents other server implementations from drifting into the same shape.
8. Implications for a client-side fix — informed by the research above
Two client-side fixes are now clearly scoped. Both are small; they address different complaints.
8a. Fix for “metadata lags audio” — wire up the MA command-channel event listener
Single change: register a MaApiTransport.EventListener via MaCommandMultiplexer.eventListener = ... inside MusicAssistantManager initialization. The listener filters incoming events for type == "queue_updated", extracts data.current_item.media_item (title, artist, album, image), and pushes a synthesized metadata update into _playbackState via a new helper on PlaybackService.
This beats the SendSpin server/state broadcast by roughly 1 second per Q1’s timing breakdown. No new network calls, no new permissions. The infrastructure (interface, multiplexer dispatch) already exists and is simply unused.
Risk: double-updates if the later server/state broadcast arrives and re-applies stale or conflicting data. Mitigation: withMetadata’s preserve-on-null semantics already handle this gracefully — both sources produce the same end state.
8b. Fix for “widget shows wrong artwork” — prefer URL over binary
Per Q5, the protocol permits the divergence, so client policy is our call. The user’s specific observation (URL correct, binary wrong) plus the Q4 note that the divergence direction can vary per track means the client should pick a default, apply it consistently, and move on.
@Volatile private var urlArtwork: Bitmap? = null
@Volatile private var binaryArtwork: Bitmap? = null
private val effectiveArtwork: Bitmap? get() = urlArtwork ?: binaryArtwork
// In fetchArtwork success path: urlArtwork = scaled; updateMediaSessionArtwork(effectiveArtwork)
// In onArtwork: binaryArtwork = scaled; updateMediaSessionArtwork(effectiveArtwork)
// In onArtworkCleared: binaryArtwork = null; updateMediaSessionArtwork(effectiveArtwork)
// On track change (title differs): urlArtwork = null (force re-fetch via fetchArtwork)
URL is preferred when available because the app UI already uses URL and is observably correct. Binary stays as a bridge for the pre-URL-fetch window (solves the “Android Auto grey box” concern). On track change we invalidate urlArtwork so the fetch re-runs even if the URL string is unchanged — Coil caches the bytes so it’s near-free.
8c. Upstream fix (MA) — not ours, but worth filing
MA server should unify the three image-resolution paths in providers/sendspin/player.py so the dedupe URL, the binary byte source, and the artwork_url field all come from one canonical helper. See Q4 for details. Filing an issue against music-assistant/server with the Q4 analysis inlined would be appropriate.
The client-side fix in 8b renders us resilient regardless of whether or when MA fixes this. Landing 8b first + filing upstream is the right order.
Additional notes on 8b:
- Preserves the “grey box on Android Auto” concern: when URL isn’t fetched yet but binary is, we still show binary.
- Eliminates the last-writer race.
- Gives URL structural priority once available.
- Pairs well with B1 if we ever expand beyond channel 0.
- The per-track-change “invalidate
urlArtwork” step is what fixes the same-album-but-stale case: Coil’s cache returns instantly if the URL repeats, so no extra network cost.
9. Reference map
- Client hello build (artwork channel request):
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/protocol/message/MessageBuilder.kt:63-73 - Client binary artwork parse:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/protocol/message/BinaryMessageParser.kt:121-138 - Client artwork callbacks:
android/app/src/main/java/com/sendspindroid/sendspin/SendSpinClient.kt:444-450 - Client metadata handler:
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt:1137-1200 - Client URL fetch:
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt:1478-1509 - Client MediaSession update:
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt:1563-1583 - Client
withMetadatasemantics:android/shared/src/commonMain/kotlin/com/sendspindroid/model/PlaybackState.kt:45-81 -
Client queue fetch + id resolution:
android/shared/src/commonMain/kotlin/com/sendspindroid/musicassistant/MaCommandClient.kt:171-194 - MA SendSpin provider player lifecycle:
music_assistant/providers/sendspin/player.py_on_player_media_updated: line 782send_current_media_metadata: line 795_send_album_artwork(dedupe): line 721_send_artist_artwork(dedupe): line 749
- aiosendspin metadata role (delta semantics):
aiosendspin/server/roles/metadata/group.py:111-114,aiosendspin/server/roles/metadata/state.py:106-138 - aiosendspin artwork role (channel config):
aiosendspin/server/roles/artwork/v1.py:82-115,aiosendspin/server/roles/artwork/group.py:45-92