🎉 live server seems to be working now
This commit is contained in:
642
third_party/sendspin-go/pkg/protocol/client.go
vendored
Normal file
642
third_party/sendspin-go/pkg/protocol/client.go
vendored
Normal file
@@ -0,0 +1,642 @@
|
||||
// ABOUTME: WebSocket client for Sendspin Protocol communication
|
||||
// ABOUTME: Handles connection, handshake, and message routing
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// BinaryMessageHeaderSize is the size of binary message header (type byte + timestamp)
|
||||
BinaryMessageHeaderSize = 1 + 8 // 9 bytes: 1 byte type + 8 byte timestamp
|
||||
|
||||
// AudioChunkMessageType is the binary message type ID for audio chunks.
|
||||
// Per spec: Player role binary messages use IDs 4-7 (bits 000001xx), slot 0 is audio.
|
||||
AudioChunkMessageType = 4
|
||||
|
||||
// Artwork binary message type IDs per spec: Artwork role uses 8-11, one per channel.
|
||||
// ArtworkChannel0MessageType is channel 0; channels 1-3 use 9, 10, 11 respectively.
|
||||
ArtworkChannel0MessageType = 8
|
||||
ArtworkChannel1MessageType = 9
|
||||
ArtworkChannel2MessageType = 10
|
||||
ArtworkChannel3MessageType = 11
|
||||
|
||||
// ArtworkChannelCount is the maximum number of artwork channels the spec allocates binary IDs for.
|
||||
ArtworkChannelCount = 4
|
||||
)
|
||||
|
||||
// Heartbeat parameters. Vars (not consts) so tests can override with shorter
|
||||
// values; production code never mutates them.
|
||||
//
|
||||
// pingPeriod: how often we send a control Ping to the server.
|
||||
// pongWait: read deadline; reset on every Pong arrival.
|
||||
// writeWait: bound on each WriteControl call so a slow socket doesn't
|
||||
// block the ping goroutine forever.
|
||||
//
|
||||
// Invariant: pingPeriod < pongWait. Otherwise the deadline can expire
|
||||
// before our next ping has a chance to elicit a pong.
|
||||
var (
|
||||
pingPeriod = 30 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
writeWait = 10 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerAddr string
|
||||
ClientID string
|
||||
Name string
|
||||
Version int
|
||||
DeviceInfo DeviceInfo
|
||||
PlayerV1Support PlayerV1Support
|
||||
ArtworkV1Support *ArtworkV1Support
|
||||
VisualizerV1Support *VisualizerV1Support
|
||||
|
||||
// SupportedRoles overrides the auto-built role list in the client/hello
|
||||
// message. When nil or empty, handshake() builds the list from the V1
|
||||
// support fields above (player@v1 + metadata@v1 always, plus artwork@v1
|
||||
// and visualizer@v1 when their support structs are set). Set this to
|
||||
// advertise a specific subset — e.g. []string{"metadata@v1"} for a
|
||||
// metadata-only client, or []string{"controller@v1"} for controller
|
||||
// scenarios where advertising player@v1 would incorrectly activate
|
||||
// the audio stream.
|
||||
SupportedRoles []string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
config Config
|
||||
conn *websocket.Conn
|
||||
mu sync.RWMutex
|
||||
|
||||
AudioChunks chan AudioChunk
|
||||
ArtworkChunks chan ArtworkChunk
|
||||
ControlMsgs chan PlayerCommand
|
||||
TimeSyncResp chan ServerTime
|
||||
StreamStart chan StreamStart
|
||||
StreamClear chan StreamClear
|
||||
StreamEnd chan StreamEnd
|
||||
ServerState chan ServerStateMessage
|
||||
GroupUpdate chan GroupUpdate
|
||||
|
||||
// serverHello holds the parsed server/hello message received during
|
||||
// handshake. Nil until Start()/Connect() completes successfully.
|
||||
serverHello *ServerHello
|
||||
|
||||
// rawServerHello holds the raw JSON envelope bytes of the server/hello
|
||||
// message, useful for conformance testing and protocol debugging where
|
||||
// the exact wire representation matters.
|
||||
rawServerHello []byte
|
||||
|
||||
connected bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type AudioChunk struct {
|
||||
Timestamp int64 // Microseconds, server clock
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// ArtworkChunk is an incoming binary artwork frame routed from the artwork@v1 role.
|
||||
type ArtworkChunk struct {
|
||||
Channel int // 0-3; derived from the binary message type minus ArtworkChannel0MessageType
|
||||
Timestamp int64 // Microseconds, server clock
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewClient(config Config) *Client {
|
||||
return newClient(config)
|
||||
}
|
||||
|
||||
// NewClientFromConn wraps an already-established websocket connection. Use
|
||||
// this for server-initiated scenarios: the caller has accepted an incoming
|
||||
// connection on a listening socket and now needs the library to run the
|
||||
// client-side protocol (handshake, message loop, channel routing) over it.
|
||||
//
|
||||
// Unlike NewClient+Connect, the returned client is NOT yet running — call
|
||||
// Start to perform the handshake and launch the read loop.
|
||||
//
|
||||
// The caller transfers ownership of conn to the client; Close will close it.
|
||||
func NewClientFromConn(config Config, conn *websocket.Conn) *Client {
|
||||
c := newClient(config)
|
||||
c.conn = conn
|
||||
c.connected = true
|
||||
return c
|
||||
}
|
||||
|
||||
func newClient(config Config) *Client {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Client{
|
||||
config: config,
|
||||
AudioChunks: make(chan AudioChunk, 100),
|
||||
ArtworkChunks: make(chan ArtworkChunk, 10),
|
||||
ControlMsgs: make(chan PlayerCommand, 10),
|
||||
TimeSyncResp: make(chan ServerTime, 10),
|
||||
StreamStart: make(chan StreamStart, 1),
|
||||
StreamClear: make(chan StreamClear, 10),
|
||||
StreamEnd: make(chan StreamEnd, 1),
|
||||
ServerState: make(chan ServerStateMessage, 10),
|
||||
GroupUpdate: make(chan GroupUpdate, 10),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect dials the configured server, runs the handshake, and starts the
|
||||
// message read loop. Use NewClientFromConn + Start when you already have
|
||||
// an accepted connection (server-initiated scenarios).
|
||||
func (c *Client) Connect() error {
|
||||
u := url.URL{Scheme: "ws", Host: c.config.ServerAddr, Path: "/sendspin"}
|
||||
log.Printf("Connecting to %s", u.String())
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial failed: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
return c.Start()
|
||||
}
|
||||
|
||||
// Start runs the client/hello handshake and launches the background read
|
||||
// loop. The connection must already be set via NewClientFromConn or Connect.
|
||||
// Calling Start more than once on the same client is undefined.
|
||||
func (c *Client) Start() error {
|
||||
c.mu.RLock()
|
||||
conn := c.conn
|
||||
c.mu.RUnlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("no connection: use NewClientFromConn or Connect before Start")
|
||||
}
|
||||
|
||||
conn.SetReadLimit(1 << 20) // 1MB
|
||||
|
||||
if err := c.handshake(); err != nil {
|
||||
c.Close()
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
}
|
||||
|
||||
// Snapshot the heartbeat vars on Start's goroutine so the values we
|
||||
// pass to pingLoop and the PongHandler are read here, not from the
|
||||
// goroutines we're about to spawn. Tests mutate these package-level
|
||||
// vars between test cases; the snapshot establishes a happens-before
|
||||
// from the mutation site to the goroutine read, which the race
|
||||
// detector requires.
|
||||
pp, pw, ww := pingPeriod, pongWait, writeWait
|
||||
|
||||
// Install PongHandler that resets the read deadline. The handler runs
|
||||
// synchronously from ReadMessage's goroutine, so SetReadDeadline is
|
||||
// safe to call from here.
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pw))
|
||||
})
|
||||
|
||||
// Initial deadline. If the server never pongs, ReadMessage in
|
||||
// readMessages will hit this deadline and exit, triggering Close().
|
||||
if err := conn.SetReadDeadline(time.Now().Add(pw)); err != nil {
|
||||
c.Close()
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
go c.readMessages()
|
||||
go c.pingLoop(pp, ww)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pingLoop sends a periodic WebSocket control ping. The server's pong
|
||||
// reply arrives via the PongHandler installed in Start, which resets
|
||||
// the read deadline. Exits on context cancel or write failure; on
|
||||
// write failure we just return — readMessages's defer is responsible
|
||||
// for the Close, so calling it from here would race that path.
|
||||
//
|
||||
// The period and write deadline are passed as args (rather than read
|
||||
// from the package vars) so the read happens-before the goroutine
|
||||
// launch; tests that mutate the package vars between cases stay clean
|
||||
// under -race.
|
||||
func (c *Client) pingLoop(period, writeDeadline time.Duration) {
|
||||
ticker := time.NewTicker(period)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.mu.RLock()
|
||||
conn := c.conn
|
||||
c.mu.RUnlock()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline)); err != nil {
|
||||
// Write failure on a control frame means the connection
|
||||
// is going down. Don't bother retrying — readMessages
|
||||
// will hit the read deadline and tear down.
|
||||
return
|
||||
}
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handshake() error {
|
||||
roles := c.buildSupportedRoles()
|
||||
|
||||
hello := ClientHello{
|
||||
ClientID: c.config.ClientID,
|
||||
Name: c.config.Name,
|
||||
Version: c.config.Version,
|
||||
SupportedRoles: roles,
|
||||
DeviceInfo: &c.config.DeviceInfo,
|
||||
ArtworkV1Support: c.config.ArtworkV1Support,
|
||||
VisualizerV1Support: c.config.VisualizerV1Support,
|
||||
}
|
||||
// Only advertise player@v1_support when player@v1 is in the role list.
|
||||
// Strict peers (aiosendspin) reject a client/hello that carries a
|
||||
// player@v1_support block without a matching role because the schema
|
||||
// marks supported_formats as non-nullable, and a zero-value Go
|
||||
// PlayerV1Support encodes its nil slice as JSON null.
|
||||
if containsRole(roles, "player@v1") {
|
||||
hello.PlayerV1Support = &c.config.PlayerV1Support
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: "client/hello",
|
||||
Payload: hello,
|
||||
}
|
||||
|
||||
if err := c.sendJSON(msg); err != nil {
|
||||
return fmt.Errorf("failed to send client/hello: %w", err)
|
||||
}
|
||||
|
||||
c.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read server/hello: %w", err)
|
||||
}
|
||||
c.conn.SetReadDeadline(time.Time{}) // Clear deadline
|
||||
|
||||
var serverMsg Message
|
||||
if err := json.Unmarshal(data, &serverMsg); err != nil {
|
||||
return fmt.Errorf("failed to parse server/hello: %w", err)
|
||||
}
|
||||
|
||||
if serverMsg.Type != "server/hello" {
|
||||
return fmt.Errorf("expected server/hello, got %s", serverMsg.Type)
|
||||
}
|
||||
|
||||
// Parse the server hello payload into a typed struct and cache both
|
||||
// the parsed form and the raw envelope bytes for later retrieval via
|
||||
// ServerHello() / RawServerHello().
|
||||
payloadBytes, err := json.Marshal(serverMsg.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-marshal server/hello payload: %w", err)
|
||||
}
|
||||
var parsedHello ServerHello
|
||||
if err := json.Unmarshal(payloadBytes, &parsedHello); err != nil {
|
||||
return fmt.Errorf("failed to decode server/hello payload: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.serverHello = &parsedHello
|
||||
c.rawServerHello = append([]byte(nil), data...)
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("Handshake complete with server")
|
||||
|
||||
// Send initial state per spec (client/state with nested player object)
|
||||
state := ClientStateMessage{
|
||||
Player: &PlayerState{
|
||||
State: "synchronized",
|
||||
Volume: 100,
|
||||
Muted: false,
|
||||
},
|
||||
}
|
||||
|
||||
stateMsg := Message{
|
||||
Type: "client/state",
|
||||
Payload: state,
|
||||
}
|
||||
|
||||
if err := c.sendJSON(stateMsg); err != nil {
|
||||
return fmt.Errorf("failed to send initial state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerHello returns the parsed server/hello message received during
|
||||
// handshake, or nil if handshake has not yet completed. Safe for concurrent
|
||||
// reads; callers should not mutate the returned struct.
|
||||
func (c *Client) ServerHello() *ServerHello {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.serverHello
|
||||
}
|
||||
|
||||
// RawServerHello returns the raw JSON envelope bytes of the server/hello
|
||||
// message received during handshake, or nil if handshake has not yet
|
||||
// completed. Useful for conformance testing and protocol debugging where
|
||||
// the exact wire representation matters. Returns a fresh copy; callers
|
||||
// may mutate it without affecting the client.
|
||||
func (c *Client) RawServerHello() []byte {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.rawServerHello == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, len(c.rawServerHello))
|
||||
copy(out, c.rawServerHello)
|
||||
return out
|
||||
}
|
||||
|
||||
// Send writes a typed envelope to the connected peer. The message is
|
||||
// wrapped as {"type": msgType, "payload": payload} and sent as a text
|
||||
// WebSocket frame. Use this for protocol messages the library does not
|
||||
// yet have a dedicated sender for (e.g. client/command in controller
|
||||
// scenarios).
|
||||
func (c *Client) Send(msgType string, payload any) error {
|
||||
return c.sendJSON(Message{Type: msgType, Payload: payload})
|
||||
}
|
||||
|
||||
// containsRole reports whether the given role is in the list.
|
||||
func containsRole(roles []string, role string) bool {
|
||||
for _, r := range roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSupportedRoles returns the role list for the client/hello message.
|
||||
// An explicit Config.SupportedRoles wins when set, since the caller is
|
||||
// telling us exactly which roles to advertise (metadata-only, controller,
|
||||
// etc.). Otherwise we build the default list from the V1 support fields:
|
||||
// player@v1 and metadata@v1 are always advertised for backwards
|
||||
// compatibility, and artwork@v1 / visualizer@v1 join the list when their
|
||||
// support structs are set.
|
||||
func (c *Client) buildSupportedRoles() []string {
|
||||
if len(c.config.SupportedRoles) > 0 {
|
||||
return c.config.SupportedRoles
|
||||
}
|
||||
roles := []string{"player@v1", "metadata@v1", "controller@v1"}
|
||||
if c.config.ArtworkV1Support != nil {
|
||||
roles = append(roles, "artwork@v1")
|
||||
}
|
||||
if c.config.VisualizerV1Support != nil {
|
||||
roles = append(roles, "visualizer@v1")
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
func (c *Client) sendJSON(msg Message) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if !c.connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
return c.conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
func (c *Client) readMessages() {
|
||||
defer c.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
messageType, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("Read error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if messageType == websocket.BinaryMessage {
|
||||
c.handleBinaryMessage(data)
|
||||
} else if messageType == websocket.TextMessage {
|
||||
c.handleJSONMessage(data)
|
||||
} else {
|
||||
log.Printf("Unknown WebSocket message type: %d", messageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleBinaryMessage(data []byte) {
|
||||
if len(data) < BinaryMessageHeaderSize {
|
||||
log.Printf("Invalid binary message: too short")
|
||||
return
|
||||
}
|
||||
|
||||
msgType := data[0]
|
||||
timestamp := int64(binary.BigEndian.Uint64(data[1:BinaryMessageHeaderSize]))
|
||||
payload := data[BinaryMessageHeaderSize:]
|
||||
|
||||
switch {
|
||||
case msgType == AudioChunkMessageType:
|
||||
select {
|
||||
case c.AudioChunks <- AudioChunk{Timestamp: timestamp, Data: payload}:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
case msgType >= ArtworkChannel0MessageType && msgType <= ArtworkChannel3MessageType:
|
||||
select {
|
||||
case c.ArtworkChunks <- ArtworkChunk{
|
||||
Channel: int(msgType) - ArtworkChannel0MessageType,
|
||||
Timestamp: timestamp,
|
||||
Data: payload,
|
||||
}:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
default:
|
||||
log.Printf("Unknown binary message type: %d", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
// handleJSONMessage routes JSON messages per spec
|
||||
func (c *Client) handleJSONMessage(data []byte) {
|
||||
var msg Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Printf("Failed to parse JSON message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(msg.Payload)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal payload for %s: %v", msg.Type, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "server/command":
|
||||
var cmdMsg ServerCommandMessage
|
||||
if err := json.Unmarshal(payloadBytes, &cmdMsg); err != nil {
|
||||
log.Printf("Failed to parse server/command: %v", err)
|
||||
return
|
||||
}
|
||||
if cmdMsg.Player != nil {
|
||||
select {
|
||||
case c.ControlMsgs <- *cmdMsg.Player:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
case "server/time":
|
||||
var timeMsg ServerTime
|
||||
if err := json.Unmarshal(payloadBytes, &timeMsg); err != nil {
|
||||
log.Printf("Failed to parse server/time: %v", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.TimeSyncResp <- timeMsg:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
|
||||
case "stream/start":
|
||||
var start StreamStart
|
||||
if err := json.Unmarshal(payloadBytes, &start); err != nil {
|
||||
log.Printf("Failed to parse stream/start: %v", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.StreamStart <- start:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
|
||||
case "stream/clear":
|
||||
var clear StreamClear
|
||||
if err := json.Unmarshal(payloadBytes, &clear); err != nil {
|
||||
log.Printf("Failed to parse stream/clear: %v", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.StreamClear <- clear:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
|
||||
case "stream/end":
|
||||
var end StreamEnd
|
||||
if err := json.Unmarshal(payloadBytes, &end); err != nil {
|
||||
log.Printf("Failed to parse stream/end: %v", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.StreamEnd <- end:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
|
||||
case "server/state":
|
||||
var state ServerStateMessage
|
||||
if err := json.Unmarshal(payloadBytes, &state); err != nil {
|
||||
log.Printf("Failed to parse server/state: %v", err)
|
||||
return
|
||||
}
|
||||
if state.Metadata != nil {
|
||||
log.Printf("Metadata: %v - %v (%v)",
|
||||
derefString(state.Metadata.Artist),
|
||||
derefString(state.Metadata.Title),
|
||||
derefString(state.Metadata.Album))
|
||||
}
|
||||
select {
|
||||
case c.ServerState <- state:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
log.Printf("Server state channel full, dropping message")
|
||||
}
|
||||
|
||||
case "group/update":
|
||||
var update GroupUpdate
|
||||
if err := json.Unmarshal(payloadBytes, &update); err != nil {
|
||||
log.Printf("Failed to parse group/update: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Group update: id=%v, state=%v",
|
||||
derefString(update.GroupID),
|
||||
derefString(update.PlaybackState))
|
||||
select {
|
||||
case c.GroupUpdate <- update:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
log.Printf("Group update channel full, dropping message")
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("Unknown message type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// SendState sends a client/state message per spec
|
||||
func (c *Client) SendState(state PlayerState) error {
|
||||
msg := Message{
|
||||
Type: "client/state",
|
||||
Payload: ClientStateMessage{
|
||||
Player: &state,
|
||||
},
|
||||
}
|
||||
return c.sendJSON(msg)
|
||||
}
|
||||
|
||||
// SendGoodbye sends a client/goodbye message before disconnecting
|
||||
func (c *Client) SendGoodbye(reason string) error {
|
||||
msg := Message{
|
||||
Type: "client/goodbye",
|
||||
Payload: ClientGoodbye{
|
||||
Reason: reason,
|
||||
},
|
||||
}
|
||||
return c.sendJSON(msg)
|
||||
}
|
||||
|
||||
func (c *Client) SendTimeSync(t1 int64) error {
|
||||
msg := Message{
|
||||
Type: "client/time",
|
||||
Payload: ClientTime{
|
||||
ClientTransmitted: t1,
|
||||
},
|
||||
}
|
||||
return c.sendJSON(msg)
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.connected {
|
||||
c.connected = false
|
||||
c.cancel()
|
||||
c.conn.Close()
|
||||
log.Printf("Connection closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the client connection is lost.
|
||||
func (c *Client) Done() <-chan struct{} {
|
||||
return c.ctx.Done()
|
||||
}
|
||||
|
||||
func (c *Client) IsConnected() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
773
third_party/sendspin-go/pkg/protocol/client_test.go
vendored
Normal file
773
third_party/sendspin-go/pkg/protocol/client_test.go
vendored
Normal file
@@ -0,0 +1,773 @@
|
||||
// ABOUTME: Tests for WebSocket client implementation
|
||||
// ABOUTME: Tests connection, handshake, and message routing
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
config := Config{
|
||||
ServerAddr: "localhost:8927",
|
||||
ClientID: "test-client",
|
||||
Name: "Test Player",
|
||||
}
|
||||
|
||||
client := NewClient(config)
|
||||
if client == nil {
|
||||
t.Fatal("expected client to be created")
|
||||
}
|
||||
|
||||
if client.config.ServerAddr != "localhost:8927" {
|
||||
t.Errorf("expected server addr localhost:8927, got %s", client.config.ServerAddr)
|
||||
}
|
||||
|
||||
if client.ArtworkChunks == nil {
|
||||
t.Error("expected ArtworkChunks channel to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// buildBinaryFrame constructs a binary protocol frame matching what the server
|
||||
// emits: [1 byte type][8 byte big-endian timestamp µs][payload].
|
||||
func buildBinaryFrame(msgType byte, timestamp int64, payload []byte) []byte {
|
||||
frame := make([]byte, BinaryMessageHeaderSize+len(payload))
|
||||
frame[0] = msgType
|
||||
binary.BigEndian.PutUint64(frame[1:BinaryMessageHeaderSize], uint64(timestamp))
|
||||
copy(frame[BinaryMessageHeaderSize:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
// recvWithTimeout waits briefly for a value on a channel. Returns the zero
|
||||
// value + false if nothing arrives in time.
|
||||
func recvAudioChunk(t *testing.T, ch <-chan AudioChunk) (AudioChunk, bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case chunk := <-ch:
|
||||
return chunk, true
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
return AudioChunk{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func recvArtworkChunk(t *testing.T, ch <-chan ArtworkChunk) (ArtworkChunk, bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case chunk := <-ch:
|
||||
return chunk, true
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
return ArtworkChunk{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBinaryMessage_AudioChunkRouting(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
payload := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
|
||||
client.handleBinaryMessage(buildBinaryFrame(AudioChunkMessageType, 123_456, payload))
|
||||
|
||||
chunk, ok := recvAudioChunk(t, client.AudioChunks)
|
||||
if !ok {
|
||||
t.Fatal("expected an AudioChunk on the channel")
|
||||
}
|
||||
if chunk.Timestamp != 123_456 {
|
||||
t.Errorf("timestamp = %d, want 123456", chunk.Timestamp)
|
||||
}
|
||||
if string(chunk.Data) != string(payload) {
|
||||
t.Errorf("data = %x, want %x", chunk.Data, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleBinaryMessage_ArtworkRouting covers all four artwork channel IDs
|
||||
// (types 8, 9, 10, 11 → channels 0, 1, 2, 3). Closes #27: "Unknown binary
|
||||
// message type: 8" was the spec-defined ArtworkChannel0MessageType being
|
||||
// silently dropped.
|
||||
func TestHandleBinaryMessage_ArtworkRouting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msgType byte
|
||||
channel int
|
||||
}{
|
||||
{"channel 0", ArtworkChannel0MessageType, 0},
|
||||
{"channel 1", ArtworkChannel1MessageType, 1},
|
||||
{"channel 2", ArtworkChannel2MessageType, 2},
|
||||
{"channel 3", ArtworkChannel3MessageType, 3},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
imageBytes := []byte("fake-jpeg-bytes-here")
|
||||
timestamp := int64(999_888_777)
|
||||
|
||||
client.handleBinaryMessage(buildBinaryFrame(tc.msgType, timestamp, imageBytes))
|
||||
|
||||
chunk, ok := recvArtworkChunk(t, client.ArtworkChunks)
|
||||
if !ok {
|
||||
t.Fatalf("expected an ArtworkChunk on the channel (msgType=%d)", tc.msgType)
|
||||
}
|
||||
if chunk.Channel != tc.channel {
|
||||
t.Errorf("channel = %d, want %d", chunk.Channel, tc.channel)
|
||||
}
|
||||
if chunk.Timestamp != timestamp {
|
||||
t.Errorf("timestamp = %d, want %d", chunk.Timestamp, timestamp)
|
||||
}
|
||||
if string(chunk.Data) != string(imageBytes) {
|
||||
t.Errorf("data = %q, want %q", chunk.Data, imageBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleBinaryMessage_UnknownTypeLogged confirms unknown types are still
|
||||
// dropped (and not routed anywhere) after the artwork additions. This guards
|
||||
// the switch-default branch so a future add-without-test doesn't turn a
|
||||
// real bug into silent mis-routing.
|
||||
func TestHandleBinaryMessage_UnknownTypeLogged(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
|
||||
// Type 99 is not defined anywhere in the spec.
|
||||
client.handleBinaryMessage(buildBinaryFrame(99, 0, []byte{0xff}))
|
||||
|
||||
if _, ok := recvAudioChunk(t, client.AudioChunks); ok {
|
||||
t.Error("unknown type was routed to AudioChunks")
|
||||
}
|
||||
if _, ok := recvArtworkChunk(t, client.ArtworkChunks); ok {
|
||||
t.Error("unknown type was routed to ArtworkChunks")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSupportedRoles_Default covers the old auto-built path. Keep this
|
||||
// test lean; a table test for every permutation of the four V1 support
|
||||
// fields is over-investment for what's essentially a handful of if
|
||||
// statements.
|
||||
func TestBuildSupportedRoles_Default(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config Config
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "bare default is player+metadata+controller",
|
||||
config: Config{},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1"},
|
||||
},
|
||||
{
|
||||
name: "artwork support adds artwork@v1",
|
||||
config: Config{ArtworkV1Support: &ArtworkV1Support{}},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"},
|
||||
},
|
||||
{
|
||||
name: "visualizer support adds visualizer@v1",
|
||||
config: Config{VisualizerV1Support: &VisualizerV1Support{}},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1", "visualizer@v1"},
|
||||
},
|
||||
{
|
||||
name: "both support structs set",
|
||||
config: Config{
|
||||
ArtworkV1Support: &ArtworkV1Support{},
|
||||
VisualizerV1Support: &VisualizerV1Support{},
|
||||
},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1", "visualizer@v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := NewClient(tc.config)
|
||||
got := client.buildSupportedRoles()
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("buildSupportedRoles() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSupportedRoles_ExplicitOverride verifies that Config.SupportedRoles
|
||||
// wins over the V1 support struct auto-build. This is the load-bearing
|
||||
// behavior for conformance metadata-only and controller scenarios that
|
||||
// need to NOT advertise player@v1.
|
||||
func TestBuildSupportedRoles_ExplicitOverride(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config Config
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "controller only",
|
||||
config: Config{SupportedRoles: []string{"controller@v1"}},
|
||||
want: []string{"controller@v1"},
|
||||
},
|
||||
{
|
||||
name: "metadata only",
|
||||
config: Config{SupportedRoles: []string{"metadata@v1"}},
|
||||
want: []string{"metadata@v1"},
|
||||
},
|
||||
{
|
||||
// Confirms that setting ArtworkV1Support alongside an override
|
||||
// does NOT inject artwork@v1 — the caller's override is canon.
|
||||
name: "override wins over artwork support struct",
|
||||
config: Config{
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
ArtworkV1Support: &ArtworkV1Support{},
|
||||
},
|
||||
want: []string{"player@v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := NewClient(tc.config)
|
||||
got := client.buildSupportedRoles()
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("buildSupportedRoles() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewClientFromConn_HandshakeAndClose spins up a local WebSocket server
|
||||
// that plays the Sendspin handshake dance (read client/hello, send
|
||||
// server/hello, read client/state, close). The test then uses
|
||||
// NewClientFromConn + Start to drive the client side over an accepted
|
||||
// connection, proving that server-initiated scenarios can use the library
|
||||
// without hand-rolling the message loop.
|
||||
func TestNewClientFromConn_HandshakeAndClose(t *testing.T) {
|
||||
// Capture the roles the client advertises so we can assert the override
|
||||
// plumbed through to the wire.
|
||||
capturedHello := make(chan ClientHello, 1)
|
||||
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Read client/hello.
|
||||
_, helloBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
var envelope Message
|
||||
if err := json.Unmarshal(helloBytes, &envelope); err != nil {
|
||||
t.Errorf("unmarshal client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(envelope.Payload)
|
||||
var hello ClientHello
|
||||
_ = json.Unmarshal(payloadBytes, &hello)
|
||||
capturedHello <- hello
|
||||
|
||||
// Send server/hello.
|
||||
serverHello := Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{
|
||||
ServerID: "test-server",
|
||||
Name: "Test Server",
|
||||
Version: 1,
|
||||
ActiveRoles: hello.SupportedRoles,
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(serverHello); err != nil {
|
||||
t.Errorf("write server/hello: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Read client/state (the library sends this immediately after
|
||||
// a successful handshake — see handshake() in client.go).
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the connection open briefly so the client's read loop has
|
||||
// something to read, then close.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
// Dial it ourselves to simulate a server-initiated scenario where the
|
||||
// caller has an accepted *websocket.Conn and hands it to the library.
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "test-from-conn",
|
||||
Name: "FromConn Test",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case hello := <-capturedHello:
|
||||
if !reflect.DeepEqual(hello.SupportedRoles, []string{"controller@v1"}) {
|
||||
t.Errorf("server saw SupportedRoles = %v, want [controller@v1]", hello.SupportedRoles)
|
||||
}
|
||||
if hello.ClientID != "test-from-conn" {
|
||||
t.Errorf("server saw ClientID = %q, want test-from-conn", hello.ClientID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server never captured a client/hello")
|
||||
}
|
||||
|
||||
// ServerHello() and RawServerHello() must return the handshake data
|
||||
// after Start() completes. The test server above responds with a hello
|
||||
// whose ServerID is "test-server" and Name is "Test Server", so both
|
||||
// accessors should agree.
|
||||
parsed := client.ServerHello()
|
||||
if parsed == nil {
|
||||
t.Fatal("ServerHello() returned nil after successful handshake")
|
||||
}
|
||||
if parsed.ServerID != "test-server" {
|
||||
t.Errorf("ServerHello().ServerID = %q, want test-server", parsed.ServerID)
|
||||
}
|
||||
if parsed.Name != "Test Server" {
|
||||
t.Errorf("ServerHello().Name = %q, want Test Server", parsed.Name)
|
||||
}
|
||||
|
||||
raw := client.RawServerHello()
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("RawServerHello() returned empty bytes after successful handshake")
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("RawServerHello() did not return valid JSON: %v", err)
|
||||
}
|
||||
if envelope["type"] != "server/hello" {
|
||||
t.Errorf("raw envelope type = %v, want server/hello", envelope["type"])
|
||||
}
|
||||
|
||||
// Confirm RawServerHello returns a copy: mutating the returned slice
|
||||
// must not affect subsequent calls.
|
||||
if len(raw) > 0 {
|
||||
raw[0] = 0xFF
|
||||
}
|
||||
raw2 := client.RawServerHello()
|
||||
if bytes.Equal(raw, raw2) {
|
||||
t.Error("RawServerHello returned a shared reference; mutation leaked into the client")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_NoConnection confirms Start refuses to run without a connection
|
||||
// in place. Prevents a future caller from assuming Start does its own dialing.
|
||||
func TestStart_NoConnection(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
err := client.Start()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when Start is called with no connection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no connection") {
|
||||
t.Errorf("error message = %q, want substring %q", err.Error(), "no connection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerHello_BeforeHandshake guards against nil confusion: the
|
||||
// accessors must return nil/empty before Start() runs, not stale data
|
||||
// from a previous client or a zero-value ServerHello.
|
||||
func TestServerHello_BeforeHandshake(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
|
||||
if hello := client.ServerHello(); hello != nil {
|
||||
t.Errorf("ServerHello() before handshake = %+v, want nil", hello)
|
||||
}
|
||||
if raw := client.RawServerHello(); raw != nil {
|
||||
t.Errorf("RawServerHello() before handshake = %v, want nil", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientSend_WritesEnvelope verifies that Client.Send emits a correctly
|
||||
// shaped {"type": ..., "payload": ...} envelope on the wire. The test
|
||||
// server reads a client/command message after handshake and asserts on the
|
||||
// envelope shape, which is exactly what the conformance adapter's
|
||||
// controller scenarios need.
|
||||
func TestClientSend_WritesEnvelope(t *testing.T) {
|
||||
capturedCommand := make(chan map[string]any, 1)
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Handshake: read client/hello, write server/hello, read client/state.
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1},
|
||||
}); err != nil {
|
||||
t.Errorf("write server/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Now read the client/command the test below will send via Send().
|
||||
_, cmdBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read client/command: %v", err)
|
||||
return
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(cmdBytes, &envelope); err != nil {
|
||||
t.Errorf("unmarshal envelope: %v", err)
|
||||
return
|
||||
}
|
||||
capturedCommand <- envelope
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "sender",
|
||||
Name: "Sender Test",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{"controller": map[string]any{"command": "next"}}
|
||||
if err := client.Send("client/command", payload); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case envelope := <-capturedCommand:
|
||||
if envelope["type"] != "client/command" {
|
||||
t.Errorf("envelope.type = %v, want client/command", envelope["type"])
|
||||
}
|
||||
innerPayload, ok := envelope["payload"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope.payload not a map: %v", envelope["payload"])
|
||||
}
|
||||
controller, ok := innerPayload["controller"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload.controller not a map: %v", innerPayload["controller"])
|
||||
}
|
||||
if controller["command"] != "next" {
|
||||
t.Errorf("controller.command = %v, want next", controller["command"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server never captured a client/command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_DetectsHalfOpenConnection verifies that the client tears down
|
||||
// (Done() fires) when the server stops responding to WebSocket control
|
||||
// pings. Without the heartbeat fix, ReadMessage blocks forever after a
|
||||
// silently-dropped connection (NAT timeout, idle eviction, missed RST) and
|
||||
// Done() never closes — the symptom Chris saw in the field as "Burst sample
|
||||
// N/8 timed out" with no reconnect.
|
||||
//
|
||||
// The fake server completes the Sendspin handshake and then installs a
|
||||
// no-op PingHandler so the client's pings are read but never elicit a Pong.
|
||||
// The client's pongWait deadline expires, ReadMessage errors out, Close
|
||||
// runs from readMessages's defer, and Done() fires.
|
||||
func TestClient_DetectsHalfOpenConnection(t *testing.T) {
|
||||
// Override the package-level heartbeat vars to test-friendly values
|
||||
// so the test wallclock budget is well under a second. Restore via
|
||||
// t.Cleanup so other tests in this binary keep production timings.
|
||||
savedPingPeriod, savedPongWait, savedWriteWait := pingPeriod, pongWait, writeWait
|
||||
pingPeriod = 50 * time.Millisecond
|
||||
pongWait = 250 * time.Millisecond
|
||||
writeWait = 100 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
pingPeriod = savedPingPeriod
|
||||
pongWait = savedPongWait
|
||||
writeWait = savedWriteWait
|
||||
})
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
serverDone := make(chan struct{})
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(serverDone)
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Override the default PingHandler so the server does NOT auto-pong.
|
||||
// This is the half-open simulation: pings arrive but no pong reply
|
||||
// goes back. Returning nil keeps ReadMessage from surfacing an error,
|
||||
// so the server happily reads forever while the client's read deadline
|
||||
// counts down on the other side.
|
||||
conn.SetPingHandler(func(string) error { return nil })
|
||||
|
||||
// Sendspin handshake: client/hello → server/hello → client/state.
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1},
|
||||
}); err != nil {
|
||||
t.Errorf("write server/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Drain any further frames (the client's pings) until the connection
|
||||
// errors out from the client side. We don't pong, so the client's
|
||||
// read deadline will fire and it will close the socket.
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "half-open-test",
|
||||
Name: "Half Open Test",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
// pongWait + slack. If Done() doesn't fire, the heartbeat is broken.
|
||||
select {
|
||||
case <-client.Done():
|
||||
case <-time.After(750 * time.Millisecond):
|
||||
t.Fatal("Done() did not fire within pongWait + slack; half-open connection went undetected")
|
||||
}
|
||||
|
||||
// Idempotent shutdown: calling Close again must not panic.
|
||||
client.Close()
|
||||
|
||||
// Let the server goroutine unwind so the test doesn't leak it.
|
||||
select {
|
||||
case <-serverDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Log("server handler did not unwind in time (not fatal; client side already verified)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandshake_PlayerSupportGatedByRoles is a regression test for a real
|
||||
// bug caught by the conformance harness against aiosendspin. The Go
|
||||
// library used to emit player@v1_support in every client/hello regardless
|
||||
// of the advertised role list. aiosendspin's schema marks
|
||||
// ClientHelloPlayerSupport.supported_formats as non-nullable, so a zero-
|
||||
// value Go PlayerV1Support (whose nil slice encodes as JSON null) caused
|
||||
// aiosendspin's mashumaro deserializer to reject the entire hello and
|
||||
// close the connection with code 1000. sendspin-go then reported
|
||||
// "handshake failed: failed to read server/hello: websocket: close 1000".
|
||||
//
|
||||
// The fix: only set hello.PlayerV1Support when player@v1 is in the
|
||||
// final advertised role list. This mirrors how ArtworkV1Support and
|
||||
// VisualizerV1Support were already being handled (only set when
|
||||
// non-nil in config), and aligns with aiosendspin's own validation
|
||||
// comment: "player@v1_support must be provided when 'player@v1' is in
|
||||
// supported_roles".
|
||||
//
|
||||
// The test captures the raw hello bytes on the wire and asserts the
|
||||
// top-level payload key is absent for non-player roles, and present
|
||||
// for player@v1 (the default).
|
||||
func TestHandshake_PlayerSupportGatedByRoles(t *testing.T) {
|
||||
// playerSupport is populated for the "advertised player" cases so the
|
||||
// emitted player@v1_support block is a well-formed one — matches what
|
||||
// a real caller would pass — and the secondary null-check assertion
|
||||
// below is meaningful rather than a false positive.
|
||||
playerSupport := PlayerV1Support{
|
||||
SupportedFormats: []AudioFormat{
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
},
|
||||
BufferCapacity: 1_000_000,
|
||||
SupportedCommands: []string{"volume", "mute"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
supportedRoles []string
|
||||
playerV1Support PlayerV1Support
|
||||
wantPlayerKey bool
|
||||
}{
|
||||
{
|
||||
name: "controller only omits player support",
|
||||
supportedRoles: []string{"controller@v1"},
|
||||
wantPlayerKey: false,
|
||||
},
|
||||
{
|
||||
name: "metadata only omits player support",
|
||||
supportedRoles: []string{"metadata@v1"},
|
||||
wantPlayerKey: false,
|
||||
},
|
||||
{
|
||||
name: "artwork only omits player support",
|
||||
supportedRoles: []string{"artwork@v1"},
|
||||
wantPlayerKey: false,
|
||||
},
|
||||
{
|
||||
name: "player advertised keeps player support",
|
||||
supportedRoles: []string{"player@v1"},
|
||||
playerV1Support: playerSupport,
|
||||
wantPlayerKey: true,
|
||||
},
|
||||
{
|
||||
name: "player plus other roles keeps player support",
|
||||
supportedRoles: []string{"player@v1", "controller@v1"},
|
||||
playerV1Support: playerSupport,
|
||||
wantPlayerKey: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
capturedHello := make(chan []byte, 1)
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, helloBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
// Copy the slice; gorilla reuses its underlying buffer.
|
||||
snapshot := make([]byte, len(helloBytes))
|
||||
copy(snapshot, helloBytes)
|
||||
capturedHello <- snapshot
|
||||
|
||||
// Drive the rest of the handshake so the client goroutine
|
||||
// doesn't error out on a hard close mid-stream.
|
||||
_ = conn.WriteJSON(Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1},
|
||||
})
|
||||
_, _, _ = conn.ReadMessage() // client/state
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "test",
|
||||
Name: "Test",
|
||||
Version: 1,
|
||||
SupportedRoles: tc.supportedRoles,
|
||||
PlayerV1Support: tc.playerV1Support,
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
var helloBytes []byte
|
||||
select {
|
||||
case helloBytes = <-capturedHello:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server never captured a client/hello")
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(helloBytes, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
payload, ok := envelope["payload"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope.payload is not a map: %v", envelope["payload"])
|
||||
}
|
||||
|
||||
_, havePlayerKey := payload["player@v1_support"]
|
||||
if havePlayerKey != tc.wantPlayerKey {
|
||||
if tc.wantPlayerKey {
|
||||
t.Errorf("client/hello missing player@v1_support when player@v1 is advertised:\n%s", helloBytes)
|
||||
} else {
|
||||
t.Errorf("client/hello includes player@v1_support when player@v1 is NOT advertised (%v):\n%s",
|
||||
tc.supportedRoles, helloBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// When PlayerSupport is emitted and its supported_formats is nil,
|
||||
// the JSON encoder produces null. Catch that too — a nil-slice
|
||||
// null would still fail against aiosendspin's schema even if
|
||||
// we correctly gated on roles.
|
||||
if havePlayerKey {
|
||||
playerSupport, ok := payload["player@v1_support"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("player@v1_support is not a map: %v", payload["player@v1_support"])
|
||||
}
|
||||
if playerSupport["supported_formats"] == nil {
|
||||
t.Error("player@v1_support.supported_formats serialized as null; aiosendspin's schema rejects this")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
12
third_party/sendspin-go/pkg/protocol/doc.go
vendored
Normal file
12
third_party/sendspin-go/pkg/protocol/doc.go
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// ABOUTME: Resonate wire protocol package
|
||||
// ABOUTME: Defines protocol messages and WebSocket client
|
||||
// Package protocol implements the Resonate wire protocol.
|
||||
//
|
||||
// Provides message types and WebSocket client for communicating
|
||||
// with Resonate servers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// client, err := protocol.NewClient("localhost:8927")
|
||||
// err = client.SendHello(helloMsg)
|
||||
package protocol
|
||||
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"`
|
||||
}
|
||||
336
third_party/sendspin-go/pkg/protocol/messages_test.go
vendored
Normal file
336
third_party/sendspin-go/pkg/protocol/messages_test.go
vendored
Normal file
@@ -0,0 +1,336 @@
|
||||
// ABOUTME: Tests for Sendspin Protocol message types
|
||||
// ABOUTME: Verifies JSON marshaling/unmarshaling of protocol messages
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientHelloMarshaling(t *testing.T) {
|
||||
hello := ClientHello{
|
||||
ClientID: "test-id",
|
||||
Name: "Test Player",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
DeviceInfo: &DeviceInfo{
|
||||
ProductName: "Test Product",
|
||||
Manufacturer: "Test Mfg",
|
||||
SoftwareVersion: "0.1.0",
|
||||
},
|
||||
PlayerV1Support: &PlayerV1Support{
|
||||
SupportedFormats: []AudioFormat{
|
||||
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
{Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
},
|
||||
BufferCapacity: 1048576,
|
||||
SupportedCommands: []string{"volume", "mute"},
|
||||
},
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: "client/hello",
|
||||
Payload: hello,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Type != "client/hello" {
|
||||
t.Errorf("expected type client/hello, got %s", decoded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStateMarshaling(t *testing.T) {
|
||||
state := ClientStateMessage{
|
||||
Player: &PlayerState{
|
||||
State: "synchronized",
|
||||
Volume: 80,
|
||||
Muted: false,
|
||||
},
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: "client/state",
|
||||
Payload: state,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Type != "client/state" {
|
||||
t.Errorf("expected type client/state, got %s", decoded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHelloMarshaling(t *testing.T) {
|
||||
hello := ServerHello{
|
||||
ServerID: "server-123",
|
||||
Name: "Test Server",
|
||||
Version: 1,
|
||||
ActiveRoles: []string{"player@v1", "metadata@v1"},
|
||||
ConnectionReason: "playback",
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: "server/hello",
|
||||
Payload: hello,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Type != "server/hello" {
|
||||
t.Errorf("expected type server/hello, got %s", decoded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamStartArtwork_RoundTrip verifies the StreamStart message can carry
|
||||
// an artwork channel list alongside (or instead of) the player field. The
|
||||
// wire format must use width/height (not media_width/media_height, which is
|
||||
// the hello-message convention).
|
||||
func TestStreamStartArtwork_RoundTrip(t *testing.T) {
|
||||
original := StreamStart{
|
||||
Artwork: &StreamStartArtwork{
|
||||
Channels: []ArtworkStreamChannel{
|
||||
{Source: "album", Format: "jpeg", Width: 256, Height: 256},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
// Spot-check the wire shape matches what peers expect.
|
||||
want := `{"artwork":{"channels":[{"source":"album","format":"jpeg","width":256,"height":256}]}}`
|
||||
if string(data) != want {
|
||||
t.Errorf("marshal output = %s, want %s", data, want)
|
||||
}
|
||||
|
||||
var decoded StreamStart
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.Artwork == nil {
|
||||
t.Fatal("decoded.Artwork is nil")
|
||||
}
|
||||
if len(decoded.Artwork.Channels) != 1 {
|
||||
t.Fatalf("channels = %d, want 1", len(decoded.Artwork.Channels))
|
||||
}
|
||||
ch := decoded.Artwork.Channels[0]
|
||||
if ch.Source != "album" || ch.Format != "jpeg" || ch.Width != 256 || ch.Height != 256 {
|
||||
t.Errorf("decoded channel = %+v", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamStart_PlayerOmittedWhenNil confirms that existing callers who
|
||||
// only set Player (never Artwork) still produce the same wire shape.
|
||||
func TestStreamStart_PlayerOnlyUnchanged(t *testing.T) {
|
||||
msg := StreamStart{
|
||||
Player: &StreamStartPlayer{
|
||||
Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Artwork field must be omitted entirely when nil.
|
||||
want := `{"player":{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":24}}`
|
||||
if string(data) != want {
|
||||
t.Errorf("marshal output = %s, want %s", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataState_TristateUnmarshal verifies the wire-protocol contract:
|
||||
// an omitted JSON key, an explicit null, and a value must each produce a
|
||||
// distinguishable receiver-side signal. The merge layer in
|
||||
// pkg/sendspin.Receiver depends on this.
|
||||
func TestMetadataState_TristateUnmarshal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
wantPtrSet bool // is Title pointer non-nil?
|
||||
wantTitle string // value if non-nil
|
||||
wantHasField bool // HasField("title")?
|
||||
}{
|
||||
{"omitted", `{"timestamp": 1}`, false, "", false},
|
||||
{"null", `{"timestamp": 1, "title": null}`, false, "", true},
|
||||
{"value", `{"timestamp": 1, "title": "Hello"}`, true, "Hello", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var m MetadataState
|
||||
if err := json.Unmarshal([]byte(tc.json), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if (m.Title != nil) != tc.wantPtrSet {
|
||||
t.Errorf("Title pointer non-nil = %v, want %v", m.Title != nil, tc.wantPtrSet)
|
||||
}
|
||||
if m.Title != nil && *m.Title != tc.wantTitle {
|
||||
t.Errorf("Title value = %q, want %q", *m.Title, tc.wantTitle)
|
||||
}
|
||||
if got := m.HasField("title"); got != tc.wantHasField {
|
||||
t.Errorf("HasField(\"title\") = %v, want %v", got, tc.wantHasField)
|
||||
}
|
||||
// Timestamp must always decode.
|
||||
if m.Timestamp != 1 {
|
||||
t.Errorf("Timestamp = %d, want 1", m.Timestamp)
|
||||
}
|
||||
// HasField("timestamp") should be true in every case
|
||||
// because the wire JSON always carries it here.
|
||||
if !m.HasField("timestamp") {
|
||||
t.Error("HasField(\"timestamp\") = false, want true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataState_HasFieldInGoConstruction confirms that messages built
|
||||
// in process via field assignment (the conformance adapter and any
|
||||
// server-side helpers do this) report HasField == true for every field.
|
||||
// This is the backwards-compat guarantee callers can rely on.
|
||||
func TestMetadataState_HasFieldInGoConstruction(t *testing.T) {
|
||||
title := "T"
|
||||
m := MetadataState{Timestamp: 42, Title: &title}
|
||||
for _, key := range []string{"title", "artist", "album", "progress", "shuffle", "anything"} {
|
||||
if !m.HasField(key) {
|
||||
t.Errorf("HasField(%q) = false on Go-constructed value, want true", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataState_TristateAcrossAllFields exercises every tristate field
|
||||
// at once to make sure the alias-based decoder did not accidentally drop a
|
||||
// field type (pointer-to-int, pointer-to-bool, pointer-to-struct).
|
||||
func TestMetadataState_TristateAcrossAllFields(t *testing.T) {
|
||||
raw := `{
|
||||
"timestamp": 100,
|
||||
"title": "T",
|
||||
"artist": null,
|
||||
"album": "A",
|
||||
"album_artist": null,
|
||||
"artwork_url": "http://x",
|
||||
"year": 2020,
|
||||
"track": null,
|
||||
"progress": {"track_progress": 1000, "track_duration": 5000, "playback_speed": 1000},
|
||||
"shuffle": true
|
||||
}`
|
||||
var m MetadataState
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// Set fields decode to non-nil pointers.
|
||||
if m.Title == nil || *m.Title != "T" {
|
||||
t.Errorf("Title = %v, want pointer to \"T\"", m.Title)
|
||||
}
|
||||
if m.Album == nil || *m.Album != "A" {
|
||||
t.Errorf("Album = %v, want pointer to \"A\"", m.Album)
|
||||
}
|
||||
if m.ArtworkURL == nil || *m.ArtworkURL != "http://x" {
|
||||
t.Errorf("ArtworkURL = %v", m.ArtworkURL)
|
||||
}
|
||||
if m.Year == nil || *m.Year != 2020 {
|
||||
t.Errorf("Year = %v, want pointer to 2020", m.Year)
|
||||
}
|
||||
if m.Shuffle == nil || *m.Shuffle != true {
|
||||
t.Errorf("Shuffle = %v, want pointer to true", m.Shuffle)
|
||||
}
|
||||
if m.Progress == nil {
|
||||
t.Error("Progress = nil, want non-nil")
|
||||
} else if m.Progress.TrackDuration != 5000 {
|
||||
t.Errorf("Progress.TrackDuration = %d, want 5000", m.Progress.TrackDuration)
|
||||
}
|
||||
// Null fields decode to nil pointers but HasField is true.
|
||||
for _, key := range []string{"artist", "album_artist", "track"} {
|
||||
if !m.HasField(key) {
|
||||
t.Errorf("HasField(%q) = false, want true (null is present)", key)
|
||||
}
|
||||
}
|
||||
if m.Artist != nil {
|
||||
t.Errorf("Artist = %v, want nil (null on wire)", m.Artist)
|
||||
}
|
||||
if m.AlbumArtist != nil {
|
||||
t.Errorf("AlbumArtist = %v, want nil (null on wire)", m.AlbumArtist)
|
||||
}
|
||||
if m.Track != nil {
|
||||
t.Errorf("Track = %v, want nil (null on wire)", m.Track)
|
||||
}
|
||||
// Omitted fields: HasField false.
|
||||
if m.HasField("repeat") {
|
||||
t.Error("HasField(\"repeat\") = true, want false (omitted)")
|
||||
}
|
||||
if m.Repeat != nil {
|
||||
t.Errorf("Repeat = %v, want nil (omitted)", m.Repeat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataState_RoundTripPreservesValues ensures encoding then
|
||||
// decoding a populated MetadataState preserves every value. The wire
|
||||
// shape must be unchanged by the new decoder.
|
||||
func TestMetadataState_RoundTripPreservesValues(t *testing.T) {
|
||||
title := "Song"
|
||||
artist := "Artist"
|
||||
album := "Album"
|
||||
original := MetadataState{
|
||||
Timestamp: 12345,
|
||||
Title: &title,
|
||||
Artist: &artist,
|
||||
Album: &album,
|
||||
Progress: &ProgressState{
|
||||
TrackProgress: 1000, TrackDuration: 60000, PlaybackSpeed: 1000,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(&original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded MetadataState
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.Timestamp != original.Timestamp {
|
||||
t.Errorf("Timestamp = %d, want %d", decoded.Timestamp, original.Timestamp)
|
||||
}
|
||||
if decoded.Title == nil || *decoded.Title != title {
|
||||
t.Errorf("Title = %v", decoded.Title)
|
||||
}
|
||||
if decoded.Progress == nil || decoded.Progress.TrackDuration != 60000 {
|
||||
t.Errorf("Progress = %+v", decoded.Progress)
|
||||
}
|
||||
// Round-trip preserves presence: the original was constructed in Go
|
||||
// (presentKeys nil → HasField always true), then re-marshaled (omitempty
|
||||
// drops nil pointers), then re-decoded (presentKeys captures only the
|
||||
// fields that survived omitempty). HasField must report present for the
|
||||
// fields we set.
|
||||
if !decoded.HasField("title") || !decoded.HasField("progress") {
|
||||
t.Error("decoded.HasField missed a set field after round-trip")
|
||||
}
|
||||
}
|
||||
145
third_party/sendspin-go/pkg/protocol/server_conn.go
vendored
Normal file
145
third_party/sendspin-go/pkg/protocol/server_conn.go
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
// ABOUTME: ServerConn wraps a WebSocket for server-side typed message sending
|
||||
// ABOUTME: CGO-free alternative to sendspin.ServerClient for adapters and tools
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ServerConn wraps an accepted WebSocket connection with typed Send and
|
||||
// SendBinary methods plus a background writer goroutine. It is the
|
||||
// lightweight, CGO-free counterpart to sendspin.ServerClient — use it
|
||||
// when you need typed protocol I/O without the full server runtime
|
||||
// (e.g., conformance adapters, CLI tools, test harnesses).
|
||||
//
|
||||
// The zero value is not usable — construct via NewServerConn.
|
||||
type ServerConn struct {
|
||||
conn *websocket.Conn
|
||||
id string
|
||||
name string
|
||||
sendChan chan interface{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewServerConn wraps an existing WebSocket connection and starts a
|
||||
// background writer goroutine. Call Close() when done to stop the
|
||||
// writer. Does NOT close the underlying WebSocket — the caller owns
|
||||
// that lifecycle.
|
||||
func NewServerConn(conn *websocket.Conn, id, name string) *ServerConn {
|
||||
sc := &ServerConn{
|
||||
conn: conn,
|
||||
id: id,
|
||||
name: name,
|
||||
sendChan: make(chan interface{}, 100),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go sc.runWriter()
|
||||
return sc
|
||||
}
|
||||
|
||||
// ID returns the identifier passed to NewServerConn.
|
||||
func (sc *ServerConn) ID() string { return sc.id }
|
||||
|
||||
// Name returns the name passed to NewServerConn.
|
||||
func (sc *ServerConn) Name() string { return sc.name }
|
||||
|
||||
// Send enqueues a typed control message (JSON envelope) for
|
||||
// transmission. Returns an error immediately if the send buffer is
|
||||
// full. The message is wrapped in a {"type": msgType, "payload": ...}
|
||||
// envelope by the writer goroutine.
|
||||
func (sc *ServerConn) Send(msgType string, payload interface{}) error {
|
||||
msg := Message{
|
||||
Type: msgType,
|
||||
Payload: payload,
|
||||
}
|
||||
select {
|
||||
case sc.sendChan <- msg:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("send buffer full")
|
||||
}
|
||||
}
|
||||
|
||||
// SendBinary enqueues a raw binary frame for transmission. Returns an
|
||||
// error immediately if the send buffer is full.
|
||||
func (sc *ServerConn) SendBinary(data []byte) error {
|
||||
select {
|
||||
case sc.sendChan <- data:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("send buffer full")
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the writer goroutine. Safe to call multiple times. Does
|
||||
// NOT close the underlying WebSocket connection.
|
||||
func (sc *ServerConn) Close() {
|
||||
sc.once.Do(func() {
|
||||
close(sc.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (sc *ServerConn) runWriter() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
const writeDeadline = 10 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-sc.sendChan:
|
||||
switch v := msg.(type) {
|
||||
case []byte:
|
||||
sc.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
if err := sc.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
|
||||
return
|
||||
}
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sc.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
if err := sc.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
if err := sc.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-sc.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAudioChunk packs timestamp + payload into a Sendspin binary audio
|
||||
// frame: [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes].
|
||||
func CreateAudioChunk(timestamp int64, audioData []byte) []byte {
|
||||
chunk := make([]byte, BinaryMessageHeaderSize+len(audioData))
|
||||
chunk[0] = AudioChunkMessageType
|
||||
binary.BigEndian.PutUint64(chunk[1:BinaryMessageHeaderSize], uint64(timestamp))
|
||||
copy(chunk[BinaryMessageHeaderSize:], audioData)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte
|
||||
// timestamp (µs)][image bytes]. Channel is 0-3, mapping to the artwork
|
||||
// channel message types (8-11).
|
||||
func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte {
|
||||
chunk := make([]byte, BinaryMessageHeaderSize+len(imageData))
|
||||
chunk[0] = byte(ArtworkChannel0MessageType + channel)
|
||||
binary.BigEndian.PutUint64(chunk[1:BinaryMessageHeaderSize], uint64(timestamp))
|
||||
copy(chunk[BinaryMessageHeaderSize:], imageData)
|
||||
return chunk
|
||||
}
|
||||
146
third_party/sendspin-go/pkg/protocol/server_conn_test.go
vendored
Normal file
146
third_party/sendspin-go/pkg/protocol/server_conn_test.go
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
// ABOUTME: Tests for ServerConn typed connection wrapper
|
||||
// ABOUTME: Verifies Send/SendBinary/Close lifecycle and frame helpers
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestServerConn_SendAndClose(t *testing.T) {
|
||||
received := make(chan Message, 1)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upgrader := websocket.Upgrader{}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("server read: %v", err)
|
||||
return
|
||||
}
|
||||
var msg Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Errorf("unmarshal: %v", err)
|
||||
return
|
||||
}
|
||||
received <- msg
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + server.URL[len("http"):]
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sc := NewServerConn(conn, "test-id", "Test Server")
|
||||
|
||||
if err := sc.Send("server/hello", map[string]string{"name": "test"}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-received:
|
||||
if msg.Type != "server/hello" {
|
||||
t.Errorf("type = %q, want server/hello", msg.Type)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for message")
|
||||
}
|
||||
|
||||
sc.Close()
|
||||
sc.Close() // double-close must not panic
|
||||
}
|
||||
|
||||
func TestServerConn_SendBinary(t *testing.T) {
|
||||
received := make(chan []byte, 1)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upgrader := websocket.Upgrader{}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("server read: %v", err)
|
||||
return
|
||||
}
|
||||
if msgType != websocket.BinaryMessage {
|
||||
t.Errorf("message type = %d, want binary", msgType)
|
||||
}
|
||||
received <- data
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + server.URL[len("http"):]
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sc := NewServerConn(conn, "test-id", "Test")
|
||||
defer sc.Close()
|
||||
|
||||
chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB})
|
||||
if err := sc.SendBinary(chunk); err != nil {
|
||||
t.Fatalf("SendBinary: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-received:
|
||||
if data[0] != AudioChunkMessageType {
|
||||
t.Errorf("type byte = %d, want %d", data[0], AudioChunkMessageType)
|
||||
}
|
||||
if len(data) != BinaryMessageHeaderSize+2 {
|
||||
t.Errorf("len = %d, want %d", len(data), BinaryMessageHeaderSize+2)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for binary message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConn_Accessors(t *testing.T) {
|
||||
sc := &ServerConn{id: "abc", name: "Test"}
|
||||
if sc.ID() != "abc" {
|
||||
t.Errorf("ID() = %q, want abc", sc.ID())
|
||||
}
|
||||
if sc.Name() != "Test" {
|
||||
t.Errorf("Name() = %q, want Test", sc.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAudioChunk_Format(t *testing.T) {
|
||||
chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB})
|
||||
if chunk[0] != AudioChunkMessageType {
|
||||
t.Errorf("type = %d, want %d", chunk[0], AudioChunkMessageType)
|
||||
}
|
||||
if len(chunk) != BinaryMessageHeaderSize+2 {
|
||||
t.Errorf("len = %d, want %d", len(chunk), BinaryMessageHeaderSize+2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArtworkChunk_Format(t *testing.T) {
|
||||
chunk := CreateArtworkChunk(2, 5000000, []byte{0xFF})
|
||||
expectedType := byte(ArtworkChannel0MessageType + 2)
|
||||
if chunk[0] != expectedType {
|
||||
t.Errorf("type = %d, want %d", chunk[0], expectedType)
|
||||
}
|
||||
if len(chunk) != BinaryMessageHeaderSize+1 {
|
||||
t.Errorf("len = %d, want %d", len(chunk), BinaryMessageHeaderSize+1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user