🎉 live server seems to be working now
This commit is contained in:
330
third_party/sendspin-go/pkg/protocol/messages.go
vendored
Normal file
330
third_party/sendspin-go/pkg/protocol/messages.go
vendored
Normal file
@@ -0,0 +1,330 @@
|
||||
// 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user