Skip to content

Define scheduled updates for metadata, color, and artwork - #135

Merged
maximmaxim345 merged 14 commits into
mainfrom
fix/scheduled-update-handling
Aug 28, 2026
Merged

Define scheduled updates for metadata, color, and artwork#135
maximmaxim345 merged 14 commits into
mainfrom
fix/scheduled-update-handling

Conversation

@maximmaxim345

@maximmaxim345 maximmaxim345 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Define what a future timestamp on metadata, color, and artwork messages means: the update is scheduled and takes effect at that time, with one shared current-plus-pending model where the newest message always wins the pending slot.

Changes

The metadata and color state timestamps left the future-timestamp case undefined, and the artwork binary rule ("latest wins") could be read as either arrival order or timestamp order, so client behavior diverged and a server could not safely send an update ahead of time (e.g. the next track's metadata timed to the audible track change, which lags the server's own transition by the buffered audio).

All three roles now define the same model. Clients keep their current state plus at most one pending update: a future-timestamped message becomes the pending update, replacing any held one, and applies when its time is reached; any other message applies immediately and discards the held one. Clients never compare timestamps. Around it, server/state defers to the role sections instead of implying apply-on-receipt, a null role object discards any pending update, omitting a role object leaves a pending update in place, the first state message for a role must carry a past-or-present timestamp, progress extrapolation reads only from the current state and never from a pending update, an empty artwork message is a scheduled clear, and stream/end discards pending images. There is no wire change and no role version bump; this pins down what v1 left undefined.

This solves the problem without needing stream/clear. stream/clear would not properly solve this problem, since:

  • It is only defined on a stream basis. metadata and color don't use binary streams.
  • stream/clear would clear all pending artwork channels, this would not look great in practice, causing disappearing images.

What #175 removed

Rebased onto main now that #175 has landed. Dropping delta merging retired the rule this PR previously needed: that the first message sent after a scheduled update repeat every field the scheduled message carried. With full state on every message, a client that applied the pending update and one that discarded it both land in the next message's state, so there is nothing left to reconcile. The cancel recipe survives in simplified form and now reads the same way in all three roles.

#175 also anchors track_progress to the metadata timestamp, which is what makes a scheduled progress well defined: it describes the position at the moment the update takes effect.

Changed after review

The lead-time bound now measures from the timestamp rather than from the moment of scheduling: "SHOULD NOT send a scheduled update more than 20 seconds before its timestamp". That is a different rule from the original "SHOULD NOT schedule an update more than 20 seconds ahead", and the better one, since it bounds how long a client holds a pending update and therefore how much a pause discards.

Breaking changes: server

In practice none: AFAIK no server sends timestamps in advance, and timestamping every update with the current time remains fully conformant with no obligations. A server that schedules ahead cancels by resending the current state with a past or present timestamp; artwork needs nothing special since every message is a complete image.

Breaking changes: client

Clients that apply metadata/color on receipt render scheduled updates too early once servers start scheduling; they need the single pending slot: hold a future-timestamped message until its time, let any newer message replace it, no timestamp comparison anywhere. Artwork clients that queue multiple timestamped images or block on them must reduce to the same single pending image per channel and discard pending images on stream/end.

@kahrendt kahrendt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we specify a suggestion in the spec on how much lead time the server should future data? Doing it early on means potentially more network traffic around the artwork role; i.e., pausing needs to clear the pending buffer and then it would get resent as soon as its resumed. If it only sends it a small time in advance this window gets narrower.

@maximmaxim345

maximmaxim345 commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Should we specify a suggestion in the spec on how much lead time the server should future data? Doing it early on means potentially more network traffic around the artwork role; i.e., pausing needs to clear the pending buffer and then it would get resent as soon as its resumed. If it only sends it a small time in advance this window gets narrower.

My Music Assistant implementation/prototype sends it around 30s in advance, would limiting it to maybe 20s or 15s (with a SHOULD) be fine with you? Or keep it 30s and add a note in the Spec?

