Files
-rpi-sendspin/third_party/sendspin-go/pkg/protocol/messages.go

331 lines
13 KiB
Go

// ABOUTME: Sendspin Protocol message type definitions
// ABOUTME: Defines structs for all message types per the Sendspin spec
package protocol
import "encoding/json"
// Message is the top-level wrapper for all protocol messages
type Message struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// ClientHello is sent by clients to initiate the handshake
// Per spec: roles use versioned format like "player@v1"
type ClientHello struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
Version int `json:"version"`
SupportedRoles []string `json:"supported_roles"`
DeviceInfo *DeviceInfo `json:"device_info,omitempty"`
// Per spec: support objects use versioned keys like "player@v1_support"
PlayerV1Support *PlayerV1Support `json:"player@v1_support,omitempty"`
ArtworkV1Support *ArtworkV1Support `json:"artwork@v1_support,omitempty"`
VisualizerV1Support *VisualizerV1Support `json:"visualizer@v1_support,omitempty"`
// Legacy support fields for Music Assistant backward compatibility
// Uses unversioned keys like "player_support" instead of "player@v1_support"
PlayerSupport *PlayerSupport `json:"player_support,omitempty"`
MetadataSupport *MetadataSupport `json:"metadata_support,omitempty"`
ArtworkSupport *ArtworkSupport `json:"artwork_support,omitempty"`
VisualizerSupport *VisualizerSupport `json:"visualizer_support,omitempty"`
}
type DeviceInfo struct {
ProductName string `json:"product_name"`
Manufacturer string `json:"manufacturer"`
SoftwareVersion string `json:"software_version"`
}
// PlayerV1Support describes player@v1 capabilities per spec
type PlayerV1Support struct {
SupportedFormats []AudioFormat `json:"supported_formats"`
BufferCapacity int `json:"buffer_capacity"`
SupportedCommands []string `json:"supported_commands"`
// Legacy fields for Music Assistant backward compatibility
// MA uses separate arrays instead of AudioFormat objects
SupportCodecs []string `json:"support_codecs,omitempty"`
SupportChannels []int `json:"support_channels,omitempty"`
SupportSampleRates []int `json:"support_sample_rates,omitempty"`
SupportBitDepth []int `json:"support_bit_depth,omitempty"`
}
// ArtworkV1Support describes artwork@v1 capabilities per spec
type ArtworkV1Support struct {
Channels []ArtworkChannel `json:"channels"`
}
type ArtworkChannel struct {
Source string `json:"source"` // "album", "artist", or "none"
Format string `json:"format"` // "jpeg", "png", or "bmp"
MediaWidth int `json:"media_width"`
MediaHeight int `json:"media_height"`
}
// VisualizerV1Support describes visualizer@v1 capabilities per spec
type VisualizerV1Support struct {
BufferCapacity int `json:"buffer_capacity"`
}
type AudioFormat struct {
Codec string `json:"codec"`
Channels int `json:"channels"`
SampleRate int `json:"sample_rate"`
BitDepth int `json:"bit_depth"`
}
// ServerHello is the server's response to client/hello
type ServerHello struct {
ServerID string `json:"server_id"`
Name string `json:"name"`
Version int `json:"version"`
ActiveRoles []string `json:"active_roles"`
ConnectionReason string `json:"connection_reason"` // "discovery" or "playback"
}
// ClientStateMessage is sent as client/state with role-specific objects
type ClientStateMessage struct {
Player *PlayerState `json:"player,omitempty"`
}
// PlayerState reports the player's current state per spec
type PlayerState struct {
State string `json:"state"` // "synchronized" or "error"
Volume int `json:"volume,omitempty"` // 0-100, if volume command supported
Muted bool `json:"muted,omitempty"` // if mute command supported
}
// ServerCommandMessage is sent as server/command with role-specific objects
type ServerCommandMessage struct {
Player *PlayerCommand `json:"player,omitempty"`
}
type PlayerCommand struct {
Command string `json:"command"` // "volume" or "mute"
Volume int `json:"volume,omitempty"`
Mute bool `json:"mute,omitempty"`
}
type StreamStartPlayer struct {
Codec string `json:"codec"`
SampleRate int `json:"sample_rate"`
Channels int `json:"channels"`
BitDepth int `json:"bit_depth"`
CodecHeader string `json:"codec_header,omitempty"` // Base64-encoded
}
// StreamStartArtwork describes the artwork channels a server is about to send.
// Distinct from ArtworkV1Support (client hello): the hello advertises what the
// client can accept (media_width/height), while this describes the actual
// dimensions of the image about to be streamed (width/height).
type StreamStartArtwork struct {
Channels []ArtworkStreamChannel `json:"channels"`
}
// ArtworkStreamChannel is a single channel entry inside StreamStartArtwork.
type ArtworkStreamChannel struct {
Source string `json:"source"` // "album", "artist", etc.
Format string `json:"format"` // "jpeg", "png", "bmp"
Width int `json:"width"`
Height int `json:"height"`
}
// StreamStart notifies the client of stream format (nested structure).
// Player and Artwork fields are independent — a server may send either or both.
type StreamStart struct {
Player *StreamStartPlayer `json:"player,omitempty"`
Artwork *StreamStartArtwork `json:"artwork,omitempty"`
}
// ServerStateMessage is sent as server/state with role-specific objects
type ServerStateMessage struct {
Metadata *MetadataState `json:"metadata,omitempty"`
Controller *ControllerState `json:"controller,omitempty"`
}
// MetadataState contains track metadata per spec (for metadata role).
//
// The wire encoding is tristate: an omitted key means "unchanged, preserve
// prior value", a JSON null means "explicitly clear", and a value means
// "set". Receivers must distinguish all three to merge a diff_update onto a
// running snapshot — see HasField.
type MetadataState struct {
Timestamp int64 `json:"timestamp"` // Server clock µs when valid
Title *string `json:"title,omitempty"` // Track title
Artist *string `json:"artist,omitempty"` // Primary artist(s)
AlbumArtist *string `json:"album_artist,omitempty"` // Album artist(s)
Album *string `json:"album,omitempty"` // Album name
ArtworkURL *string `json:"artwork_url,omitempty"` // URL to artwork
Year *int `json:"year,omitempty"` // Release year YYYY
Track *int `json:"track,omitempty"` // Track number (1-indexed)
Progress *ProgressState `json:"progress,omitempty"` // Playback progress
Repeat *string `json:"repeat,omitempty"` // "off", "one", "all"
Shuffle *bool `json:"shuffle,omitempty"` // Shuffle enabled
// presentKeys records which JSON keys appeared in the incoming message,
// so callers can distinguish "field omitted" (preserve prior value)
// from "field set to null" (clear prior value). Populated by
// UnmarshalJSON; nil for messages constructed in Go directly.
presentKeys map[string]struct{}
}
// HasField reports whether the named JSON key was present in the incoming
// server/state message. The check is case-sensitive against the wire name
// (e.g. "artwork_url", not "ArtworkURL"). Returns true for messages
// constructed in-process via field assignment (not through UnmarshalJSON),
// so backwards-compatible callers that build a MetadataState directly are
// treated as if every assigned field were "present".
func (m *MetadataState) HasField(jsonKey string) bool {
if m.presentKeys == nil {
return true
}
_, ok := m.presentKeys[jsonKey]
return ok
}
// UnmarshalJSON decodes a MetadataState while tracking which JSON keys
// were present. Required to distinguish "omitted" (preserve prior value)
// from "null" (clear prior value) per the Sendspin metadata diff-update
// protocol — both decode to a nil pointer otherwise, which loses the
// signal.
func (m *MetadataState) UnmarshalJSON(data []byte) error {
// First pass: capture key presence.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Second pass: decode known fields. The `type alias` indirection is the
// standard Go trick to avoid infinite recursion: json.Unmarshal on the
// alias bypasses our custom UnmarshalJSON because the alias type does
// not inherit methods.
type alias MetadataState
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*m = MetadataState(a)
m.presentKeys = make(map[string]struct{}, len(raw))
for k := range raw {
m.presentKeys[k] = struct{}{}
}
return nil
}
// ProgressState contains playback progress info per spec
type ProgressState struct {
TrackProgress int `json:"track_progress"` // Current position in ms
TrackDuration int `json:"track_duration"` // Total duration in ms (0 = unknown)
PlaybackSpeed int `json:"playback_speed"` // Speed * 1000 (1000 = normal, 0 = paused)
}
// ControllerState contains controller state per spec
type ControllerState struct {
SupportedCommands []string `json:"supported_commands"`
Volume int `json:"volume"` // Group volume 0-100
Muted bool `json:"muted"` // Group mute state
}
// GroupUpdate is sent as group/update per spec
type GroupUpdate struct {
PlaybackState *string `json:"playback_state,omitempty"` // "playing", "paused", "stopped"
GroupID *string `json:"group_id,omitempty"`
GroupName *string `json:"group_name,omitempty"`
}
// StreamClear instructs clients to clear buffers (for seek)
type StreamClear struct {
Roles []string `json:"roles,omitempty"` // Roles to clear: "player", "visualizer"
}
// StreamEnd ends streams for specified roles
type StreamEnd struct {
Roles []string `json:"roles,omitempty"` // Roles to end (omit = all)
}
// ClientGoodbye is sent before graceful disconnect
type ClientGoodbye struct {
Reason string `json:"reason"` // "another_server", "shutdown", "restart", "user_request"
}
// ClientTime is sent for clock synchronization
type ClientTime struct {
ClientTransmitted int64 `json:"client_transmitted"` // Client timestamp in microseconds
}
// ServerTime is the response to client/time
type ServerTime struct {
ClientTransmitted int64 `json:"client_transmitted"` // Echoed client timestamp
ServerReceived int64 `json:"server_received"` // Server receive timestamp
ServerTransmitted int64 `json:"server_transmitted"` // Server send timestamp
}
// Legacy types for Music Assistant backward compatibility
// MetadataSupport describes metadata/artwork capabilities (legacy format)
type MetadataSupport struct {
SupportPictureFormats []string `json:"support_picture_formats"`
MediaWidth int `json:"media_width,omitempty"`
MediaHeight int `json:"media_height,omitempty"`
}
// StreamMetadata contains track information (legacy message type)
type StreamMetadata struct {
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
ArtworkURL string `json:"artwork_url,omitempty"`
}
// SessionMetadata contains track metadata within session updates (legacy format)
type SessionMetadata struct {
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ArtworkURL string `json:"artwork_url,omitempty"`
Track int `json:"track,omitempty"`
TrackDuration int `json:"track_duration,omitempty"`
Year int `json:"year,omitempty"`
PlaybackSpeed float64 `json:"playback_speed,omitempty"`
Repeat string `json:"repeat,omitempty"`
Shuffle bool `json:"shuffle,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
// SessionUpdate notifies client of session state changes (legacy message type)
type SessionUpdate struct {
GroupID string `json:"group_id"`
PlaybackState string `json:"playback_state,omitempty"` // "playing" or "idle"
Metadata *SessionMetadata `json:"metadata,omitempty"`
}
// VisualizerSupport is a legacy alias for VisualizerV1Support
type VisualizerSupport = VisualizerV1Support
// ArtworkSupport is a legacy alias for ArtworkV1Support
type ArtworkSupport = ArtworkV1Support
// ClientState is a legacy alias for PlayerState (flat format used with client/state)
type ClientState struct {
State string `json:"state"` // "synchronized" or "error"
Volume int `json:"volume"` // 0-100
Muted bool `json:"muted"`
}
// ServerCommand is a legacy flat format for player commands
type ServerCommand struct {
Command string `json:"command"`
Volume int `json:"volume,omitempty"`
Mute bool `json:"mute,omitempty"`
}
// PlayerSupport is a format for player capabilities (Music Assistant / aiosendspin compatibility)
type PlayerSupport struct {
SupportedFormats []AudioFormat `json:"supported_formats,omitempty"`
BufferCapacity int `json:"buffer_capacity,omitempty"`
SupportedCommands []string `json:"supported_commands,omitempty"`
}