// 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"` }