It's not explicitly mentioned anywhere, but a nice feature this can allow is a "coming up" display, already showing the next track with a countdown.

For reference, Roon has something similar in their player:
image
(https://www.fidelity-online.de/the-new-roon-radio/)

@maximmaxim345

Copy link
Copy Markdown
Member Author

For now I set the limit at 20 seconds, but I can still change it if you think that isn't optimal.

Also explicitly mentioned how clients might use the data to encourage servers to actually send it in advance.
But I still didn't require servers to send it in advance since that heavily depends on the servers architecture and content.

@maximmaxim345

Copy link
Copy Markdown
Member Author

Found a gap in this PR while I was thinking about #128. beebf5f now discards the pending image if stream/start reconfigures that artwork channel.
Alternative would be to keep it and treat a channel's config as describing only the images sent while it is current, but then a channel switched to source: 'none' would still display an image seconds after being disabled.

Comment thread roles/artwork/v1.md Outdated
Comment thread roles/color/v1.md Outdated
Comment thread roles/metadata/v1.md Outdated
Comment thread roles/metadata/v1.md
@maximmaxim345

This comment was marked as outdated.

maximmaxim345 added a commit that referenced this pull request Aug 28, 2026
`client/state`, `server/state` and `group/update` each defined their own
merge rules, and the three disagreed on whether merging was required,
whether `null` cleared a field, how deep the merge went, and which
message had to be complete (#149). Rather than reconcile them into one
rule, drop merging.

## Changes:

- All three messages carry full state on every message.
- For `client/state` and `server/state` the role object is the unit: an
included object carries that role's full state, and an omitted one
leaves that role's state unchanged. Differential updates survive at role
granularity, which is what the optional role objects were always for.
- Drop the `| null` variant from leaf fields in the `metadata` and
`color` objects. A role object may still be `null` in `server/state`,
which is how the server ends a role's state when it drops the role from
`active_roles`, and it was never a delta mechanism.
- `group/update`'s `playback_state`, `group_id` and `group_name` are no
longer optional.

What this costs is bandwidth on metadata text, which is sent on state
change rather than continuously; anything large already travels as a
binary message. What it saves is the reconciliation layer on the
receiving side, which has been a steady source of bugs and runs to a few
hundred lines in a client that gets it right.

## Fields whose absence carried a second meaning

Dropping the merge silently repurposes every optional field that used to
mean "unchanged". Each one is now stated where the reader meets the
field, rather than in a rule they have to remember:

- `metadata.progress`: omitting it clears the client's position, so the
server includes it in every `metadata` state that has a position to
report. Track position is calculated from the current metadata state
rather than from the last message that happened to include `progress`,
which only made sense while absent meant unchanged. A resent `progress`
is measured at the new `timestamp` rather than copied forward under it,
which the merge rules used to make moot by never resending it at all.
- The player state object's `supported_commands`: absence now says the
player accepts no commands, which is what `[]` already says. The `?`
goes rather than the redundant encoding staying around to silently
revoke `set_output_delay`. Both other `supported_commands` in the spec
are already non-optional.
- Role objects themselves, covered by the omitted-object rule above.

## Follow-on cleanups

- The player role's "Delta updates" paragraph is gone.
- `REQUIRED for players` on the three non-optional fields in the player
state object goes with it. It was an anchor for that paragraph, `?`
already marks optionality (see the note under Communication), and the
object is player-only.
- The `available: false` handling no longer describes sending a
`group/update` carrying only `playback_state`.

This conflicts with #135 on `messaging.md`, `roles/metadata/v1.md` and
`roles/color/v1.md`, in that PR's favour: with no merging, its rule that
the first message after a scheduled one must repeat the scheduled
message's fields has nothing left to do.

Out of scope: the `management/set-pairing-config` patch semantics in
`management.md`, which is a configuration API rather than state
streaming.

Closes #149
Refs #101

## Breaking changes

Both directions break, not just the server.

Servers must send full `server/state` and `group/update` messages
instead of deltas. A client that still merges keeps stale leaves,
because clearing a leaf is now omission rather than an explicit `null`.

Clients must send full `client/state` messages, and a player must now
always carry `supported_commands`. A client that still sends deltas has
them read as full state, so every field it omits is dropped rather than
retained. Neither side errors.

---------

Co-authored-by: Maxim Raznatovski <nda.mr43@gmail.com>
@maximmaxim345
maximmaxim345 force-pushed the fix/scheduled-update-handling branch from beebf5f to 3fe35bd Compare August 28, 2026 11:32
@maximmaxim345
maximmaxim345 marked this pull request as ready for review August 28, 2026 11:34

@kahrendt kahrendt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@maximmaxim345
maximmaxim345 merged commit 148ed41 into main Aug 28, 2026
1 check passed
@maximmaxim345
maximmaxim345 deleted the fix/scheduled-update-handling branch August 28, 2026 13:36
maximmaxim345 added a commit that referenced this pull request Sep 2, 2026
An artwork image larger than one Noise frame (65518 bytes of payload)
currently relies on transport fragmentation, which admits no
interleaving: the sender must finish the fragmented message before any
other frame, so the whole image is an indivisible burst that audio
chunks cannot preempt. A player holding only `min_buffer_ms` of audio
can underrun behind it; on a live stream that truncates audio. With BMP
gone (#168) the worst case shrank, but photographic JPEG still exceeds
one frame beyond roughly 500x500 (the spec's own `stream/start` example,
800x800 JPEG, is 3-5 frames), and PNG exceeds it at almost any artwork
size.

## Changes:

- An image is transferred as a fixed 14-byte announce message carrying
the timestamp and the image's `total_size` (uint32), followed by parts
carrying only image data, all on the channel's message type; a flags
byte after the type byte distinguishes the message kinds, following the
shape #172 gave fragmentation
- The announce carries no image data, so a client can allocate its image
buffer before any image bytes arrive and write each part directly into
it; the transfer completes when the received data reaches `total_size`
- Every artwork message is capped at 65518 bytes after the type byte so
it never needs transport fragmentation; any other messages MAY be sent
between the messages of a transfer, and transfers on different channels
are independent
- The pending image is now the most recently announced image, from its
announce until it becomes current: an announce discards any held pending
image, partly received or complete, and the pending image becomes
current once its transfer completes and its timestamp is reached
- A rejected announce (no active stream, or client not available) still
starts its transfer, so its parts are rejected rather than treated as
malformed
- The clear message becomes an announce with `total_size` 0, which
completes immediately with no parts
- A two-byte cancel message (flags bit 1) discards the channel's pending
image, partly received or complete, leaving the current image showing;
it replaces resend-the-current-image as the cancel recipe in the
scheduled-artwork rules, which under chunking would cost a full image
retransfer
- Malformed sequences (a message under 2 bytes or over the frame cap, an
announce whose length is not 14 bytes, a cancel longer than 2 bytes, a
part with no transfer in flight, data extending past `total_size`, a
nonzero reserved flag bit, both flag bits set) close the connection,
matching fragmentation

The pending-image redefinition keeps the newest-wins rule from #135
applying at the announce, as it did at message arrival before chunking,
and means a client needs at most two image buffers per channel,
receiving the transfer directly into the pending one.

Every image costs one extra small message, including images that fit in
a single frame; the uniform format was judged worth ~31 bytes of
ciphertext per image to give clients flexibility to transfer directly
into a proper sized buffer.

There is no explicit last-part flag; `total_size` already determines
completion, and a redundant end marker would add mismatch states to
define.

Every artwork message changes shape: the flags byte shifts the
timestamp, the header and image data travel in separate messages, and
the empty clear message becomes a bare announce with `total_size` 0.

Closes #134

## Breaking changes:

The wire format for artwork is completely changed. All servers and
clients will have to re-implement to handle:

- the announce binary message
- the artwork specific split chunks versus using the protocol's
generalized noise split
- handling the pending slot; i.e., servers should now send a cancel
message instead of re-sending the current image

---------

Co-authored-by: Maxim Raznatovski <nda.mr43@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants