🎉 live server seems to be working now
This commit is contained in:
115
third_party/sendspin-go/pkg/sendspin/buffer_tracker.go
vendored
Normal file
115
third_party/sendspin-go/pkg/sendspin/buffer_tracker.go
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
// ABOUTME: Per-client buffer tracker for send-ahead pacing
|
||||
// ABOUTME: Tracks in-flight bytes and duration to prevent client buffer overflow
|
||||
package sendspin
|
||||
|
||||
const (
|
||||
// DefaultMaxBufferDurationUs caps buffer duration at 30 seconds
|
||||
// regardless of byte capacity, matching aiosendspin's behavior.
|
||||
DefaultMaxBufferDurationUs = 30_000_000
|
||||
)
|
||||
|
||||
// bufferedChunk records one sent chunk's end time, size, and duration.
|
||||
type bufferedChunk struct {
|
||||
endTimeUs int64
|
||||
byteCount int
|
||||
durationUs int64
|
||||
}
|
||||
|
||||
// BufferTracker tracks the estimated bytes and duration of audio that a
|
||||
// client has buffered (sent but not yet played). The server checks
|
||||
// CanSend before each chunk send and skips the client if the buffer is
|
||||
// full. PruneConsumed removes chunks whose playback time has passed.
|
||||
//
|
||||
// All methods are called from the same goroutine (the audio tick loop)
|
||||
// so no synchronization is needed.
|
||||
type BufferTracker struct {
|
||||
ring []bufferedChunk
|
||||
head int // index of the oldest entry
|
||||
count int // number of valid entries
|
||||
|
||||
bufferedBytes int
|
||||
bufferedDurationUs int64
|
||||
|
||||
capacityBytes int
|
||||
maxDurationUs int64
|
||||
}
|
||||
|
||||
// NewBufferTracker creates a tracker with the given byte capacity and
|
||||
// a 30-second duration cap.
|
||||
func NewBufferTracker(capacityBytes int) *BufferTracker {
|
||||
// Pre-allocate for ~30s of 20ms chunks = 1500 slots.
|
||||
// The ring grows if needed but this avoids early resizes.
|
||||
initialCap := 1500
|
||||
if capacityBytes <= 0 {
|
||||
capacityBytes = 1048576 // 1MB default
|
||||
}
|
||||
return &BufferTracker{
|
||||
ring: make([]bufferedChunk, initialCap),
|
||||
capacityBytes: capacityBytes,
|
||||
maxDurationUs: DefaultMaxBufferDurationUs,
|
||||
}
|
||||
}
|
||||
|
||||
// PruneConsumed removes all chunks whose end time has passed. Call this
|
||||
// once per tick before CanSend.
|
||||
func (t *BufferTracker) PruneConsumed(nowUs int64) {
|
||||
for t.count > 0 {
|
||||
entry := t.ring[t.head]
|
||||
if entry.endTimeUs > nowUs {
|
||||
break
|
||||
}
|
||||
t.bufferedBytes -= entry.byteCount
|
||||
t.bufferedDurationUs -= entry.durationUs
|
||||
t.head = (t.head + 1) % len(t.ring)
|
||||
t.count--
|
||||
}
|
||||
}
|
||||
|
||||
// CanSend reports whether a chunk of the given size and duration can be
|
||||
// sent without exceeding the client's buffer capacity or the duration cap.
|
||||
func (t *BufferTracker) CanSend(byteCount int, durationUs int64) bool {
|
||||
if t.bufferedBytes+byteCount > t.capacityBytes {
|
||||
return false
|
||||
}
|
||||
if t.bufferedDurationUs+durationUs > t.maxDurationUs {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Register records a sent chunk. Call this after CanSend returns true
|
||||
// and the chunk has been enqueued for transmission.
|
||||
func (t *BufferTracker) Register(endTimeUs int64, byteCount int, durationUs int64) {
|
||||
if t.count == len(t.ring) {
|
||||
t.grow()
|
||||
}
|
||||
idx := (t.head + t.count) % len(t.ring)
|
||||
t.ring[idx] = bufferedChunk{
|
||||
endTimeUs: endTimeUs,
|
||||
byteCount: byteCount,
|
||||
durationUs: durationUs,
|
||||
}
|
||||
t.count++
|
||||
t.bufferedBytes += byteCount
|
||||
t.bufferedDurationUs += durationUs
|
||||
}
|
||||
|
||||
// BufferedBytes returns the current estimated bytes in the client's buffer.
|
||||
func (t *BufferTracker) BufferedBytes() int {
|
||||
return t.bufferedBytes
|
||||
}
|
||||
|
||||
// BufferedDurationUs returns the current estimated duration in the client's buffer.
|
||||
func (t *BufferTracker) BufferedDurationUs() int64 {
|
||||
return t.bufferedDurationUs
|
||||
}
|
||||
|
||||
func (t *BufferTracker) grow() {
|
||||
newCap := len(t.ring) * 2
|
||||
newRing := make([]bufferedChunk, newCap)
|
||||
for i := 0; i < t.count; i++ {
|
||||
newRing[i] = t.ring[(t.head+i)%len(t.ring)]
|
||||
}
|
||||
t.ring = newRing
|
||||
t.head = 0
|
||||
}
|
||||
157
third_party/sendspin-go/pkg/sendspin/buffer_tracker_test.go
vendored
Normal file
157
third_party/sendspin-go/pkg/sendspin/buffer_tracker_test.go
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
// ABOUTME: Tests for the per-client BufferTracker
|
||||
// ABOUTME: Verifies capacity gating, duration cap, prune, and ring growth
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBufferTracker_BasicFlow(t *testing.T) {
|
||||
tracker := NewBufferTracker(1000) // 1000 bytes capacity
|
||||
|
||||
// Should be able to send when empty
|
||||
if !tracker.CanSend(100, 20000) {
|
||||
t.Error("should be able to send when empty")
|
||||
}
|
||||
|
||||
// Register a chunk
|
||||
tracker.Register(100000, 100, 20000) // ends at 100ms, 100 bytes, 20ms duration
|
||||
|
||||
if tracker.BufferedBytes() != 100 {
|
||||
t.Errorf("buffered bytes = %d, want 100", tracker.BufferedBytes())
|
||||
}
|
||||
|
||||
// Should still be able to send more
|
||||
if !tracker.CanSend(100, 20000) {
|
||||
t.Error("should be able to send with capacity remaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_ByteCapacity(t *testing.T) {
|
||||
tracker := NewBufferTracker(200)
|
||||
|
||||
// Fill to capacity
|
||||
tracker.Register(100000, 100, 20000)
|
||||
tracker.Register(200000, 100, 20000)
|
||||
|
||||
// Should NOT be able to send — at capacity
|
||||
if tracker.CanSend(100, 20000) {
|
||||
t.Error("should not be able to send at byte capacity")
|
||||
}
|
||||
|
||||
// Even a 1-byte chunk should be rejected
|
||||
if tracker.CanSend(1, 20000) {
|
||||
t.Error("should not be able to send when over byte capacity")
|
||||
}
|
||||
|
||||
// Prune the first chunk (simulate time passing)
|
||||
tracker.PruneConsumed(100000) // now = 100ms, first chunk ends at 100ms
|
||||
|
||||
// Should be able to send again
|
||||
if !tracker.CanSend(100, 20000) {
|
||||
t.Error("should be able to send after pruning")
|
||||
}
|
||||
if tracker.BufferedBytes() != 100 {
|
||||
t.Errorf("buffered bytes after prune = %d, want 100", tracker.BufferedBytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_DurationCap(t *testing.T) {
|
||||
tracker := NewBufferTracker(1000000) // large byte capacity
|
||||
|
||||
// Fill 30 seconds of 20ms chunks (1500 chunks × 20000μs = 30s)
|
||||
endTime := int64(0)
|
||||
for i := 0; i < 1500; i++ {
|
||||
endTime += 20000
|
||||
tracker.Register(endTime, 100, 20000)
|
||||
}
|
||||
|
||||
// At exactly 30s — should NOT be able to send more
|
||||
if tracker.CanSend(100, 20000) {
|
||||
t.Error("should not be able to send at 30s duration cap")
|
||||
}
|
||||
|
||||
// Prune 1 chunk — should free up space
|
||||
tracker.PruneConsumed(20000)
|
||||
if !tracker.CanSend(100, 20000) {
|
||||
t.Error("should be able to send after pruning one chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_PruneMultiple(t *testing.T) {
|
||||
tracker := NewBufferTracker(10000)
|
||||
|
||||
tracker.Register(100000, 50, 20000)
|
||||
tracker.Register(200000, 60, 20000)
|
||||
tracker.Register(300000, 70, 20000)
|
||||
|
||||
if tracker.BufferedBytes() != 180 {
|
||||
t.Errorf("bytes = %d, want 180", tracker.BufferedBytes())
|
||||
}
|
||||
|
||||
// Prune first two
|
||||
tracker.PruneConsumed(200000)
|
||||
if tracker.BufferedBytes() != 70 {
|
||||
t.Errorf("after prune bytes = %d, want 70", tracker.BufferedBytes())
|
||||
}
|
||||
if tracker.BufferedDurationUs() != 20000 {
|
||||
t.Errorf("after prune duration = %d, want 20000", tracker.BufferedDurationUs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_PruneAll(t *testing.T) {
|
||||
tracker := NewBufferTracker(10000)
|
||||
|
||||
tracker.Register(100000, 50, 20000)
|
||||
tracker.Register(200000, 60, 20000)
|
||||
|
||||
tracker.PruneConsumed(300000) // past both
|
||||
|
||||
if tracker.BufferedBytes() != 0 {
|
||||
t.Errorf("bytes after full prune = %d, want 0", tracker.BufferedBytes())
|
||||
}
|
||||
if tracker.BufferedDurationUs() != 0 {
|
||||
t.Errorf("duration after full prune = %d, want 0", tracker.BufferedDurationUs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_RingGrowth(t *testing.T) {
|
||||
tracker := NewBufferTracker(1000000)
|
||||
|
||||
// Fill beyond the initial ring capacity (1500)
|
||||
for i := 0; i < 2000; i++ {
|
||||
endTime := int64((i + 1) * 20000)
|
||||
tracker.Register(endTime, 10, 20000)
|
||||
}
|
||||
|
||||
// Prune half
|
||||
tracker.PruneConsumed(1000 * 20000) // prune first 1000
|
||||
|
||||
if tracker.BufferedBytes() != 10000 {
|
||||
t.Errorf("bytes after partial prune = %d, want 10000", tracker.BufferedBytes())
|
||||
}
|
||||
|
||||
// Should still work correctly after growth
|
||||
if !tracker.CanSend(10, 20000) {
|
||||
t.Error("should be able to send after partial prune")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_EmptyPrune(t *testing.T) {
|
||||
tracker := NewBufferTracker(1000)
|
||||
|
||||
// Prune on empty tracker should not panic
|
||||
tracker.PruneConsumed(999999)
|
||||
|
||||
if tracker.BufferedBytes() != 0 {
|
||||
t.Error("empty tracker should have 0 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferTracker_ZeroCapacity(t *testing.T) {
|
||||
tracker := NewBufferTracker(0) // should default to 1MB
|
||||
|
||||
if !tracker.CanSend(1000, 20000) {
|
||||
t.Error("zero capacity should default to 1MB")
|
||||
}
|
||||
}
|
||||
158
third_party/sendspin-go/pkg/sendspin/client_dialer.go
vendored
Normal file
158
third_party/sendspin-go/pkg/sendspin/client_dialer.go
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
// ABOUTME: Outbound client discovery dialer for server-initiated mode
|
||||
// ABOUTME: Converts discovery.ClientInfo into dialed WebSocket connections
|
||||
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/internal/discovery"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// clientInfoURL builds a ws:// URL from a discovered ClientInfo.
|
||||
// Normalizes Path to ensure a single leading slash.
|
||||
func clientInfoURL(info *discovery.ClientInfo) string {
|
||||
path := info.Path
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return fmt.Sprintf("ws://%s:%d%s", info.Host, info.Port, path)
|
||||
}
|
||||
|
||||
// dialAndHandle opens a WebSocket to a discovered client and hands the
|
||||
// connection to the provided handler. The handler is expected to block
|
||||
// until the connection is fully drained (e.g. handleConnection). The
|
||||
// handler's return value is propagated so the dialer can distinguish
|
||||
// "session ran to completion — latch the instance" from "rejected by
|
||||
// filter — back off and retry."
|
||||
func dialAndHandle(ctx context.Context, info *discovery.ClientInfo, handle func(*websocket.Conn) error) error {
|
||||
url := clientInfoURL(info)
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||
|
||||
conn, _, err := dialer.DialContext(ctx, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", url, err)
|
||||
}
|
||||
log.Printf("Dialed discovered client %s at %s", info.Name, url)
|
||||
return handle(conn)
|
||||
}
|
||||
|
||||
// dialFunc dials a discovered client and returns when the resulting
|
||||
// connection has been fully handled. Returning nil means the connection
|
||||
// completed normally; returning an error means the dial or handshake
|
||||
// failed and the slot should be released for retry.
|
||||
type dialFunc func(ctx context.Context, info *discovery.ClientInfo) error
|
||||
|
||||
// clientDialer consumes discovery events and dispatches one goroutine
|
||||
// per unique mDNS instance, deduping so we never open two sockets to
|
||||
// the same advertisement concurrently.
|
||||
type clientDialer struct {
|
||||
in <-chan *discovery.ClientInfo
|
||||
dial dialFunc
|
||||
|
||||
baseBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
active map[string]bool // currently dialing/connected
|
||||
cooldown map[string]time.Time // instance -> earliest retry time
|
||||
failures map[string]int // consecutive failure count per instance
|
||||
}
|
||||
|
||||
// newClientDialer constructs a clientDialer that reads discovery events
|
||||
// from in and dispatches them through dial.
|
||||
func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer {
|
||||
return &clientDialer{
|
||||
in: in,
|
||||
dial: dial,
|
||||
baseBackoff: 1 * time.Second,
|
||||
maxBackoff: 30 * time.Second,
|
||||
active: make(map[string]bool),
|
||||
cooldown: make(map[string]time.Time),
|
||||
failures: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// run pumps discovery events until ctx is cancelled. It returns after
|
||||
// all in-flight dial goroutines have completed.
|
||||
func (d *clientDialer) run(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case info, ok := <-d.in:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if !d.claim(info.Instance) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(info *discovery.ClientInfo) {
|
||||
defer wg.Done()
|
||||
err := d.dial(ctx, info)
|
||||
d.release(info.Instance, err)
|
||||
if err != nil {
|
||||
log.Printf("dial client %s: %v", info.Instance, err)
|
||||
}
|
||||
}(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// claim returns true if the instance slot was free, not in cooldown,
|
||||
// and is now owned by the caller. Returns false otherwise.
|
||||
func (d *clientDialer) claim(instance string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.active[instance] {
|
||||
return false
|
||||
}
|
||||
if until, ok := d.cooldown[instance]; ok && time.Now().Before(until) {
|
||||
return false
|
||||
}
|
||||
d.active[instance] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// release updates the instance slot after a dial attempt completes.
|
||||
// On success (dialErr == nil) the instance stays latched in the active
|
||||
// set — we never re-dial a successfully-connected instance. On error
|
||||
// it frees the slot and schedules an exponentially-backed-off cooldown
|
||||
// before the next retry.
|
||||
func (d *clientDialer) release(instance string, dialErr error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if dialErr == nil {
|
||||
// Latch: keep active[instance] = true so future discovery
|
||||
// events for this instance are silently ignored. The server
|
||||
// already handled the connection; re-dialing would create a
|
||||
// duplicate session.
|
||||
delete(d.failures, instance)
|
||||
delete(d.cooldown, instance)
|
||||
return
|
||||
}
|
||||
|
||||
// Dial failed — release the slot so the instance can be retried
|
||||
// after the backoff period.
|
||||
delete(d.active, instance)
|
||||
d.failures[instance]++
|
||||
backoff := d.baseBackoff * time.Duration(1<<(d.failures[instance]-1))
|
||||
if backoff > d.maxBackoff || backoff <= 0 {
|
||||
backoff = d.maxBackoff
|
||||
}
|
||||
d.cooldown[instance] = time.Now().Add(backoff)
|
||||
}
|
||||
165
third_party/sendspin-go/pkg/sendspin/client_dialer_test.go
vendored
Normal file
165
third_party/sendspin-go/pkg/sendspin/client_dialer_test.go
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
// ABOUTME: Tests for outbound client discovery and dialing
|
||||
// ABOUTME: Uses injected dial functions — no real network I/O
|
||||
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/internal/discovery"
|
||||
)
|
||||
|
||||
func TestClientInfoURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info *discovery.ClientInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ipv4 host",
|
||||
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "/sendspin"},
|
||||
want: "ws://192.168.1.42:8928/sendspin",
|
||||
},
|
||||
{
|
||||
name: "path missing leading slash is normalized",
|
||||
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "sendspin"},
|
||||
want: "ws://192.168.1.42:8928/sendspin",
|
||||
},
|
||||
{
|
||||
name: "custom path preserved",
|
||||
info: &discovery.ClientInfo{Host: "10.0.0.5", Port: 9000, Path: "/custom/endpoint"},
|
||||
want: "ws://10.0.0.5:9000/custom/endpoint",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clientInfoURL(tt.info)
|
||||
if got != tt.want {
|
||||
t.Errorf("clientInfoURL = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDialerDedupesByInstance(t *testing.T) {
|
||||
in := make(chan *discovery.ClientInfo, 10)
|
||||
|
||||
var dialCalls int32
|
||||
var mu sync.Mutex
|
||||
var seen []string
|
||||
|
||||
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
|
||||
atomic.AddInt32(&dialCalls, 1)
|
||||
mu.Lock()
|
||||
seen = append(seen, info.Instance)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
d := newClientDialer(in, dial)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Emit the same instance 3 times and a different instance once
|
||||
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
|
||||
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
|
||||
in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"}
|
||||
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
|
||||
|
||||
// Wait until at least 2 unique dials have happened
|
||||
deadline := time.After(500 * time.Millisecond)
|
||||
for {
|
||||
if atomic.LoadInt32(&dialCalls) >= 2 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for dial calls, got %d", atomic.LoadInt32(&dialCalls))
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Give any extra (incorrect) calls a chance to fire
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
if got := atomic.LoadInt32(&dialCalls); got != 2 {
|
||||
t.Errorf("dialCalls = %d, want 2 (one per unique instance)", got)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(seen) != 2 {
|
||||
t.Fatalf("seen = %v, want 2 entries", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(counter) >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %d calls, got %d", want, atomic.LoadInt32(counter))
|
||||
}
|
||||
|
||||
func TestClientDialerBackoffOnFailure(t *testing.T) {
|
||||
in := make(chan *discovery.ClientInfo, 10)
|
||||
|
||||
var dialCalls int32
|
||||
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
|
||||
atomic.AddInt32(&dialCalls, 1)
|
||||
return errors.New("boom")
|
||||
}
|
||||
|
||||
d := newClientDialer(in, dial)
|
||||
// Override backoff clock to keep the test fast
|
||||
d.baseBackoff = 20 * time.Millisecond
|
||||
d.maxBackoff = 80 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
info := &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
|
||||
|
||||
// First event — should dial immediately
|
||||
in <- info
|
||||
waitForCalls(t, &dialCalls, 1, 200*time.Millisecond)
|
||||
|
||||
// Immediately re-emit — should be rejected by backoff
|
||||
in <- info
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&dialCalls); got != 1 {
|
||||
t.Errorf("dialCalls after immediate retry = %d, want 1 (blocked by backoff)", got)
|
||||
}
|
||||
|
||||
// Wait past base backoff, re-emit — should dial again
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
in <- info
|
||||
waitForCalls(t, &dialCalls, 2, 200*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
139
third_party/sendspin-go/pkg/sendspin/client_discovery_integration_test.go
vendored
Normal file
139
third_party/sendspin-go/pkg/sendspin/client_discovery_integration_test.go
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
// ABOUTME: Integration test for server-initiated client discovery
|
||||
// ABOUTME: Fakes an mDNS advertisement and verifies the server dials + handshakes
|
||||
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
func TestServerDiscoversAndDialsClient(t *testing.T) {
|
||||
// mDNS multicast is unreliable in some environments (CI sandboxes,
|
||||
// Windows boxes with strict firewalls or Hyper-V virtual switches).
|
||||
// Opt in with SENDSPIN_MULTICAST_TESTS=1 when running locally.
|
||||
if os.Getenv("SENDSPIN_MULTICAST_TESTS") == "" {
|
||||
t.Skip("requires multicast; set SENDSPIN_MULTICAST_TESTS=1 to enable")
|
||||
}
|
||||
|
||||
// 1. Stand up a fake "player" HTTP server that upgrades to WS and
|
||||
// sends client/hello.
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
playerReady := make(chan string, 1) // receives serverID via server/hello
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(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()
|
||||
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "test-player-1",
|
||||
Name: "Test Player",
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(hello)
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
t.Errorf("write hello: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, resp, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg protocol.Message
|
||||
if err := json.Unmarshal(resp, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type == "server/hello" {
|
||||
playerReady <- "ok"
|
||||
}
|
||||
|
||||
// Block until the server closes us.
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, portStr, err := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split host: %v", err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
// 2. Advertise an mDNS _sendspin._tcp record pointing at httptest.
|
||||
ips := []net.IP{net.ParseIP("127.0.0.1")}
|
||||
svc, err := mdns.NewMDNSService(
|
||||
"test-player-instance",
|
||||
"_sendspin._tcp",
|
||||
"",
|
||||
"",
|
||||
port,
|
||||
ips,
|
||||
[]string{"path=/", "name=Test Player"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mdns service: %v", err)
|
||||
}
|
||||
mdnsServer, err := mdns.NewServer(&mdns.Config{Zone: svc})
|
||||
if err != nil {
|
||||
t.Fatalf("mdns server: %v", err)
|
||||
}
|
||||
defer mdnsServer.Shutdown()
|
||||
|
||||
// 3. Start a Sendspin server with DiscoverClients=true.
|
||||
source := NewTestTone(48000, 2)
|
||||
srv, err := NewServer(ServerConfig{
|
||||
Port: findFreePort(t),
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
EnableMDNS: false,
|
||||
DiscoverClients: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
serverDone := make(chan error, 1)
|
||||
go func() { serverDone <- srv.Start() }()
|
||||
defer srv.Stop()
|
||||
|
||||
// 4. Wait for the player to confirm it received server/hello.
|
||||
select {
|
||||
case <-playerReady:
|
||||
// success
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timed out waiting for dialed handshake")
|
||||
}
|
||||
}
|
||||
|
||||
// findFreePort returns a TCP port that is free at call time.
|
||||
func findFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
130
third_party/sendspin-go/pkg/sendspin/client_id.go
vendored
Normal file
130
third_party/sendspin-go/pkg/sendspin/client_id.go
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
// ABOUTME: Stable client_id resolution (override -> config value -> MAC -> generated UUID)
|
||||
// ABOUTME: Persistence of new values is delegated to a caller-supplied callback
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type clientIDSource string
|
||||
|
||||
const (
|
||||
sourceOverride clientIDSource = "override"
|
||||
sourceConfig clientIDSource = "config"
|
||||
sourceMAC clientIDSource = "mac"
|
||||
sourceGenerated clientIDSource = "generated"
|
||||
)
|
||||
|
||||
// PersistFn is called by ResolveClientID whenever a new client_id needs to be
|
||||
// written back to the config file. Implementations typically call
|
||||
// WriteStringKey(path, "client_id", id). Returning an error is logged but not
|
||||
// fatal — the player will still boot with the resolved id; the id just won't
|
||||
// survive a restart.
|
||||
type PersistFn func(id string) error
|
||||
|
||||
// ResolveClientID returns a stable client_id, following this precedence:
|
||||
// 1. override — used as-is and persisted via persist (so a one-time seed sticks)
|
||||
// 2. fromConfig — used as-is, no persistence (value came from the file we'd write to)
|
||||
// 3. primary network interface MAC (xx:xx:xx:xx:xx:xx) — not persisted; MAC is inherently stable
|
||||
// 4. freshly generated UUID — persisted so the next launch takes path 2
|
||||
//
|
||||
// Persisted value beating MAC is deliberate: once Music Assistant has
|
||||
// registered a player under one id, switching to another creates a duplicate
|
||||
// entry. Deleting client_id from the config file is the explicit "re-derive".
|
||||
//
|
||||
// persist may be nil if the caller cannot write back (e.g. no config path
|
||||
// resolvable). In that case generated/override ids still work for the session
|
||||
// but won't survive a restart.
|
||||
func ResolveClientID(override, fromConfig string, persist PersistFn) (string, error) {
|
||||
if override != "" {
|
||||
if override != fromConfig {
|
||||
tryPersist(persist, override, "override")
|
||||
}
|
||||
logClientID(override, sourceOverride, persist != nil && override != fromConfig)
|
||||
return override, nil
|
||||
}
|
||||
|
||||
if fromConfig != "" {
|
||||
logClientID(fromConfig, sourceConfig, false)
|
||||
return fromConfig, nil
|
||||
}
|
||||
|
||||
if mac, ok := pickStableMAC(allInterfaces()); ok {
|
||||
logClientID(mac, sourceMAC, false)
|
||||
return mac, nil
|
||||
}
|
||||
|
||||
generated := uuid.New().String()
|
||||
tryPersist(persist, generated, "generated id")
|
||||
logClientID(generated, sourceGenerated, persist != nil)
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
func tryPersist(persist PersistFn, id, what string) {
|
||||
if persist == nil {
|
||||
return
|
||||
}
|
||||
if err := persist(id); err != nil {
|
||||
log.Printf("client_id: could not persist %s: %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
func logClientID(id string, source clientIDSource, persisted bool) {
|
||||
if persisted {
|
||||
log.Printf("client_id: %s (source: %s, persisted)", id, source)
|
||||
return
|
||||
}
|
||||
log.Printf("client_id: %s (source: %s)", id, source)
|
||||
}
|
||||
|
||||
// allInterfaces is a package-level var so tests can stub it.
|
||||
var allInterfaces = func() []net.Interface {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return ifaces
|
||||
}
|
||||
|
||||
// pickStableMAC returns the MAC of the alphabetically-first eligible interface.
|
||||
// Eligible means: up, not loopback, 6-byte hardware address, not zero, not broadcast.
|
||||
// Alphabetical ordering gives deterministic selection across reboots and OSes
|
||||
// (eth0 < wlan0, Ethernet < Wi-Fi).
|
||||
func pickStableMAC(ifaces []net.Interface) (string, bool) {
|
||||
eligible := make([]net.Interface, 0, len(ifaces))
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if len(iface.HardwareAddr) != 6 {
|
||||
continue
|
||||
}
|
||||
if isZeroMAC(iface.HardwareAddr) || isBroadcastMAC(iface.HardwareAddr) {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, iface)
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
return "", false
|
||||
}
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].Name < eligible[j].Name
|
||||
})
|
||||
return eligible[0].HardwareAddr.String(), true
|
||||
}
|
||||
|
||||
func isZeroMAC(mac net.HardwareAddr) bool {
|
||||
return bytes.Equal(mac, []byte{0, 0, 0, 0, 0, 0})
|
||||
}
|
||||
|
||||
func isBroadcastMAC(mac net.HardwareAddr) bool {
|
||||
return bytes.Equal(mac, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff})
|
||||
}
|
||||
244
third_party/sendspin-go/pkg/sendspin/client_id_test.go
vendored
Normal file
244
third_party/sendspin-go/pkg/sendspin/client_id_test.go
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
// ABOUTME: Tests for ResolveClientID precedence and the MAC picker filter
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mac(b ...byte) net.HardwareAddr { return net.HardwareAddr(b) }
|
||||
|
||||
func TestPickStableMAC(t *testing.T) {
|
||||
up := net.FlagUp
|
||||
lo := net.FlagUp | net.FlagLoopback
|
||||
down := net.Flags(0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ifaces []net.Interface
|
||||
want string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "single eligible",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "eth0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
},
|
||||
want: "aa:bb:cc:dd:ee:ff",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "skips loopback",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "lo", Flags: lo, HardwareAddr: mac(1, 2, 3, 4, 5, 6)},
|
||||
{Name: "eth0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
},
|
||||
want: "aa:bb:cc:dd:ee:ff",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "skips interfaces that are not up",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "eth0", Flags: down, HardwareAddr: mac(1, 1, 1, 1, 1, 1)},
|
||||
{Name: "wlan0", Flags: up, HardwareAddr: mac(2, 2, 2, 2, 2, 2)},
|
||||
},
|
||||
want: "02:02:02:02:02:02",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "skips zero MAC",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "eth0", Flags: up, HardwareAddr: mac(0, 0, 0, 0, 0, 0)},
|
||||
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
},
|
||||
want: "aa:bb:cc:dd:ee:ff",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "skips broadcast MAC",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "eth0", Flags: up, HardwareAddr: mac(0xff, 0xff, 0xff, 0xff, 0xff, 0xff)},
|
||||
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
},
|
||||
want: "aa:bb:cc:dd:ee:ff",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "skips short hardware address",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "eth0", Flags: up, HardwareAddr: mac(1, 2, 3, 4)},
|
||||
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
},
|
||||
want: "aa:bb:cc:dd:ee:ff",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "alphabetical sort: eth0 wins over wlan0",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0, 0, 0, 0, 0x01)},
|
||||
{Name: "eth0", Flags: up, HardwareAddr: mac(0xbb, 0, 0, 0, 0, 0x02)},
|
||||
},
|
||||
want: "bb:00:00:00:00:02",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "nothing eligible",
|
||||
ifaces: []net.Interface{
|
||||
{Name: "lo", Flags: lo, HardwareAddr: mac(0, 0, 0, 0, 0, 0)},
|
||||
{Name: "eth0", Flags: down, HardwareAddr: mac(1, 1, 1, 1, 1, 1)},
|
||||
},
|
||||
want: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
ifaces: nil,
|
||||
want: "",
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := pickStableMAC(tt.ifaces)
|
||||
if ok != tt.wantOk {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOk)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("mac = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type persistRecorder struct {
|
||||
calls []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *persistRecorder) persist(id string) error {
|
||||
p.calls = append(p.calls, id)
|
||||
return p.err
|
||||
}
|
||||
|
||||
func withStubInterfaces(t *testing.T, stub func() []net.Interface) {
|
||||
t.Helper()
|
||||
prev := allInterfaces
|
||||
allInterfaces = stub
|
||||
t.Cleanup(func() { allInterfaces = prev })
|
||||
}
|
||||
|
||||
func TestResolveClientID_OverrideWinsAndPersistsWhenDifferent(t *testing.T) {
|
||||
rec := &persistRecorder{}
|
||||
|
||||
id, err := ResolveClientID("my-override", "", rec.persist)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if id != "my-override" {
|
||||
t.Errorf("id = %q, want my-override", id)
|
||||
}
|
||||
if len(rec.calls) != 1 || rec.calls[0] != "my-override" {
|
||||
t.Errorf("persist calls = %v, want [my-override]", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_OverrideMatchingConfigDoesNotPersist(t *testing.T) {
|
||||
rec := &persistRecorder{}
|
||||
|
||||
id, err := ResolveClientID("same-id", "same-id", rec.persist)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if id != "same-id" {
|
||||
t.Errorf("id = %q, want same-id", id)
|
||||
}
|
||||
if len(rec.calls) != 0 {
|
||||
t.Errorf("persist calls = %v, want no-op when override matches config", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_ConfigBeatsMAC(t *testing.T) {
|
||||
rec := &persistRecorder{}
|
||||
withStubInterfaces(t, func() []net.Interface {
|
||||
return []net.Interface{
|
||||
{Name: "eth0", Flags: net.FlagUp, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
}
|
||||
})
|
||||
|
||||
id, err := ResolveClientID("", "from-config", rec.persist)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if id != "from-config" {
|
||||
t.Errorf("id = %q, want from-config (config must beat MAC)", id)
|
||||
}
|
||||
if len(rec.calls) != 0 {
|
||||
t.Errorf("persist calls = %v, want none (config path uses as-is)", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_MACNotPersisted(t *testing.T) {
|
||||
rec := &persistRecorder{}
|
||||
withStubInterfaces(t, func() []net.Interface {
|
||||
return []net.Interface{
|
||||
{Name: "eth0", Flags: net.FlagUp, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
|
||||
}
|
||||
})
|
||||
|
||||
id, err := ResolveClientID("", "", rec.persist)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if id != "aa:bb:cc:dd:ee:ff" {
|
||||
t.Errorf("id = %q, want MAC", id)
|
||||
}
|
||||
if len(rec.calls) != 0 {
|
||||
t.Errorf("persist calls = %v, want none (MAC is inherently stable)", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_GeneratedUUIDPersisted(t *testing.T) {
|
||||
rec := &persistRecorder{}
|
||||
withStubInterfaces(t, func() []net.Interface { return nil })
|
||||
|
||||
id, err := ResolveClientID("", "", rec.persist)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("resolve returned empty id")
|
||||
}
|
||||
if len(rec.calls) != 1 || rec.calls[0] != id {
|
||||
t.Errorf("persist calls = %v, want [%q]", rec.calls, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_NilPersistStillReturnsID(t *testing.T) {
|
||||
withStubInterfaces(t, func() []net.Interface { return nil })
|
||||
|
||||
id, err := ResolveClientID("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Error("resolve with nil persist must still return a non-empty id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_PersistErrorIsNotFatal(t *testing.T) {
|
||||
rec := &persistRecorder{err: errors.New("disk full")}
|
||||
withStubInterfaces(t, func() []net.Interface { return nil })
|
||||
|
||||
id, err := ResolveClientID("", "", rec.persist)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve should not fail when persist fails: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Error("resolve returned empty id")
|
||||
}
|
||||
if len(rec.calls) != 1 {
|
||||
t.Errorf("persist should have been attempted once, got %d calls", len(rec.calls))
|
||||
}
|
||||
}
|
||||
395
third_party/sendspin-go/pkg/sendspin/config.go
vendored
Normal file
395
third_party/sendspin-go/pkg/sendspin/config.go
vendored
Normal file
@@ -0,0 +1,395 @@
|
||||
// ABOUTME: YAML config-file support for the player and server (paths, env overlay, write-back)
|
||||
// ABOUTME: Flat keys 1:1 with CLI flags; precedence CLI > env > file > built-in default
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// PlayerEnvPrefix is the namespace for environment overrides of player config
|
||||
// values. Env key = PlayerEnvPrefix + upper-snake(flag name). Example:
|
||||
// "-buffer-ms" -> SENDSPIN_PLAYER_BUFFER_MS.
|
||||
const PlayerEnvPrefix = "SENDSPIN_PLAYER_"
|
||||
|
||||
// PlayerConfigFile mirrors the player's CLI flags. Fields with "zero" values
|
||||
// that could reasonably be meaningful (bool, int) are pointers so absence in
|
||||
// the YAML can be distinguished from an explicit false/0.
|
||||
type PlayerConfigFile struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Server string `yaml:"server,omitempty"`
|
||||
Port *int `yaml:"port,omitempty"`
|
||||
BufferMs *int `yaml:"buffer_ms,omitempty"`
|
||||
StaticDelayMs *int `yaml:"static_delay_ms,omitempty"`
|
||||
LogFile string `yaml:"log_file,omitempty"`
|
||||
NoTUI *bool `yaml:"no_tui,omitempty"`
|
||||
StreamLogs *bool `yaml:"stream_logs,omitempty"`
|
||||
ProductName string `yaml:"product_name,omitempty"`
|
||||
Manufacturer string `yaml:"manufacturer,omitempty"`
|
||||
NoReconnect *bool `yaml:"no_reconnect,omitempty"`
|
||||
Daemon *bool `yaml:"daemon,omitempty"`
|
||||
PreferredCodec string `yaml:"preferred_codec,omitempty"`
|
||||
BufferCapacity *int `yaml:"buffer_capacity,omitempty"`
|
||||
ClientID string `yaml:"client_id,omitempty"`
|
||||
AudioDevice string `yaml:"audio_device,omitempty"`
|
||||
MaxSampleRate *int `yaml:"max_sample_rate,omitempty"`
|
||||
MaxBitDepth *int `yaml:"max_bit_depth,omitempty"`
|
||||
}
|
||||
|
||||
// loadYAMLConfig walks searchPaths, and for the first one that exists opens
|
||||
// the file and unmarshals into out. It returns the path that was loaded, or
|
||||
// empty if no candidate existed. A missing file is not an error; I/O or
|
||||
// parse errors are returned as-is with the offending path attached.
|
||||
func loadYAMLConfig(searchPaths []string, out any) (string, error) {
|
||||
for _, candidate := range searchPaths {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return candidate, fmt.Errorf("read %s: %w", candidate, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, out); err != nil {
|
||||
return candidate, fmt.Errorf("parse %s: %w", candidate, err)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// LoadPlayerConfig searches for a player.yaml and returns its parsed contents
|
||||
// along with the path that was loaded (empty if none was found).
|
||||
//
|
||||
// Search order (first existing wins):
|
||||
// 1. explicitPath if non-empty
|
||||
// 2. $SENDSPIN_PLAYER_CONFIG if set
|
||||
// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/player.yaml
|
||||
// 4. /etc/sendspin/player.yaml
|
||||
//
|
||||
// A missing file is not an error; the caller gets (nil, "", nil).
|
||||
func LoadPlayerConfig(explicitPath string) (*PlayerConfigFile, string, error) {
|
||||
var cfg PlayerConfigFile
|
||||
used, err := loadYAMLConfig(playerConfigSearchPaths(explicitPath), &cfg)
|
||||
if err != nil {
|
||||
return nil, used, err
|
||||
}
|
||||
if used == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
return &cfg, used, nil
|
||||
}
|
||||
|
||||
// userConfigPath returns <UserConfigDir>/sendspin/<relative>. Matches the
|
||||
// canonical path layout for both player.yaml and server.yaml.
|
||||
func userConfigPath(relative string) (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(dir, "sendspin", relative), nil
|
||||
}
|
||||
|
||||
// DefaultPlayerConfigPath returns the canonical user-level player.yaml path
|
||||
// for this OS. Used when we need to auto-create the config for write-back.
|
||||
func DefaultPlayerConfigPath() (string, error) {
|
||||
return userConfigPath("player.yaml")
|
||||
}
|
||||
|
||||
func playerConfigSearchPaths(explicit string) []string {
|
||||
paths := make([]string, 0, 4)
|
||||
if explicit != "" {
|
||||
paths = append(paths, explicit)
|
||||
}
|
||||
if env := os.Getenv("SENDSPIN_PLAYER_CONFIG"); env != "" {
|
||||
paths = append(paths, env)
|
||||
}
|
||||
if p, err := userConfigPath("player.yaml"); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
paths = append(paths, "/etc/sendspin/player.yaml")
|
||||
return paths
|
||||
}
|
||||
|
||||
// ApplyEnvAndFile overlays <envPrefix> env vars and YAML config-file values
|
||||
// into the given FlagSet, but only for flags the user did NOT set on the CLI.
|
||||
// Precedence: CLI (untouched here) > env > file > flag default.
|
||||
//
|
||||
// envPrefix is the namespace for env-var lookups (e.g. "SENDSPIN_PLAYER_").
|
||||
// fileValues is the flat flag-key → value map the caller derives from its
|
||||
// typed config struct (see PlayerConfigFile.AsStringMap and
|
||||
// ServerConfigFile.AsStringMap). A nil map is treated as empty.
|
||||
//
|
||||
// setByUser is typically built with flag.Visit before calling this.
|
||||
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error {
|
||||
var firstErr error
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if firstErr != nil || setByUser[f.Name] {
|
||||
return
|
||||
}
|
||||
envKey := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
|
||||
if val, ok := os.LookupEnv(envKey); ok {
|
||||
if err := fs.Set(f.Name, val); err != nil {
|
||||
firstErr = fmt.Errorf("env %s -> -%s: %w", envKey, f.Name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
configKey := strings.ReplaceAll(f.Name, "-", "_")
|
||||
if val, ok := fileValues[configKey]; ok {
|
||||
if err := fs.Set(f.Name, val); err != nil {
|
||||
firstErr = fmt.Errorf("config %s -> -%s: %w", configKey, f.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// AsStringMap returns only the keys the user actually set in the YAML, as
|
||||
// strings suitable for flag.Set. Absent keys are omitted so the overlay
|
||||
// correctly falls through to the flag default.
|
||||
func (c *PlayerConfigFile) AsStringMap() map[string]string {
|
||||
m := make(map[string]string)
|
||||
if c == nil {
|
||||
return m
|
||||
}
|
||||
if c.Name != "" {
|
||||
m["name"] = c.Name
|
||||
}
|
||||
if c.Server != "" {
|
||||
m["server"] = c.Server
|
||||
}
|
||||
if c.Port != nil {
|
||||
m["port"] = strconv.Itoa(*c.Port)
|
||||
}
|
||||
if c.BufferMs != nil {
|
||||
m["buffer_ms"] = strconv.Itoa(*c.BufferMs)
|
||||
}
|
||||
if c.StaticDelayMs != nil {
|
||||
m["static_delay_ms"] = strconv.Itoa(*c.StaticDelayMs)
|
||||
}
|
||||
if c.LogFile != "" {
|
||||
m["log_file"] = c.LogFile
|
||||
}
|
||||
if c.NoTUI != nil {
|
||||
m["no_tui"] = strconv.FormatBool(*c.NoTUI)
|
||||
}
|
||||
if c.StreamLogs != nil {
|
||||
m["stream_logs"] = strconv.FormatBool(*c.StreamLogs)
|
||||
}
|
||||
if c.ProductName != "" {
|
||||
m["product_name"] = c.ProductName
|
||||
}
|
||||
if c.Manufacturer != "" {
|
||||
m["manufacturer"] = c.Manufacturer
|
||||
}
|
||||
if c.NoReconnect != nil {
|
||||
m["no_reconnect"] = strconv.FormatBool(*c.NoReconnect)
|
||||
}
|
||||
if c.Daemon != nil {
|
||||
m["daemon"] = strconv.FormatBool(*c.Daemon)
|
||||
}
|
||||
if c.PreferredCodec != "" {
|
||||
m["preferred_codec"] = c.PreferredCodec
|
||||
}
|
||||
if c.BufferCapacity != nil {
|
||||
m["buffer_capacity"] = strconv.Itoa(*c.BufferCapacity)
|
||||
}
|
||||
if c.ClientID != "" {
|
||||
m["client_id"] = c.ClientID
|
||||
}
|
||||
if c.AudioDevice != "" {
|
||||
m["audio_device"] = c.AudioDevice
|
||||
}
|
||||
if c.MaxSampleRate != nil {
|
||||
m["max_sample_rate"] = strconv.Itoa(*c.MaxSampleRate)
|
||||
}
|
||||
if c.MaxBitDepth != nil {
|
||||
m["max_bit_depth"] = strconv.Itoa(*c.MaxBitDepth)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// WriteStringKey reads the YAML at path (if any), sets the given top-level
|
||||
// string key to value, and atomically writes the result back. Comments and
|
||||
// existing keys are preserved via yaml.Node round-tripping. Used to persist
|
||||
// the auto-generated client_id and the --client-id override.
|
||||
func WriteStringKey(path, key, value string) error {
|
||||
var root yaml.Node
|
||||
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||
return fmt.Errorf("parse existing config %s: %w", path, err)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
mapping := topLevelMapping(&root)
|
||||
|
||||
setOrAppendStringKey(mapping, key, value)
|
||||
|
||||
buf, err := yaml.Marshal(&root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return atomicWriteFile(path, buf)
|
||||
}
|
||||
|
||||
// topLevelMapping returns the mapping node that backs the top of a YAML
|
||||
// document. If root is empty or non-document, it's initialized in place.
|
||||
func topLevelMapping(root *yaml.Node) *yaml.Node {
|
||||
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 && root.Content[0].Kind == yaml.MappingNode {
|
||||
return root.Content[0]
|
||||
}
|
||||
mapping := &yaml.Node{Kind: yaml.MappingNode}
|
||||
root.Kind = yaml.DocumentNode
|
||||
root.Content = []*yaml.Node{mapping}
|
||||
return mapping
|
||||
}
|
||||
|
||||
// setOrAppendStringKey updates the value for key in a MappingNode, or appends
|
||||
// a new key/value pair if the key is not present. Leaves all other entries
|
||||
// (and their comments) untouched.
|
||||
func setOrAppendStringKey(mapping *yaml.Node, key, value string) {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if mapping.Content[i].Value == key {
|
||||
mapping.Content[i+1].Kind = yaml.ScalarNode
|
||||
mapping.Content[i+1].Tag = "!!str"
|
||||
mapping.Content[i+1].Value = value
|
||||
mapping.Content[i+1].Style = 0
|
||||
return
|
||||
}
|
||||
}
|
||||
mapping.Content = append(mapping.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value},
|
||||
)
|
||||
}
|
||||
|
||||
// ServerEnvPrefix is the namespace for environment overrides of server
|
||||
// config values. Env key = ServerEnvPrefix + upper-snake(flag name).
|
||||
// Example: "-no-mdns" -> SENDSPIN_SERVER_NO_MDNS.
|
||||
const ServerEnvPrefix = "SENDSPIN_SERVER_"
|
||||
|
||||
// ServerConfigFile mirrors the server's CLI flags. Fields with "zero" values
|
||||
// that could reasonably be meaningful (bool) are pointers so absence in
|
||||
// the YAML can be distinguished from an explicit false.
|
||||
type ServerConfigFile struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Port *int `yaml:"port,omitempty"`
|
||||
LogFile string `yaml:"log_file,omitempty"`
|
||||
Debug *bool `yaml:"debug,omitempty"`
|
||||
NoMDNS *bool `yaml:"no_mdns,omitempty"`
|
||||
NoTUI *bool `yaml:"no_tui,omitempty"`
|
||||
Audio string `yaml:"audio,omitempty"`
|
||||
DiscoverClients *bool `yaml:"discover_clients,omitempty"`
|
||||
Daemon *bool `yaml:"daemon,omitempty"`
|
||||
}
|
||||
|
||||
// LoadServerConfig searches for a server.yaml and returns its parsed contents
|
||||
// along with the path that was loaded (empty if none was found).
|
||||
//
|
||||
// Search order (first existing wins):
|
||||
// 1. explicitPath if non-empty
|
||||
// 2. $SENDSPIN_SERVER_CONFIG if set
|
||||
// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/server.yaml
|
||||
// 4. /etc/sendspin/server.yaml
|
||||
//
|
||||
// A missing file is not an error; the caller gets (nil, "", nil).
|
||||
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error) {
|
||||
var cfg ServerConfigFile
|
||||
used, err := loadYAMLConfig(serverConfigSearchPaths(explicitPath), &cfg)
|
||||
if err != nil {
|
||||
return nil, used, err
|
||||
}
|
||||
if used == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
return &cfg, used, nil
|
||||
}
|
||||
|
||||
// DefaultServerConfigPath returns the canonical user-level server.yaml path
|
||||
// for this OS.
|
||||
func DefaultServerConfigPath() (string, error) {
|
||||
return userConfigPath("server.yaml")
|
||||
}
|
||||
|
||||
func serverConfigSearchPaths(explicit string) []string {
|
||||
paths := make([]string, 0, 4)
|
||||
if explicit != "" {
|
||||
paths = append(paths, explicit)
|
||||
}
|
||||
if env := os.Getenv("SENDSPIN_SERVER_CONFIG"); env != "" {
|
||||
paths = append(paths, env)
|
||||
}
|
||||
if p, err := userConfigPath("server.yaml"); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
paths = append(paths, "/etc/sendspin/server.yaml")
|
||||
return paths
|
||||
}
|
||||
|
||||
// AsStringMap returns only the keys the user actually set in the YAML, as
|
||||
// strings suitable for flag.Set. Absent keys are omitted so the overlay
|
||||
// correctly falls through to the flag default.
|
||||
func (c *ServerConfigFile) AsStringMap() map[string]string {
|
||||
m := make(map[string]string)
|
||||
if c == nil {
|
||||
return m
|
||||
}
|
||||
if c.Name != "" {
|
||||
m["name"] = c.Name
|
||||
}
|
||||
if c.Port != nil {
|
||||
m["port"] = strconv.Itoa(*c.Port)
|
||||
}
|
||||
if c.LogFile != "" {
|
||||
m["log_file"] = c.LogFile
|
||||
}
|
||||
if c.Debug != nil {
|
||||
m["debug"] = strconv.FormatBool(*c.Debug)
|
||||
}
|
||||
if c.NoMDNS != nil {
|
||||
m["no_mdns"] = strconv.FormatBool(*c.NoMDNS)
|
||||
}
|
||||
if c.NoTUI != nil {
|
||||
m["no_tui"] = strconv.FormatBool(*c.NoTUI)
|
||||
}
|
||||
if c.Audio != "" {
|
||||
m["audio"] = c.Audio
|
||||
}
|
||||
if c.DiscoverClients != nil {
|
||||
m["discover_clients"] = strconv.FormatBool(*c.DiscoverClients)
|
||||
}
|
||||
if c.Daemon != nil {
|
||||
m["daemon"] = strconv.FormatBool(*c.Daemon)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via tempfile + rename. Matches the
|
||||
// atomicity guarantees the old writePersistedClientID used to provide.
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
119
third_party/sendspin-go/pkg/sendspin/config_server_test.go
vendored
Normal file
119
third_party/sendspin-go/pkg/sendspin/config_server_test.go
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
// ABOUTME: Tests for YAML config loading and env prefix routing for sendspin-server
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadServerConfig_ExplicitPathWithAllKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "server.yaml")
|
||||
body := `# Test server
|
||||
name: "Living Room Server"
|
||||
port: 9000
|
||||
log_file: "custom-server.log"
|
||||
debug: true
|
||||
no_mdns: true
|
||||
no_tui: true
|
||||
audio: "/srv/music/radio.m3u8"
|
||||
discover_clients: true
|
||||
daemon: true
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
cfg, used, err := LoadServerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if used != path {
|
||||
t.Errorf("used = %q, want %q", used, path)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("cfg is nil")
|
||||
}
|
||||
if cfg.Name != "Living Room Server" {
|
||||
t.Errorf("name = %q", cfg.Name)
|
||||
}
|
||||
if cfg.Port == nil || *cfg.Port != 9000 {
|
||||
t.Errorf("port = %v, want 9000", cfg.Port)
|
||||
}
|
||||
if cfg.LogFile != "custom-server.log" {
|
||||
t.Errorf("log_file = %q", cfg.LogFile)
|
||||
}
|
||||
if cfg.Debug == nil || !*cfg.Debug {
|
||||
t.Errorf("debug = %v, want true", cfg.Debug)
|
||||
}
|
||||
if cfg.NoMDNS == nil || !*cfg.NoMDNS {
|
||||
t.Errorf("no_mdns = %v, want true", cfg.NoMDNS)
|
||||
}
|
||||
if cfg.NoTUI == nil || !*cfg.NoTUI {
|
||||
t.Errorf("no_tui = %v, want true", cfg.NoTUI)
|
||||
}
|
||||
if cfg.Audio != "/srv/music/radio.m3u8" {
|
||||
t.Errorf("audio = %q", cfg.Audio)
|
||||
}
|
||||
if cfg.DiscoverClients == nil || !*cfg.DiscoverClients {
|
||||
t.Errorf("discover_clients = %v, want true", cfg.DiscoverClients)
|
||||
}
|
||||
if cfg.Daemon == nil || !*cfg.Daemon {
|
||||
t.Errorf("daemon = %v, want true", cfg.Daemon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadServerConfig_MissingFileIsNotAnError(t *testing.T) {
|
||||
cfg, used, err := LoadServerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg != nil || used != "" {
|
||||
t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadServerConfig_EnvPathHonored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "from-env-server.yaml")
|
||||
if err := os.WriteFile(envPath, []byte("name: EnvServer\n"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_SERVER_CONFIG", envPath)
|
||||
|
||||
cfg, used, err := LoadServerConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if used != envPath {
|
||||
t.Errorf("used = %q, want %q", used, envPath)
|
||||
}
|
||||
if cfg.Name != "EnvServer" {
|
||||
t.Errorf("name = %q", cfg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEnvAndFile_ServerEnvPrefix confirms the generalized envPrefix
|
||||
// parameter routes SENDSPIN_SERVER_* correctly. Precedence rules themselves
|
||||
// are already covered by the player tests; this is pure plumbing.
|
||||
func TestApplyEnvAndFile_ServerEnvPrefix(t *testing.T) {
|
||||
fs := flag.NewFlagSet("server", flag.ContinueOnError)
|
||||
port := fs.Int("port", 8927, "port")
|
||||
audio := fs.String("audio", "", "audio source")
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_SERVER_PORT", "9999")
|
||||
t.Setenv("SENDSPIN_SERVER_AUDIO", "/srv/env.flac")
|
||||
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, ServerEnvPrefix, nil); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *port != 9999 {
|
||||
t.Errorf("port = %d, want 9999", *port)
|
||||
}
|
||||
if *audio != "/srv/env.flac" {
|
||||
t.Errorf("audio = %q", *audio)
|
||||
}
|
||||
}
|
||||
397
third_party/sendspin-go/pkg/sendspin/config_test.go
vendored
Normal file
397
third_party/sendspin-go/pkg/sendspin/config_test.go
vendored
Normal file
@@ -0,0 +1,397 @@
|
||||
// ABOUTME: Tests for YAML config loading, env/file overlay precedence, and write-back round-trip
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPlayerConfig_ExplicitPathWithAllKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
body := `# My config
|
||||
name: "Kitchen"
|
||||
server: "192.168.1.100:8927"
|
||||
port: 8999
|
||||
buffer_ms: 250
|
||||
static_delay_ms: 10
|
||||
log_file: "custom.log"
|
||||
no_tui: true
|
||||
stream_logs: false
|
||||
product_name: "Test Speaker"
|
||||
manufacturer: "Acme"
|
||||
no_reconnect: true
|
||||
daemon: false
|
||||
preferred_codec: "flac"
|
||||
buffer_capacity: 2097152
|
||||
client_id: "aa:bb:cc:dd:ee:ff"
|
||||
audio_device: "USB Audio Device"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
cfg, used, err := LoadPlayerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if used != path {
|
||||
t.Errorf("used path = %q, want %q", used, path)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("cfg is nil")
|
||||
}
|
||||
if cfg.Name != "Kitchen" || cfg.Server != "192.168.1.100:8927" {
|
||||
t.Errorf("string keys: %+v", cfg)
|
||||
}
|
||||
if cfg.Port == nil || *cfg.Port != 8999 {
|
||||
t.Errorf("port = %v, want 8999", cfg.Port)
|
||||
}
|
||||
if cfg.NoTUI == nil || !*cfg.NoTUI {
|
||||
t.Errorf("no_tui = %v, want true", cfg.NoTUI)
|
||||
}
|
||||
if cfg.StreamLogs == nil || *cfg.StreamLogs {
|
||||
t.Errorf("stream_logs = %v, want false", cfg.StreamLogs)
|
||||
}
|
||||
if cfg.ClientID != "aa:bb:cc:dd:ee:ff" {
|
||||
t.Errorf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.AudioDevice != "USB Audio Device" {
|
||||
t.Errorf("audio_device = %q", cfg.AudioDevice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlayerConfig_MissingFileIsNotAnError(t *testing.T) {
|
||||
cfg, used, err := LoadPlayerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg != nil || used != "" {
|
||||
t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlayerConfig_InvalidYAMLIsAnError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
if err := os.WriteFile(path, []byte("not: valid: : yaml"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
_, _, err := LoadPlayerConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlayerConfig_EnvPathHonored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "from-env.yaml")
|
||||
if err := os.WriteFile(envPath, []byte("name: EnvName\n"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_CONFIG", envPath)
|
||||
|
||||
cfg, used, err := LoadPlayerConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if used != envPath {
|
||||
t.Errorf("used = %q, want %q", used, envPath)
|
||||
}
|
||||
if cfg.Name != "EnvName" {
|
||||
t.Errorf("name = %q", cfg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// applyPlayerFlags builds a flag.FlagSet mirroring the real player CLI so
|
||||
// ApplyEnvAndFile can be tested end-to-end.
|
||||
func newTestFlagSet() (*flag.FlagSet, map[string]*string, map[string]*int, map[string]*bool) {
|
||||
fs := flag.NewFlagSet("player", flag.ContinueOnError)
|
||||
strs := map[string]*string{
|
||||
"name": fs.String("name", "", "player name"),
|
||||
"server": fs.String("server", "", "server addr"),
|
||||
"log-file": fs.String("log-file", "default.log", "log file"),
|
||||
"product-name": fs.String("product-name", "", "product name"),
|
||||
"manufacturer": fs.String("manufacturer", "", "mfg"),
|
||||
"preferred-codec": fs.String("preferred-codec", "", "codec"),
|
||||
"client-id": fs.String("client-id", "", "client id"),
|
||||
}
|
||||
ints := map[string]*int{
|
||||
"port": fs.Int("port", 8927, "port"),
|
||||
"buffer-ms": fs.Int("buffer-ms", 150, "buffer ms"),
|
||||
"static-delay-ms": fs.Int("static-delay-ms", 0, "static delay"),
|
||||
"buffer-capacity": fs.Int("buffer-capacity", 1048576, "buffer cap"),
|
||||
"max-sample-rate": fs.Int("max-sample-rate", 0, "max sample rate"),
|
||||
"max-bit-depth": fs.Int("max-bit-depth", 0, "max bit depth"),
|
||||
}
|
||||
bools := map[string]*bool{
|
||||
"no-tui": fs.Bool("no-tui", false, "no tui"),
|
||||
"stream-logs": fs.Bool("stream-logs", false, "stream logs"),
|
||||
"no-reconnect": fs.Bool("no-reconnect", false, "no reconnect"),
|
||||
"daemon": fs.Bool("daemon", false, "daemon"),
|
||||
}
|
||||
return fs, strs, ints, bools
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_FileFillsUnsetFlags(t *testing.T) {
|
||||
fs, strs, ints, bools := newTestFlagSet()
|
||||
// Simulate user passing only "-name Foo"
|
||||
if err := fs.Parse([]string{"-name", "CLI-Name"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
setByUser := map[string]bool{"name": true}
|
||||
|
||||
port := 9000
|
||||
noTUI := true
|
||||
cfg := &PlayerConfigFile{
|
||||
Server: "file.example:1234",
|
||||
Port: &port,
|
||||
NoTUI: &noTUI,
|
||||
}
|
||||
|
||||
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *strs["name"] != "CLI-Name" {
|
||||
t.Errorf("CLI should win: name = %q", *strs["name"])
|
||||
}
|
||||
if *strs["server"] != "file.example:1234" {
|
||||
t.Errorf("file should fill: server = %q", *strs["server"])
|
||||
}
|
||||
if *ints["port"] != 9000 {
|
||||
t.Errorf("file should fill: port = %d", *ints["port"])
|
||||
}
|
||||
if !*bools["no-tui"] {
|
||||
t.Errorf("file should fill: no_tui = %v", *bools["no-tui"])
|
||||
}
|
||||
// Unset elsewhere keeps default
|
||||
if *ints["buffer-ms"] != 150 {
|
||||
t.Errorf("default should stick: buffer-ms = %d", *ints["buffer-ms"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_EnvBeatsFile(t *testing.T) {
|
||||
fs, strs, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_SERVER", "env.example:9999")
|
||||
t.Setenv("SENDSPIN_PLAYER_PORT", "5555")
|
||||
|
||||
port := 9000
|
||||
cfg := &PlayerConfigFile{
|
||||
Server: "file.example:1234",
|
||||
Port: &port,
|
||||
}
|
||||
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *strs["server"] != "env.example:9999" {
|
||||
t.Errorf("env should beat file: server = %q", *strs["server"])
|
||||
}
|
||||
if *ints["port"] != 5555 {
|
||||
t.Errorf("env should beat file: port = %d", *ints["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_NilConfigStillHonorsEnv(t *testing.T) {
|
||||
fs, strs, _, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_NAME", "env-only")
|
||||
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *strs["name"] != "env-only" {
|
||||
t.Errorf("name = %q, want env-only", *strs["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_InvalidEnvReturnsError(t *testing.T) {
|
||||
fs, _, _, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_PORT", "not-a-number")
|
||||
|
||||
err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on invalid env int")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Errorf("error should mention the offending flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEnvAndFile_MaxSampleRateAndBitDepth covers the output-capability
|
||||
// override keys end-to-end: file value flows through, env beats file, CLI
|
||||
// beats env. Same precedence as every other key, but worth a dedicated test:
|
||||
// these are the operator's escape hatch when the auto-probe is wrong, and a
|
||||
// silent bug here means users can't override.
|
||||
func TestApplyEnvAndFile_MaxSampleRateAndBitDepth(t *testing.T) {
|
||||
t.Run("file fills both", func(t *testing.T) {
|
||||
fs, _, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
rate, depth := 48000, 16
|
||||
cfg := &PlayerConfigFile{MaxSampleRate: &rate, MaxBitDepth: &depth}
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *ints["max-sample-rate"] != 48000 {
|
||||
t.Errorf("max-sample-rate = %d, want 48000", *ints["max-sample-rate"])
|
||||
}
|
||||
if *ints["max-bit-depth"] != 16 {
|
||||
t.Errorf("max-bit-depth = %d, want 16", *ints["max-bit-depth"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env beats file", func(t *testing.T) {
|
||||
fs, _, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000")
|
||||
rate := 48000
|
||||
cfg := &PlayerConfigFile{MaxSampleRate: &rate}
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *ints["max-sample-rate"] != 96000 {
|
||||
t.Errorf("env should beat file: max-sample-rate = %d", *ints["max-sample-rate"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CLI beats env and file", func(t *testing.T) {
|
||||
fs, _, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse([]string{"-max-sample-rate", "192000"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
setByUser := map[string]bool{"max-sample-rate": true}
|
||||
t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000")
|
||||
rate := 48000
|
||||
cfg := &PlayerConfigFile{MaxSampleRate: &rate}
|
||||
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *ints["max-sample-rate"] != 192000 {
|
||||
t.Errorf("CLI should win: max-sample-rate = %d", *ints["max-sample-rate"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps guards a subtle precedence
|
||||
// bug: if AsStringMap emitted "0" for an unset *int field, that "0" would
|
||||
// flow through ApplyEnvAndFile as an explicit value and clobber any flag
|
||||
// default, env override, or CLI flag the user actually set. The pointer-vs-
|
||||
// nil distinction in the YAML field is the only thing keeping the precedence
|
||||
// chain honest, and AsStringMap must respect it.
|
||||
func TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps(t *testing.T) {
|
||||
cfg := &PlayerConfigFile{} // all fields nil/empty
|
||||
m := cfg.AsStringMap()
|
||||
if _, ok := m["max_sample_rate"]; ok {
|
||||
t.Error("max_sample_rate should be absent when not set in YAML")
|
||||
}
|
||||
if _, ok := m["max_bit_depth"]; ok {
|
||||
t.Error("max_bit_depth should be absent when not set in YAML")
|
||||
}
|
||||
|
||||
// Set explicitly to zero — the user genuinely wants 0, which means
|
||||
// "no cap, auto-probe". Pointer is non-nil so the key DOES appear.
|
||||
zero := 0
|
||||
cfg = &PlayerConfigFile{MaxSampleRate: &zero, MaxBitDepth: &zero}
|
||||
m = cfg.AsStringMap()
|
||||
if got := m["max_sample_rate"]; got != "0" {
|
||||
t.Errorf("explicit zero should round-trip; got %q", got)
|
||||
}
|
||||
if got := m["max_bit_depth"]; got != "0" {
|
||||
t.Errorf("explicit zero should round-trip; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStringKey_CreatesFileWithSingleKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "player.yaml")
|
||||
|
||||
if err := WriteStringKey(path, "client_id", "aa:bb:cc:dd:ee:ff"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
cfg, _, err := LoadPlayerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load after write: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "aa:bb:cc:dd:ee:ff" {
|
||||
t.Errorf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf("tempfile should be cleaned up: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStringKey_UpdatesExistingKeyPreservingOthersAndComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
initial := `# Living room speaker
|
||||
name: "Living Room"
|
||||
server: "192.168.1.100:8927"
|
||||
client_id: "old-id"
|
||||
buffer_ms: 250
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(initial), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := WriteStringKey(path, "client_id", "new-id"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
|
||||
if !strings.Contains(text, `client_id: "new-id"`) && !strings.Contains(text, `client_id: new-id`) {
|
||||
t.Errorf("client_id not updated in file:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "old-id") {
|
||||
t.Errorf("old value still present:\n%s", text)
|
||||
}
|
||||
// Other keys preserved.
|
||||
for _, want := range []string{"Living Room", "192.168.1.100:8927", "buffer_ms"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("missing preserved content %q in:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// The leading comment survives round-trip.
|
||||
if !strings.Contains(text, "# Living room speaker") {
|
||||
t.Errorf("leading comment lost:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStringKey_AppendsKeyWhenAbsent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
initial := "name: Kitchen\n"
|
||||
if err := os.WriteFile(path, []byte(initial), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := WriteStringKey(path, "client_id", "new-value"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
cfg, _, err := LoadPlayerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Name != "Kitchen" {
|
||||
t.Errorf("original key lost: name = %q", cfg.Name)
|
||||
}
|
||||
if cfg.ClientID != "new-value" {
|
||||
t.Errorf("new key missing: client_id = %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
30
third_party/sendspin-go/pkg/sendspin/doc.go
vendored
Normal file
30
third_party/sendspin-go/pkg/sendspin/doc.go
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
// ABOUTME: High-level Sendspin library API
|
||||
// ABOUTME: Provides simple Player and Server APIs for most use cases
|
||||
// Package sendspin provides high-level APIs for Sendspin audio streaming.
|
||||
//
|
||||
// This is the main entry point for most library users, providing:
|
||||
// - Player: Connect to servers and play synchronized audio
|
||||
// - Server: Serve audio to multiple clients
|
||||
// - AudioSource: Interface for custom audio sources
|
||||
//
|
||||
// For lower-level control, see the audio, protocol, sync, and discovery packages.
|
||||
//
|
||||
// Example Player:
|
||||
//
|
||||
// player, err := sendspin.NewPlayer(sendspin.PlayerConfig{
|
||||
// ServerAddr: "localhost:8927",
|
||||
// PlayerName: "Living Room",
|
||||
// Volume: 80,
|
||||
// })
|
||||
// err = player.Connect()
|
||||
// err = player.Play()
|
||||
//
|
||||
// Example Server:
|
||||
//
|
||||
// source, err := sendspin.FileSource("/path/to/audio.flac")
|
||||
// server, err := sendspin.NewServer(sendspin.ServerConfig{
|
||||
// Port: 8927,
|
||||
// Source: source,
|
||||
// })
|
||||
// err = server.Start()
|
||||
package sendspin
|
||||
300
third_party/sendspin-go/pkg/sendspin/group.go
vendored
Normal file
300
third_party/sendspin-go/pkg/sendspin/group.go
vendored
Normal file
@@ -0,0 +1,300 @@
|
||||
// ABOUTME: Group owns the per-playback-group event bus
|
||||
// ABOUTME: Publishes client-level events to subscribed GroupRole handlers
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
// Event is the sealed interface implemented by every event published on a
|
||||
// Group's event bus. Callers type-switch on the interface to handle the
|
||||
// concrete types they care about.
|
||||
//
|
||||
// The isGroupEvent marker is unexported, so third-party packages cannot
|
||||
// implement Event directly — all events must be defined in this package.
|
||||
type Event interface {
|
||||
isGroupEvent()
|
||||
}
|
||||
|
||||
// ClientJoinedEvent fires when a client has completed the handshake and
|
||||
// been added to a Group. Handlers that need to send a greeting message
|
||||
// or snapshot state to the new client should listen for this.
|
||||
//
|
||||
// Unlike ClientLeftEvent, this event carries a live *ServerClient
|
||||
// pointer because at publish time the client has just completed its
|
||||
// handshake and its connection is fully alive — handler calls like
|
||||
// c.Send(...) are safe. ClientLeftEvent intentionally drops the
|
||||
// pointer because by the time it fires, the client is mid-teardown.
|
||||
type ClientJoinedEvent struct {
|
||||
Client *ServerClient
|
||||
}
|
||||
|
||||
func (ClientJoinedEvent) isGroupEvent() {}
|
||||
|
||||
// ClientLeftEvent fires when a client has disconnected and been removed
|
||||
// from a Group. The event carries only the ID and name of the departed
|
||||
// client, not a pointer — by the time handlers see this event, the
|
||||
// underlying ServerClient may already be mid-teardown and its methods
|
||||
// are unsafe to call. Handlers that need per-client state must have
|
||||
// captured it earlier (e.g. on the matching ClientJoinedEvent).
|
||||
type ClientLeftEvent struct {
|
||||
ClientID string
|
||||
ClientName string
|
||||
}
|
||||
|
||||
func (ClientLeftEvent) isGroupEvent() {}
|
||||
|
||||
// ClientStateChangedEvent fires when a client's player state (state,
|
||||
// volume, muted) has been updated from a client/state control message.
|
||||
// The snapshot fields are captured at publish time; the Client pointer
|
||||
// is provided for handlers that need to reply.
|
||||
//
|
||||
// Handlers should treat the embedded State/Volume/Muted fields as the
|
||||
// authoritative value for this specific event; calling c.State() on
|
||||
// the Client pointer may return a newer value written by a subsequent
|
||||
// state update that raced this event's delivery.
|
||||
type ClientStateChangedEvent struct {
|
||||
Client *ServerClient
|
||||
State string
|
||||
Volume int
|
||||
Muted bool
|
||||
}
|
||||
|
||||
func (ClientStateChangedEvent) isGroupEvent() {}
|
||||
|
||||
// GroupPlaybackStateChangedEvent fires when the group's playback state
|
||||
// transitions. Roles that need to react (e.g. re-broadcast metadata at
|
||||
// stopped→playing) listen via the optional PlaybackStateChangedHandler
|
||||
// interface dispatched by Group's role event loop.
|
||||
//
|
||||
// OldState may be empty for the initial transition out of the zero value.
|
||||
// Same-state transitions are not published (no-op writes are silent).
|
||||
type GroupPlaybackStateChangedEvent struct {
|
||||
OldState string
|
||||
NewState string
|
||||
}
|
||||
|
||||
func (GroupPlaybackStateChangedEvent) isGroupEvent() {}
|
||||
|
||||
// Group owns the event bus and the set of clients currently attached to
|
||||
// a playback group. For M2 there is exactly one Group per Server,
|
||||
// auto-created in NewServer. Multi-group support is a post-#61 concern.
|
||||
//
|
||||
// Event ordering: events originating from a single source (e.g., all
|
||||
// events from one client's read loop) are delivered to each subscriber
|
||||
// in publish order. Events from different sources have no relative
|
||||
// ordering guarantee — a handler that reacts to ClientJoinedEvent by
|
||||
// publishing another event does not race the client's own subsequent
|
||||
// events deterministically. M3 role handlers that need cross-source
|
||||
// ordering must coordinate via their own synchronization.
|
||||
//
|
||||
// The zero value is not usable — construct via NewGroup.
|
||||
type Group struct {
|
||||
id string
|
||||
playbackState string
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]*ServerClient
|
||||
subs map[int]chan Event
|
||||
nextSub int
|
||||
closed bool
|
||||
|
||||
roles map[string]GroupRole
|
||||
roleDispatchStarted bool
|
||||
}
|
||||
|
||||
// NewGroup constructs a Group with the given identifier. The ID is
|
||||
// typically the server's UUID for the implicit default group.
|
||||
func NewGroup(id string) *Group {
|
||||
return &Group{
|
||||
id: id,
|
||||
playbackState: "playing",
|
||||
clients: make(map[string]*ServerClient),
|
||||
subs: make(map[int]chan Event),
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the group identifier.
|
||||
func (g *Group) ID() string { return g.id }
|
||||
|
||||
// Subscribe registers a new listener and returns the event channel plus
|
||||
// an unsubscribe function. Each Subscribe call gets its own buffered
|
||||
// channel (capacity 32). Calling unsubscribe closes the channel and
|
||||
// removes it from the fan-out list; calling it more than once is safe.
|
||||
//
|
||||
// After Close() has been called, Subscribe returns a pre-closed channel
|
||||
// and a no-op unsubscribe. A receive on the returned channel will yield
|
||||
// the zero value with ok == false — callers ranging over the channel
|
||||
// should treat this as "group has shut down" rather than "no events
|
||||
// yet."
|
||||
func (g *Group) Subscribe() (<-chan Event, func()) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.closed {
|
||||
// Return a closed channel and a no-op unsubscribe so callers
|
||||
// don't have to check for this case.
|
||||
ch := make(chan Event)
|
||||
close(ch)
|
||||
return ch, func() {}
|
||||
}
|
||||
|
||||
id := g.nextSub
|
||||
g.nextSub++
|
||||
// Buffer size 32 is a heuristic: the bus carries low-rate control
|
||||
// events (joins, leaves, state changes), not audio. Slow handlers
|
||||
// drop events via the non-blocking publish path rather than stalling
|
||||
// the publisher.
|
||||
ch := make(chan Event, 32)
|
||||
g.subs[id] = ch
|
||||
|
||||
var once sync.Once
|
||||
unsubscribe := func() {
|
||||
once.Do(func() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if existing, ok := g.subs[id]; ok {
|
||||
delete(g.subs, id)
|
||||
close(existing)
|
||||
}
|
||||
})
|
||||
}
|
||||
return ch, unsubscribe
|
||||
}
|
||||
|
||||
// publish fans an event out to every active subscriber. This is the
|
||||
// top-level entry point used by code that does not already hold g.mu.
|
||||
// Sends are non-blocking: if a subscriber's buffer is full, the event
|
||||
// is dropped and a warning is logged.
|
||||
func (g *Group) publish(evt Event) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
g.publishLocked(evt)
|
||||
}
|
||||
|
||||
// publishLocked fans an event out assuming the caller already holds
|
||||
// g.mu (either Lock or RLock). Use this from code that mutates g.subs
|
||||
// or g.clients and wants to publish under the same critical section
|
||||
// to preserve ordering. Sends are non-blocking for the same reason
|
||||
// publish is — slow subscribers drop events instead of stalling the
|
||||
// publisher.
|
||||
//
|
||||
// Note: when invoked under a write Lock (as addClient/removeClient do),
|
||||
// the non-blocking sends run while the writer lock is held. This is
|
||||
// bounded by the number of subscribers and each send is select-default,
|
||||
// so the critical section remains O(subscribers) with no blocking.
|
||||
func (g *Group) publishLocked(evt Event) {
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
|
||||
for id, ch := range g.subs {
|
||||
select {
|
||||
case ch <- evt:
|
||||
default:
|
||||
log.Printf("Group %s: subscriber %d dropped event %T (buffer full)", g.id, id, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addClient attaches a ServerClient to the group and publishes a
|
||||
// ClientJoinedEvent. Idempotent — adding the same client twice is a
|
||||
// no-op on the second call.
|
||||
func (g *Group) addClient(c *ServerClient) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
if _, exists := g.clients[c.ID()]; exists {
|
||||
return
|
||||
}
|
||||
g.clients[c.ID()] = c
|
||||
|
||||
// Send group/update to the joining client — group-level concern,
|
||||
// not role-specific. Sent before ClientJoinedEvent so role handlers
|
||||
// can assume the client already knows its group context.
|
||||
groupID := g.id
|
||||
playbackState := g.playbackState
|
||||
if playbackState == "" {
|
||||
playbackState = "playing"
|
||||
}
|
||||
c.Send("group/update", protocol.GroupUpdate{
|
||||
GroupID: &groupID,
|
||||
PlaybackState: &playbackState,
|
||||
})
|
||||
|
||||
g.publishLocked(ClientJoinedEvent{Client: c})
|
||||
}
|
||||
|
||||
// removeClient detaches a ServerClient and publishes a ClientLeftEvent.
|
||||
// Idempotent — removing an unknown client is a no-op.
|
||||
func (g *Group) removeClient(c *ServerClient) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if _, exists := g.clients[c.ID()]; !exists {
|
||||
return
|
||||
}
|
||||
delete(g.clients, c.ID())
|
||||
|
||||
g.publishLocked(ClientLeftEvent{
|
||||
ClientID: c.ID(),
|
||||
ClientName: c.Name(),
|
||||
})
|
||||
}
|
||||
|
||||
// Clients returns a snapshot of the ServerClients currently attached to
|
||||
// this group. The returned slice is a fresh copy; mutating it does not
|
||||
// affect the group.
|
||||
func (g *Group) Clients() []*ServerClient {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
out := make([]*ServerClient, 0, len(g.clients))
|
||||
for _, c := range g.clients {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close releases all subscribers and marks the group as shut down.
|
||||
// After Close, Subscribe returns a pre-closed channel and publish is a
|
||||
// no-op. Close is safe to call multiple times.
|
||||
func (g *Group) Close() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
g.closed = true
|
||||
|
||||
for id, ch := range g.subs {
|
||||
close(ch)
|
||||
delete(g.subs, id)
|
||||
}
|
||||
clear(g.clients)
|
||||
}
|
||||
|
||||
// SetPlaybackState updates the group's playback state. If the new state
|
||||
// differs from the current value, a GroupPlaybackStateChangedEvent is
|
||||
// published. Future clients joining the group will receive the new
|
||||
// state in group/update. Same-state writes are a silent no-op.
|
||||
func (g *Group) SetPlaybackState(state string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.playbackState == state {
|
||||
return
|
||||
}
|
||||
oldState := g.playbackState
|
||||
g.playbackState = state
|
||||
g.publishLocked(GroupPlaybackStateChangedEvent{
|
||||
OldState: oldState,
|
||||
NewState: state,
|
||||
})
|
||||
}
|
||||
163
third_party/sendspin-go/pkg/sendspin/group_role.go
vendored
Normal file
163
third_party/sendspin-go/pkg/sendspin/group_role.go
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
// ABOUTME: GroupRole interface and role dispatch on Group
|
||||
// ABOUTME: Roles register with the Group and receive client events + messages
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GroupRole is the interface implemented by per-role handlers. Each
|
||||
// role family (controller, metadata, player, artwork) registers one
|
||||
// GroupRole with the Group. The Group dispatches client lifecycle
|
||||
// events to all registered roles.
|
||||
type GroupRole interface {
|
||||
Role() string
|
||||
OnClientJoin(c *ServerClient)
|
||||
OnClientLeave(id string, name string)
|
||||
}
|
||||
|
||||
// MessageHandler is an optional interface that a GroupRole may
|
||||
// implement to receive role-specific messages (e.g., client/command
|
||||
// payloads routed by role key). Roles that don't handle messages
|
||||
// don't need to implement this.
|
||||
type MessageHandler interface {
|
||||
HandleMessage(c *ServerClient, payload json.RawMessage) error
|
||||
}
|
||||
|
||||
// PlaybackStateChangedHandler is an optional interface that a GroupRole
|
||||
// may implement to receive notifications when the group's playback state
|
||||
// transitions. The Group dispatches GroupPlaybackStateChangedEvent to
|
||||
// roles that implement this interface; same-state writes are not
|
||||
// dispatched (Group.SetPlaybackState filters them out).
|
||||
//
|
||||
// Note: oldState may be empty for the very first transition out of the
|
||||
// zero value.
|
||||
type PlaybackStateChangedHandler interface {
|
||||
OnPlaybackStateChanged(oldState, newState string)
|
||||
}
|
||||
|
||||
// RegisterRole registers a GroupRole with this group. The role is
|
||||
// stored by its Role() family name. Duplicate registrations for the
|
||||
// same family overwrite the previous one.
|
||||
//
|
||||
// After registration, the role receives OnClientJoin / OnClientLeave
|
||||
// calls for clients that join or leave the group, dispatched from
|
||||
// the group's internal event subscriber.
|
||||
func (g *Group) RegisterRole(role GroupRole) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.roles == nil {
|
||||
g.roles = make(map[string]GroupRole)
|
||||
}
|
||||
g.roles[role.Role()] = role
|
||||
|
||||
// Optional attach-to-group hook: roles that need to iterate the
|
||||
// group's clients on broadcast (e.g. MetadataGroupRole) implement
|
||||
// the unexported attachToGroup method to receive a back-reference.
|
||||
// Kept unexported so callers can't bypass RegisterRole.
|
||||
if a, ok := role.(interface{ attachToGroup(*Group) }); ok {
|
||||
a.attachToGroup(g)
|
||||
}
|
||||
|
||||
if g.roleDispatchStarted {
|
||||
return
|
||||
}
|
||||
g.roleDispatchStarted = true
|
||||
|
||||
events, _ := g.subscribeInternal()
|
||||
go g.dispatchRoleEvents(events)
|
||||
}
|
||||
|
||||
// GetRole returns the registered GroupRole for the given family name,
|
||||
// or nil if none is registered.
|
||||
func (g *Group) GetRole(family string) GroupRole {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
if g.roles == nil {
|
||||
return nil
|
||||
}
|
||||
return g.roles[family]
|
||||
}
|
||||
|
||||
// RouteMessage routes a role-specific message payload to the
|
||||
// registered GroupRole for the given family. Returns an error if no
|
||||
// role is registered for the family or if the role doesn't implement
|
||||
// MessageHandler.
|
||||
func (g *Group) RouteMessage(c *ServerClient, roleFamily string, payload json.RawMessage) error {
|
||||
g.mu.RLock()
|
||||
role, ok := g.roles[roleFamily]
|
||||
g.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("no role registered for %q", roleFamily)
|
||||
}
|
||||
|
||||
handler, ok := role.(MessageHandler)
|
||||
if !ok {
|
||||
return fmt.Errorf("role %q does not handle messages", roleFamily)
|
||||
}
|
||||
|
||||
return handler.HandleMessage(c, payload)
|
||||
}
|
||||
|
||||
// dispatchRoleEvents reads from the group's event bus and calls
|
||||
// the appropriate lifecycle method on every registered role.
|
||||
func (g *Group) dispatchRoleEvents(events <-chan Event) {
|
||||
for evt := range events {
|
||||
g.mu.RLock()
|
||||
roles := make([]GroupRole, 0, len(g.roles))
|
||||
for _, r := range g.roles {
|
||||
roles = append(roles, r)
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
switch e := evt.(type) {
|
||||
case ClientJoinedEvent:
|
||||
for _, r := range roles {
|
||||
r.OnClientJoin(e.Client)
|
||||
}
|
||||
case ClientLeftEvent:
|
||||
for _, r := range roles {
|
||||
r.OnClientLeave(e.ClientID, e.ClientName)
|
||||
}
|
||||
case GroupPlaybackStateChangedEvent:
|
||||
for _, r := range roles {
|
||||
if h, ok := r.(PlaybackStateChangedHandler); ok {
|
||||
h.OnPlaybackStateChanged(e.OldState, e.NewState)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// ClientStateChangedEvent and future event types are
|
||||
// dispatched to roles that subscribe to them via other
|
||||
// mechanisms (e.g., the event bus directly). The role
|
||||
// dispatcher only handles lifecycle events.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeInternal creates a subscription while the caller already
|
||||
// holds g.mu.Lock. This avoids the deadlock that would occur if we
|
||||
// called the public Subscribe (which also takes Lock).
|
||||
func (g *Group) subscribeInternal() (<-chan Event, func()) {
|
||||
if g.closed {
|
||||
ch := make(chan Event)
|
||||
close(ch)
|
||||
return ch, func() {}
|
||||
}
|
||||
|
||||
id := g.nextSub
|
||||
g.nextSub++
|
||||
ch := make(chan Event, 32)
|
||||
g.subs[id] = ch
|
||||
|
||||
return ch, func() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if existing, ok := g.subs[id]; ok {
|
||||
delete(g.subs, id)
|
||||
close(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
147
third_party/sendspin-go/pkg/sendspin/group_role_test.go
vendored
Normal file
147
third_party/sendspin-go/pkg/sendspin/group_role_test.go
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
// ABOUTME: Tests for GroupRole interface and role dispatch on Group
|
||||
// ABOUTME: Verifies RegisterRole, event dispatch, and message routing
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testRole is a minimal GroupRole implementation for testing.
|
||||
type testRole struct {
|
||||
mu sync.Mutex
|
||||
role string
|
||||
joined []*ServerClient
|
||||
left []string
|
||||
messages []json.RawMessage
|
||||
}
|
||||
|
||||
func (r *testRole) Role() string { return r.role }
|
||||
|
||||
func (r *testRole) OnClientJoin(c *ServerClient) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.joined = append(r.joined, c)
|
||||
}
|
||||
|
||||
func (r *testRole) OnClientLeave(id string, name string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.left = append(r.left, id)
|
||||
}
|
||||
|
||||
func (r *testRole) HandleMessage(c *ServerClient, payload json.RawMessage) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.messages = append(r.messages, payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGroup_RegisterRole(t *testing.T) {
|
||||
g := NewGroup("test")
|
||||
defer g.Close()
|
||||
|
||||
role := &testRole{role: "controller"}
|
||||
g.RegisterRole(role)
|
||||
|
||||
got := g.GetRole("controller")
|
||||
if got != role {
|
||||
t.Errorf("GetRole returned %v, want registered role", got)
|
||||
}
|
||||
if g.GetRole("nonexistent") != nil {
|
||||
t.Error("GetRole for unregistered role should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroup_RoleDispatchOnJoinLeave(t *testing.T) {
|
||||
g := NewGroup("test")
|
||||
defer g.Close()
|
||||
|
||||
role := &testRole{role: "player"}
|
||||
g.RegisterRole(role)
|
||||
|
||||
sc := &ServerClient{id: "c1", name: "Client 1", roles: []string{"player@v1"}}
|
||||
g.addClient(sc)
|
||||
|
||||
// Give the dispatcher goroutine time to process.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
role.mu.Lock()
|
||||
joinedCount := len(role.joined)
|
||||
var joinedID string
|
||||
if joinedCount > 0 {
|
||||
joinedID = role.joined[0].ID()
|
||||
}
|
||||
role.mu.Unlock()
|
||||
|
||||
if joinedCount != 1 || joinedID != "c1" {
|
||||
t.Errorf("OnClientJoin: got %d calls, want 1 with id=c1", joinedCount)
|
||||
}
|
||||
|
||||
g.removeClient(sc)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
role.mu.Lock()
|
||||
leftCount := len(role.left)
|
||||
var leftID string
|
||||
if leftCount > 0 {
|
||||
leftID = role.left[0]
|
||||
}
|
||||
role.mu.Unlock()
|
||||
|
||||
if leftCount != 1 || leftID != "c1" {
|
||||
t.Errorf("OnClientLeave: got %d calls, want 1 with id=c1", leftCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroup_RouteMessage(t *testing.T) {
|
||||
g := NewGroup("test")
|
||||
defer g.Close()
|
||||
|
||||
role := &testRole{role: "controller"}
|
||||
g.RegisterRole(role)
|
||||
|
||||
sc := &ServerClient{id: "c1"}
|
||||
payload := json.RawMessage(`{"command":"next"}`)
|
||||
|
||||
err := g.RouteMessage(sc, "controller", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("RouteMessage: %v", err)
|
||||
}
|
||||
if len(role.messages) != 1 {
|
||||
t.Fatalf("HandleMessage called %d times, want 1", len(role.messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroup_RouteMessageNoHandler(t *testing.T) {
|
||||
g := NewGroup("test")
|
||||
defer g.Close()
|
||||
|
||||
err := g.RouteMessage(&ServerClient{id: "c1"}, "nonexistent", json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Error("RouteMessage to unregistered role should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// roleWithoutMessageHandler implements GroupRole but NOT MessageHandler.
|
||||
type roleWithoutMessageHandler struct {
|
||||
role string
|
||||
}
|
||||
|
||||
func (r *roleWithoutMessageHandler) Role() string { return r.role }
|
||||
func (r *roleWithoutMessageHandler) OnClientJoin(c *ServerClient) {}
|
||||
func (r *roleWithoutMessageHandler) OnClientLeave(id string, name string) {}
|
||||
|
||||
func TestGroup_RouteMessageRoleWithoutHandler(t *testing.T) {
|
||||
g := NewGroup("test")
|
||||
defer g.Close()
|
||||
|
||||
g.RegisterRole(&roleWithoutMessageHandler{role: "metadata"})
|
||||
|
||||
err := g.RouteMessage(&ServerClient{id: "c1"}, "metadata", json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Error("RouteMessage to role without MessageHandler should return error")
|
||||
}
|
||||
}
|
||||
327
third_party/sendspin-go/pkg/sendspin/group_test.go
vendored
Normal file
327
third_party/sendspin-go/pkg/sendspin/group_test.go
vendored
Normal file
@@ -0,0 +1,327 @@
|
||||
// ABOUTME: Tests for the Group event bus plumbing
|
||||
// ABOUTME: Fan-out, unsubscribe, and slow-subscriber drop behavior
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGroup_PublishSubscribe confirms the basic contract: an event
|
||||
// published after Subscribe is delivered to the subscriber's channel.
|
||||
func TestGroup_PublishSubscribe(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
events, unsubscribe := g.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
joined, ok := evt.(ClientJoinedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want ClientJoinedEvent", evt)
|
||||
}
|
||||
if joined.Client.ID() != "c1" {
|
||||
t.Errorf("Client.ID() = %q, want %q", joined.Client.ID(), "c1")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for published event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_FanOut confirms one publish reaches every active subscriber.
|
||||
func TestGroup_FanOut(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
const subscribers = 3
|
||||
chans := make([]<-chan Event, subscribers)
|
||||
for i := 0; i < subscribers; i++ {
|
||||
ch, unsub := g.Subscribe()
|
||||
defer unsub()
|
||||
chans[i] = ch
|
||||
}
|
||||
|
||||
g.publish(ClientLeftEvent{ClientID: "c-gone", ClientName: "Gone Client"})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, ch := range chans {
|
||||
wg.Add(1)
|
||||
go func(idx int, c <-chan Event) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case evt := <-c:
|
||||
if _, ok := evt.(ClientLeftEvent); !ok {
|
||||
t.Errorf("subscriber %d got %T, want ClientLeftEvent", idx, evt)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Errorf("subscriber %d timed out", idx)
|
||||
}
|
||||
}(i, ch)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestGroup_Unsubscribe confirms that after calling the unsubscribe func,
|
||||
// further publishes do NOT reach the channel.
|
||||
func TestGroup_Unsubscribe(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
events, unsubscribe := g.Subscribe()
|
||||
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
|
||||
<-events // drain the first event
|
||||
|
||||
unsubscribe()
|
||||
|
||||
// Publish a second event — the unsubscribed channel should not receive it.
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c2"}})
|
||||
|
||||
select {
|
||||
case evt, ok := <-events:
|
||||
if ok {
|
||||
t.Errorf("received event %v after unsubscribe", evt)
|
||||
}
|
||||
// Channel closed — also acceptable.
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// No event arrived within the window — correct behavior.
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_SlowSubscriberDoesNotBlockPublisher pins the non-blocking
|
||||
// fan-out contract: a subscriber that never reads must not stall events
|
||||
// for other subscribers or the publisher itself.
|
||||
func TestGroup_SlowSubscriberDoesNotBlockPublisher(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
// slow subscriber — we intentionally never read from this.
|
||||
_, _ = g.Subscribe()
|
||||
|
||||
fast, unsubscribeFast := g.Subscribe()
|
||||
defer unsubscribeFast()
|
||||
|
||||
// Overflow the slow subscriber's buffer.
|
||||
for i := 0; i < 100; i++ {
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
|
||||
}
|
||||
|
||||
// The fast subscriber should still have received events.
|
||||
received := 0
|
||||
timeout := time.After(200 * time.Millisecond)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-fast:
|
||||
received++
|
||||
if received >= 32 {
|
||||
break loop
|
||||
}
|
||||
case <-timeout:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
if received == 0 {
|
||||
t.Error("fast subscriber received zero events while slow one blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_IDReturnsConstructorValue just pins the GroupID accessor.
|
||||
func TestGroup_IDReturnsConstructorValue(t *testing.T) {
|
||||
g := NewGroup("my-group-id")
|
||||
defer g.Close()
|
||||
if got := g.ID(); got != "my-group-id" {
|
||||
t.Errorf("ID() = %q, want %q", got, "my-group-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_ConcurrentSubscribeClose spawns many Subscribe callers
|
||||
// against a concurrent Close and asserts that nothing panics and every
|
||||
// returned channel is either pre-closed or closes promptly. Guards the
|
||||
// lock discipline against future refactors that could regress the
|
||||
// Subscribe-vs-Close ordering.
|
||||
func TestGroup_ConcurrentSubscribeClose(t *testing.T) {
|
||||
const subscribers = 50
|
||||
g := NewGroup("race-bait")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
subsReady := make(chan struct{})
|
||||
|
||||
for i := 0; i < subscribers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-subsReady
|
||||
ch, unsub := g.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
// Drain until the channel closes. If Close races with our
|
||||
// Subscribe we should either get a pre-closed channel
|
||||
// immediately or see it close after Close completes.
|
||||
deadline := time.After(500 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Error("channel never closed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(subsReady)
|
||||
time.Sleep(5 * time.Millisecond) // let some Subscribes land
|
||||
g.Close()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestGroup_ConcurrentPublishClose races publishes against a close and
|
||||
// asserts no panic. A send on a closed channel would surface as a panic
|
||||
// recovered by the test runner.
|
||||
func TestGroup_ConcurrentPublishClose(t *testing.T) {
|
||||
g := NewGroup("race-bait")
|
||||
|
||||
// Pre-subscribe so there's a channel to fan out to.
|
||||
_, unsub := g.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(2 * time.Millisecond) // let some publishes land
|
||||
g.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestGroup_SetPlaybackState_PublishesOnChange confirms that
|
||||
// SetPlaybackState publishes a GroupPlaybackStateChangedEvent only on
|
||||
// actual transitions, and is a silent no-op on same-state writes. The
|
||||
// group's default state is "playing" (per NewGroup), so calling
|
||||
// SetPlaybackState("playing") first must produce no event; transitioning
|
||||
// to "stopped" must produce exactly one event with OldState="playing".
|
||||
func TestGroup_SetPlaybackState_PublishesOnChange(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
// Subscribe BEFORE any state writes so we observe every emitted event.
|
||||
events, unsubscribe := g.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
// Same-state write: must not publish (default is "playing").
|
||||
g.SetPlaybackState("playing")
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
t.Fatalf("same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
// Expected: no event.
|
||||
}
|
||||
|
||||
// Real transition: must publish exactly one event.
|
||||
g.SetPlaybackState("stopped")
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
||||
}
|
||||
t.Logf("event=%+v", ps)
|
||||
if ps.OldState != "playing" || ps.NewState != "stopped" {
|
||||
t.Errorf("event = %+v, want {OldState: playing, NewState: stopped}", ps)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for GroupPlaybackStateChangedEvent")
|
||||
}
|
||||
|
||||
// Same-state write again: must not publish.
|
||||
g.SetPlaybackState("stopped")
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
t.Fatalf("repeat same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_SetPlaybackState_OldStateInEvent pins that successive
|
||||
// transitions carry the prior playback state in OldState — not the empty
|
||||
// string, not the first-ever value. This guards against future refactors
|
||||
// that might forget to capture oldState before mutation.
|
||||
func TestGroup_SetPlaybackState_OldStateInEvent(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
events, unsubscribe := g.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
g.SetPlaybackState("paused")
|
||||
g.SetPlaybackState("playing")
|
||||
|
||||
// First event: playing → paused
|
||||
select {
|
||||
case evt := <-events:
|
||||
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
||||
}
|
||||
t.Logf("event[0]=%+v", ps)
|
||||
if ps.OldState != "playing" || ps.NewState != "paused" {
|
||||
t.Errorf("event[0] = %+v, want {playing, paused}", ps)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out on first event")
|
||||
}
|
||||
|
||||
// Second event: paused → playing
|
||||
select {
|
||||
case evt := <-events:
|
||||
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
||||
}
|
||||
t.Logf("event[1]=%+v", ps)
|
||||
if ps.OldState != "paused" || ps.NewState != "playing" {
|
||||
t.Errorf("event[1] = %+v, want {paused, playing}", ps)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out on second event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_AddClientSendsGroupUpdate confirms that addClient sends
|
||||
// a group/update message to the joining client before publishing
|
||||
// ClientJoinedEvent.
|
||||
func TestGroup_AddClientSendsGroupUpdate(t *testing.T) {
|
||||
g := NewGroup("group-123")
|
||||
defer g.Close()
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
g.addClient(sc)
|
||||
|
||||
select {
|
||||
case msg := <-sc.sendChan:
|
||||
_ = msg // Verifies group/update was sent
|
||||
default:
|
||||
t.Fatal("addClient did not send group/update")
|
||||
}
|
||||
}
|
||||
583
third_party/sendspin-go/pkg/sendspin/player.go
vendored
Normal file
583
third_party/sendspin-go/pkg/sendspin/player.go
vendored
Normal file
@@ -0,0 +1,583 @@
|
||||
// ABOUTME: High-level Player API for Sendspin streaming
|
||||
// ABOUTME: Composes Receiver + audio output with optional ProcessCallback
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
)
|
||||
|
||||
// ReconnectConfig controls automatic reconnect behavior after the protocol
|
||||
// connection drops. When Enabled is false (the zero value), Player behaves
|
||||
// as a one-shot: a lost connection stays lost.
|
||||
type ReconnectConfig struct {
|
||||
Enabled bool
|
||||
InitialDelay time.Duration // default 500ms
|
||||
MaxDelay time.Duration // default 30s
|
||||
Multiplier float64 // default 2.0
|
||||
MaxAttempts int // 0 = infinite (default)
|
||||
|
||||
// Rediscover is an optional callback invoked before each reconnect
|
||||
// attempt. Returning a non-empty address overrides the configured
|
||||
// ServerAddr for that attempt. Use this to re-run mDNS discovery when
|
||||
// the server may have moved. Errors are logged and the attempt falls
|
||||
// back to the last known address.
|
||||
Rediscover func(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// PlayerConfig holds player configuration
|
||||
type PlayerConfig struct {
|
||||
// ServerAddr is the server address (host:port)
|
||||
ServerAddr string
|
||||
|
||||
PlayerName string
|
||||
|
||||
// Volume is the initial volume (0-100)
|
||||
Volume int
|
||||
|
||||
// BufferMs is the playback buffer size in milliseconds (default: 500)
|
||||
BufferMs int
|
||||
|
||||
// StaticDelayMs shifts every scheduled play time forward by this many
|
||||
// milliseconds. Used to compensate for hardware that introduces a fixed
|
||||
// downstream latency (Bluetooth sinks, AVRs with DSP, some USB DACs).
|
||||
// Default 0 means no shift.
|
||||
StaticDelayMs int
|
||||
|
||||
// PreferredCodec reorders the advertised format list so the server
|
||||
// picks this codec first. Values: "pcm" (default), "opus", "flac".
|
||||
PreferredCodec string
|
||||
|
||||
// BufferCapacity is the buffer_capacity (bytes) advertised to the
|
||||
// server in client/hello. The server uses this to pace how far ahead
|
||||
// it sends audio. Default: 1048576 (1MB).
|
||||
BufferCapacity int
|
||||
|
||||
// ClientID is the already-resolved client_id to advertise in client/hello.
|
||||
// Required — callers should compute this once at startup (typically via
|
||||
// ResolveClientID) and reuse the same value across reconnects.
|
||||
ClientID string
|
||||
|
||||
// AudioDevice selects a specific playback device by name (as reported by
|
||||
// output.ListPlaybackDevices). Empty = let miniaudio pick the default.
|
||||
// Open fails loudly if a non-empty name doesn't match an available device.
|
||||
AudioDevice string
|
||||
|
||||
// MaxSampleRate caps the highest SampleRate advertised to the server.
|
||||
// 0 = auto-probe the AudioDevice for its native ceiling on first Connect.
|
||||
// Setting either MaxSampleRate or MaxBitDepth to a non-zero value disables
|
||||
// auto-probe entirely (replace semantics — explicit user choice wins, even
|
||||
// if it raises the cap above what the probe would have found). Use the
|
||||
// override when the auto-probe is wrong, as on Pi3 where ALSA reports the
|
||||
// bcm2835 onboard headphones accept 192k/24 but the hardware can't
|
||||
// actually drain it.
|
||||
MaxSampleRate int
|
||||
// MaxBitDepth caps the highest BitDepth advertised to the server.
|
||||
// 0 = auto-probe. See MaxSampleRate for override semantics.
|
||||
MaxBitDepth int
|
||||
|
||||
DeviceInfo DeviceInfo
|
||||
|
||||
OnMetadata func(Metadata)
|
||||
|
||||
OnStateChange func(PlayerState)
|
||||
|
||||
OnError func(error)
|
||||
|
||||
// Output overrides the default audio output backend.
|
||||
// When nil, a malgo-backed output is created on stream start.
|
||||
Output output.Output
|
||||
|
||||
// DecoderFactory overrides the default decoder selection.
|
||||
// When nil, the default codec switch (PCM, Opus, FLAC) is used.
|
||||
DecoderFactory func(audio.Format) (decode.Decoder, error)
|
||||
|
||||
// ProcessCallback is called with decoded samples before they are written to output.
|
||||
// Must not block. Runs on the audio consumption goroutine.
|
||||
ProcessCallback func([]int32)
|
||||
|
||||
// Reconnect controls automatic reconnection behavior when the protocol
|
||||
// connection drops. Disabled by default.
|
||||
Reconnect ReconnectConfig
|
||||
}
|
||||
|
||||
type DeviceInfo struct {
|
||||
ProductName string
|
||||
Manufacturer string
|
||||
SoftwareVersion string
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
AlbumArtist string
|
||||
ArtworkURL string
|
||||
Track int
|
||||
Year int
|
||||
Duration int // seconds
|
||||
}
|
||||
|
||||
type PlayerState struct {
|
||||
State string // "idle", "playing", "paused"
|
||||
Volume int
|
||||
Muted bool
|
||||
Codec string
|
||||
SampleRate int
|
||||
Channels int
|
||||
BitDepth int
|
||||
Connected bool
|
||||
}
|
||||
|
||||
type PlayerStats struct {
|
||||
Received int64
|
||||
Played int64
|
||||
Dropped int64
|
||||
BufferDepth int // milliseconds
|
||||
SyncRTT int64
|
||||
SyncQuality sync.Quality
|
||||
}
|
||||
|
||||
// Player provides high-level audio playback from Sendspin servers.
|
||||
// It composes a Receiver (connect/sync/decode/schedule) with an audio output backend.
|
||||
type Player struct {
|
||||
config PlayerConfig
|
||||
receiver *Receiver
|
||||
output output.Output
|
||||
state PlayerState
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
capsResolved bool // probe runs once at first Connect; reconnects reuse the cached caps
|
||||
}
|
||||
|
||||
func NewPlayer(config PlayerConfig) (*Player, error) {
|
||||
if config.Volume == 0 {
|
||||
config.Volume = 100
|
||||
}
|
||||
if config.BufferMs == 0 {
|
||||
config.BufferMs = 500
|
||||
}
|
||||
if config.Reconnect.Enabled {
|
||||
if config.Reconnect.InitialDelay <= 0 {
|
||||
config.Reconnect.InitialDelay = 500 * time.Millisecond
|
||||
}
|
||||
if config.Reconnect.MaxDelay <= 0 {
|
||||
config.Reconnect.MaxDelay = 30 * time.Second
|
||||
}
|
||||
if config.Reconnect.Multiplier <= 1.0 {
|
||||
config.Reconnect.Multiplier = 2.0
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Player{
|
||||
config: config,
|
||||
output: config.Output,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
state: PlayerState{
|
||||
State: "idle",
|
||||
Volume: config.Volume,
|
||||
Muted: false,
|
||||
Connected: false,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Player) Connect() error {
|
||||
recv, err := p.buildReceiver(p.config.ServerAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recv.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.receiver = recv
|
||||
p.state.Connected = true
|
||||
p.notifyStateChange()
|
||||
|
||||
go p.consumeAudio(recv)
|
||||
|
||||
if p.config.Reconnect.Enabled {
|
||||
go p.runReconnectLoop(recv)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Player) buildReceiver(addr string) (*Receiver, error) {
|
||||
p.ensureCapsResolved()
|
||||
return NewReceiver(ReceiverConfig{
|
||||
ServerAddr: addr,
|
||||
PlayerName: p.config.PlayerName,
|
||||
BufferMs: p.config.BufferMs,
|
||||
StaticDelayMs: p.config.StaticDelayMs,
|
||||
PreferredCodec: p.config.PreferredCodec,
|
||||
BufferCapacity: p.config.BufferCapacity,
|
||||
MaxSampleRate: p.config.MaxSampleRate,
|
||||
MaxBitDepth: p.config.MaxBitDepth,
|
||||
ClientID: p.config.ClientID,
|
||||
DeviceInfo: p.config.DeviceInfo,
|
||||
DecoderFactory: p.config.DecoderFactory,
|
||||
OnMetadata: p.config.OnMetadata,
|
||||
OnStreamStart: p.onStreamStart,
|
||||
OnStreamEnd: p.onStreamEnd,
|
||||
OnError: p.config.OnError,
|
||||
OnControl: p.onControl,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Player) onControl(cmd protocol.PlayerCommand) {
|
||||
switch cmd.Command {
|
||||
case "volume":
|
||||
_ = p.SetVolume(cmd.Volume)
|
||||
case "mute":
|
||||
_ = p.Mute(cmd.Mute)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureCapsResolved decides MaxSampleRate / MaxBitDepth on first call and
|
||||
// caches the decision so subsequent reconnects reuse the same caps without
|
||||
// re-probing miniaudio.
|
||||
//
|
||||
// Replace semantics: any explicit non-zero override on either field skips
|
||||
// the probe entirely, so users who want to raise the cap above the device's
|
||||
// reported ceiling can. Probe failures are logged and treated as "no cap" —
|
||||
// we'd rather advertise too much (and let the device-stall path complain)
|
||||
// than refuse to start when the malgo backend is unavailable (e.g. CI).
|
||||
//
|
||||
// When config.Output is non-nil, the caller has substituted their own output
|
||||
// (test doubles, custom backends), and probing the malgo default device
|
||||
// wouldn't tell us anything useful — skip in that case too.
|
||||
func (p *Player) ensureCapsResolved() {
|
||||
if p.capsResolved {
|
||||
return
|
||||
}
|
||||
p.capsResolved = true
|
||||
|
||||
if p.config.MaxSampleRate != 0 || p.config.MaxBitDepth != 0 {
|
||||
log.Printf("Output capability cap: %d Hz / %d-bit (source: config)",
|
||||
p.config.MaxSampleRate, p.config.MaxBitDepth)
|
||||
return
|
||||
}
|
||||
if p.config.Output != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rate, depth, err := output.QueryDeviceCapabilities(p.config.AudioDevice)
|
||||
if err != nil {
|
||||
log.Printf("Output capability probe failed (%v); advertising full format list", err)
|
||||
return
|
||||
}
|
||||
if rate == 0 && depth == 0 {
|
||||
log.Printf("Output capability probe returned no native formats; advertising full format list")
|
||||
return
|
||||
}
|
||||
p.config.MaxSampleRate = rate
|
||||
p.config.MaxBitDepth = depth
|
||||
log.Printf("Output capability cap: %d Hz / %d-bit (source: probe)", rate, depth)
|
||||
}
|
||||
|
||||
// runReconnectLoop supervises the active receiver and rebuilds it with
|
||||
// exponential backoff whenever its Done channel closes. Exits when the
|
||||
// Player context is cancelled.
|
||||
func (p *Player) runReconnectLoop(initial *Receiver) {
|
||||
current := initial
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case <-current.Done():
|
||||
}
|
||||
|
||||
// Connection lost. Enter reconnecting state and back off.
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
p.state.Connected = false
|
||||
p.state.State = "reconnecting"
|
||||
p.notifyStateChange()
|
||||
|
||||
next, ok := p.reconnectWithBackoff()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current = next
|
||||
|
||||
p.receiver = current
|
||||
p.state.Connected = true
|
||||
p.notifyStateChange()
|
||||
|
||||
go p.consumeAudio(current)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) reconnectWithBackoff() (*Receiver, bool) {
|
||||
cfg := p.config.Reconnect
|
||||
delay := cfg.InitialDelay
|
||||
attempt := 0
|
||||
|
||||
for {
|
||||
attempt++
|
||||
if cfg.MaxAttempts > 0 && attempt > cfg.MaxAttempts {
|
||||
p.notifyError(fmt.Errorf("reconnect: gave up after %d attempts", cfg.MaxAttempts))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Jittered sleep (±20%).
|
||||
jittered := jitter(delay, 0.2)
|
||||
log.Printf("Reconnect attempt %d in %v", attempt, jittered)
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return nil, false
|
||||
case <-time.After(jittered):
|
||||
}
|
||||
|
||||
addr := p.config.ServerAddr
|
||||
if cfg.Rediscover != nil {
|
||||
discovered, err := cfg.Rediscover(p.ctx)
|
||||
if err != nil {
|
||||
log.Printf("Reconnect: rediscover failed: %v (using last known addr %s)", err, addr)
|
||||
} else if discovered != "" {
|
||||
addr = discovered
|
||||
}
|
||||
}
|
||||
|
||||
recv, err := p.buildReceiver(addr)
|
||||
if err == nil {
|
||||
if err = recv.Connect(); err == nil {
|
||||
log.Printf("Reconnect: connected to %s on attempt %d", addr, attempt)
|
||||
return recv, true
|
||||
}
|
||||
}
|
||||
log.Printf("Reconnect attempt %d to %s failed: %v", attempt, addr, err)
|
||||
|
||||
delay = time.Duration(float64(delay) * cfg.Multiplier)
|
||||
if delay > cfg.MaxDelay {
|
||||
delay = cfg.MaxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jitter(d time.Duration, frac float64) time.Duration {
|
||||
if d <= 0 {
|
||||
return d
|
||||
}
|
||||
delta := (rand.Float64()*2 - 1) * frac
|
||||
return time.Duration(float64(d) * (1 + delta))
|
||||
}
|
||||
|
||||
func (p *Player) onStreamStart(format audio.Format) {
|
||||
if p.output == nil {
|
||||
p.output = output.NewMalgo(p.config.AudioDevice)
|
||||
}
|
||||
|
||||
if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil {
|
||||
p.notifyError(fmt.Errorf("failed to initialize output: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
p.output.SetVolume(p.state.Volume)
|
||||
p.output.SetMuted(p.state.Muted)
|
||||
|
||||
p.state.Codec = format.Codec
|
||||
p.state.SampleRate = format.SampleRate
|
||||
p.state.Channels = format.Channels
|
||||
p.state.BitDepth = format.BitDepth
|
||||
p.state.State = "playing"
|
||||
p.notifyStateChange()
|
||||
}
|
||||
|
||||
func (p *Player) onStreamEnd() {
|
||||
p.state.State = "idle"
|
||||
p.notifyStateChange()
|
||||
}
|
||||
|
||||
func (p *Player) consumeAudio(recv *Receiver) {
|
||||
for {
|
||||
select {
|
||||
case buf, ok := <-recv.Output():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if p.config.ProcessCallback != nil {
|
||||
p.config.ProcessCallback(buf.Samples)
|
||||
}
|
||||
|
||||
if p.output != nil {
|
||||
if err := p.output.Write(buf.Samples); err != nil {
|
||||
p.notifyError(fmt.Errorf("playback error: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) Play() error {
|
||||
if !p.state.Connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
p.state.State = "playing"
|
||||
p.notifyStateChange()
|
||||
return p.sendState()
|
||||
}
|
||||
|
||||
func (p *Player) Pause() error {
|
||||
if !p.state.Connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
p.state.State = "paused"
|
||||
p.notifyStateChange()
|
||||
return p.sendState()
|
||||
}
|
||||
|
||||
func (p *Player) Stop() error {
|
||||
if !p.state.Connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
p.state.State = "idle"
|
||||
p.notifyStateChange()
|
||||
return p.sendState()
|
||||
}
|
||||
|
||||
// SetVolume sets the volume (0-100)
|
||||
func (p *Player) SetVolume(volume int) error {
|
||||
if volume < 0 {
|
||||
volume = 0
|
||||
}
|
||||
if volume > 100 {
|
||||
volume = 100
|
||||
}
|
||||
p.state.Volume = volume
|
||||
|
||||
if p.output != nil {
|
||||
p.output.SetVolume(volume)
|
||||
}
|
||||
|
||||
if p.receiver != nil && p.state.Connected {
|
||||
p.sendState()
|
||||
}
|
||||
|
||||
p.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Player) Mute(muted bool) error {
|
||||
p.state.Muted = muted
|
||||
|
||||
if p.output != nil {
|
||||
p.output.SetMuted(muted)
|
||||
}
|
||||
|
||||
if p.receiver != nil && p.state.Connected {
|
||||
p.sendState()
|
||||
}
|
||||
|
||||
p.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Player) Status() PlayerState {
|
||||
return p.state
|
||||
}
|
||||
|
||||
func (p *Player) Stats() PlayerStats {
|
||||
stats := PlayerStats{}
|
||||
|
||||
if p.receiver != nil {
|
||||
rs := p.receiver.Stats()
|
||||
stats.Received = rs.Received
|
||||
stats.Played = rs.Played
|
||||
stats.Dropped = rs.Dropped
|
||||
stats.BufferDepth = rs.BufferDepth
|
||||
stats.SyncRTT = rs.SyncRTT
|
||||
stats.SyncQuality = rs.SyncQuality
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func (p *Player) Close() error {
|
||||
p.cancel()
|
||||
|
||||
if p.receiver != nil {
|
||||
p.receiver.Close()
|
||||
}
|
||||
|
||||
if p.output != nil {
|
||||
p.output.Close()
|
||||
}
|
||||
|
||||
p.state.Connected = false
|
||||
p.state.State = "idle"
|
||||
p.notifyStateChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCommand sends a controller command to the server (e.g., "play",
|
||||
// "pause", "next", "previous"). This is how a player requests playback
|
||||
// control — the server decides whether to act on it.
|
||||
func (p *Player) SendCommand(command string) error {
|
||||
if p.receiver == nil || p.receiver.client == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"controller": map[string]interface{}{
|
||||
"command": command,
|
||||
},
|
||||
}
|
||||
return p.receiver.client.Send("client/command", payload)
|
||||
}
|
||||
|
||||
func (p *Player) sendState() error {
|
||||
if p.receiver == nil || p.receiver.client == nil {
|
||||
return nil
|
||||
}
|
||||
return p.receiver.client.SendState(protocol.PlayerState{
|
||||
State: "synchronized",
|
||||
Volume: p.state.Volume,
|
||||
Muted: p.state.Muted,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Player) notifyStateChange() {
|
||||
if p.config.OnStateChange != nil {
|
||||
p.config.OnStateChange(p.state)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) notifyError(err error) {
|
||||
if p.config.OnError != nil {
|
||||
p.config.OnError(err)
|
||||
} else {
|
||||
log.Printf("Player error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsRole(roles []string, role string) bool {
|
||||
for _, r := range roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
259
third_party/sendspin-go/pkg/sendspin/player_test.go
vendored
Normal file
259
third_party/sendspin-go/pkg/sendspin/player_test.go
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
// ABOUTME: Tests for refactored Player composing Receiver
|
||||
// ABOUTME: Verifies backward-compatible API and new ProcessCallback
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPlayer_Defaults(t *testing.T) {
|
||||
player, err := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test Player",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if player == nil {
|
||||
t.Fatal("expected non-nil player")
|
||||
}
|
||||
if player.receiver != nil {
|
||||
t.Error("receiver should be nil before Connect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlayer_ProcessCallbackStored(t *testing.T) {
|
||||
player, _ := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test Player",
|
||||
ProcessCallback: func(samples []int32) {},
|
||||
})
|
||||
if player.config.ProcessCallback == nil {
|
||||
t.Error("expected ProcessCallback to be stored in config")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPlayer_DeviceInfoPassesThrough guards #48: --manufacturer and
|
||||
// --product-name CLI flags write into PlayerConfig.DeviceInfo, and must
|
||||
// survive the trip into the underlying Receiver without being replaced
|
||||
// by the library defaults.
|
||||
func TestNewPlayer_DeviceInfoPassesThrough(t *testing.T) {
|
||||
custom := DeviceInfo{
|
||||
ProductName: "Custom Product",
|
||||
Manufacturer: "Custom Mfg",
|
||||
SoftwareVersion: "9.9.9",
|
||||
}
|
||||
player, err := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
DeviceInfo: custom,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlayer: %v", err)
|
||||
}
|
||||
if player.config.DeviceInfo != custom {
|
||||
t.Errorf("config.DeviceInfo = %+v, want %+v", player.config.DeviceInfo, custom)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPlayer_StaticDelayStored guards #47: PlayerConfig.StaticDelayMs
|
||||
// must be preserved on the Player so Connect can plumb it into Receiver
|
||||
// and from there into Scheduler.
|
||||
func TestNewPlayer_StaticDelayStored(t *testing.T) {
|
||||
player, err := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
StaticDelayMs: 250,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlayer: %v", err)
|
||||
}
|
||||
if player.config.StaticDelayMs != 250 {
|
||||
t.Errorf("config.StaticDelayMs = %d, want 250", player.config.StaticDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiver_StaticDelayDefaultZero sanity-checks that a ReceiverConfig
|
||||
// without StaticDelayMs set produces a scheduler with zero offset. This is
|
||||
// the test that would have flagged the new field if a future refactor
|
||||
// stopped plumbing it through. Uses a short connect attempt + teardown
|
||||
// because we don't want to actually dial anything.
|
||||
func TestReceiver_StaticDelayDefaultZero(t *testing.T) {
|
||||
recv, err := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:0",
|
||||
PlayerName: "Test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver: %v", err)
|
||||
}
|
||||
defer recv.Close()
|
||||
if recv.config.StaticDelayMs != 0 {
|
||||
t.Errorf("default StaticDelayMs = %d, want 0", recv.config.StaticDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPlayer_ReconnectDefaultsApplied guards the reconnect backoff
|
||||
// defaults (#38). When the caller enables reconnect but leaves timing
|
||||
// fields zero, NewPlayer must fill them with the documented defaults so
|
||||
// an accidentally-zero delay never spin-loops.
|
||||
func TestNewPlayer_ReconnectDefaultsApplied(t *testing.T) {
|
||||
player, err := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
Reconnect: ReconnectConfig{Enabled: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlayer: %v", err)
|
||||
}
|
||||
rc := player.config.Reconnect
|
||||
if rc.InitialDelay != 500*time.Millisecond {
|
||||
t.Errorf("InitialDelay = %v, want 500ms", rc.InitialDelay)
|
||||
}
|
||||
if rc.MaxDelay != 30*time.Second {
|
||||
t.Errorf("MaxDelay = %v, want 30s", rc.MaxDelay)
|
||||
}
|
||||
if rc.Multiplier != 2.0 {
|
||||
t.Errorf("Multiplier = %v, want 2.0", rc.Multiplier)
|
||||
}
|
||||
if rc.MaxAttempts != 0 {
|
||||
t.Errorf("MaxAttempts = %d, want 0 (infinite)", rc.MaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled makes sure we don't
|
||||
// silently turn reconnect on. If Enabled is false we leave the zero values
|
||||
// alone — there is no supervisor goroutine to read them anyway.
|
||||
func TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled(t *testing.T) {
|
||||
player, _ := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
})
|
||||
if player.config.Reconnect.Enabled {
|
||||
t.Error("Reconnect.Enabled should default to false")
|
||||
}
|
||||
if player.config.Reconnect.InitialDelay != 0 {
|
||||
t.Error("InitialDelay should not be populated when Reconnect.Enabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPlayer_ReconnectRediscoverCallbackStored confirms the closure
|
||||
// survives into player.config so the reconnect supervisor can call it.
|
||||
func TestNewPlayer_ReconnectRediscoverCallbackStored(t *testing.T) {
|
||||
called := false
|
||||
player, _ := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
Reconnect: ReconnectConfig{
|
||||
Enabled: true,
|
||||
Rediscover: func(ctx context.Context) (string, error) {
|
||||
called = true
|
||||
return "other:1234", nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if player.config.Reconnect.Rediscover == nil {
|
||||
t.Fatal("Rediscover callback not stored")
|
||||
}
|
||||
addr, err := player.config.Reconnect.Rediscover(context.Background())
|
||||
if err != nil || addr != "other:1234" || !called {
|
||||
t.Errorf("callback not invoked correctly: addr=%q err=%v called=%v", addr, err, called)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJitter stays inside the ±frac band. With frac=0.2 and a 1s delay,
|
||||
// the result must always fall within [800ms, 1200ms].
|
||||
func TestJitter(t *testing.T) {
|
||||
base := 1 * time.Second
|
||||
for i := 0; i < 100; i++ {
|
||||
got := jitter(base, 0.2)
|
||||
if got < 800*time.Millisecond || got > 1200*time.Millisecond {
|
||||
t.Errorf("jitter(%v, 0.2) = %v, outside ±20%% band", base, got)
|
||||
}
|
||||
}
|
||||
if jitter(0, 0.2) != 0 {
|
||||
t.Error("jitter(0, _) should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayer_StatusBeforeConnect(t *testing.T) {
|
||||
player, _ := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test Player",
|
||||
Volume: 80,
|
||||
})
|
||||
|
||||
status := player.Status()
|
||||
if status.Volume != 80 {
|
||||
t.Errorf("expected volume 80, got %d", status.Volume)
|
||||
}
|
||||
if status.Connected {
|
||||
t.Error("expected not connected before Connect()")
|
||||
}
|
||||
if status.State != "idle" {
|
||||
t.Errorf("expected state idle, got %s", status.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe asserts replace
|
||||
// semantics: if the user has set either MaxSampleRate or MaxBitDepth,
|
||||
// ensureCapsResolved must NOT overwrite the other field via probe.
|
||||
// Explicit user choice wins entirely, even when only one of the two
|
||||
// fields is non-zero.
|
||||
func TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rate int
|
||||
depth int
|
||||
wantRate int
|
||||
wantDepth int
|
||||
}{
|
||||
{"both set", 48000, 16, 48000, 16},
|
||||
{"rate only", 96000, 0, 96000, 0},
|
||||
{"depth only", 0, 16, 0, 16},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
player, _ := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
MaxSampleRate: tt.rate,
|
||||
MaxBitDepth: tt.depth,
|
||||
})
|
||||
player.ensureCapsResolved()
|
||||
if player.config.MaxSampleRate != tt.wantRate {
|
||||
t.Errorf("MaxSampleRate = %d, want %d (probe must not run)",
|
||||
player.config.MaxSampleRate, tt.wantRate)
|
||||
}
|
||||
if player.config.MaxBitDepth != tt.wantDepth {
|
||||
t.Errorf("MaxBitDepth = %d, want %d (probe must not run)",
|
||||
player.config.MaxBitDepth, tt.wantDepth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayer_EnsureCapsResolved_RunsOnce guards reconnect behavior: the
|
||||
// caps are probed at most once. The cached flag is set even on probe-skip
|
||||
// paths so subsequent reconnects don't re-probe miniaudio either.
|
||||
func TestPlayer_EnsureCapsResolved_RunsOnce(t *testing.T) {
|
||||
player, _ := NewPlayer(PlayerConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
MaxSampleRate: 48000, // user-set, so no probe
|
||||
})
|
||||
player.ensureCapsResolved()
|
||||
if !player.capsResolved {
|
||||
t.Error("capsResolved should be true after first call")
|
||||
}
|
||||
// Mutating MaxSampleRate after first call must not be re-resolved by a
|
||||
// second invocation — proves the early return on capsResolved fires.
|
||||
player.config.MaxSampleRate = 99999
|
||||
player.ensureCapsResolved()
|
||||
if player.config.MaxSampleRate != 99999 {
|
||||
t.Errorf("second call should be no-op; got MaxSampleRate=%d",
|
||||
player.config.MaxSampleRate)
|
||||
}
|
||||
}
|
||||
823
third_party/sendspin-go/pkg/sendspin/receiver.go
vendored
Normal file
823
third_party/sendspin-go/pkg/sendspin/receiver.go
vendored
Normal file
@@ -0,0 +1,823 @@
|
||||
// ABOUTME: Receiver handles connection, sync, decode, and scheduling
|
||||
// ABOUTME: Emits decoded audio.Buffer via Output() channel for consumers
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
)
|
||||
|
||||
// Time-sync burst parameters. Mirrors sendspin-cpp's TimeBurst defaults and
|
||||
// the upstream Sendspin/time-filter README "Recommended Usage" guidance: a
|
||||
// short burst of NTP-style exchanges, each waiting for its reply, with the
|
||||
// best (lowest RTT) sample fed to the filter once per burst.
|
||||
const (
|
||||
timeSyncBurstSize = 8
|
||||
timeSyncBurstInterval = 10 * time.Second
|
||||
timeSyncResponseTimeout = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// metadataApplyTickInterval is the cadence at which metadataApplyLoop wakes
|
||||
// to drain pending updates whose server timestamp has elapsed. 100 ms is
|
||||
// imperceptible for metadata display lag; do not shorten without a real
|
||||
// reason.
|
||||
const metadataApplyTickInterval = 100 * time.Millisecond
|
||||
|
||||
type ReceiverConfig struct {
|
||||
ServerAddr string
|
||||
PlayerName string
|
||||
BufferMs int
|
||||
StaticDelayMs int // optional static latency compensation (ms) applied to every scheduled play time
|
||||
PreferredCodec string // "pcm", "opus", or "flac" — reorders the advertised format list so the server picks this codec first
|
||||
BufferCapacity int // buffer_capacity in bytes advertised to the server (default: 1048576 = 1MB)
|
||||
// MaxSampleRate caps the highest SampleRate advertised to the server.
|
||||
// 0 = no cap. Set this when the eventual audio output device cannot
|
||||
// sustain higher rates (e.g. Pi3 onboard bcm2835 headphones can't
|
||||
// actually drain 192k even though ALSA reports it accepts the format).
|
||||
MaxSampleRate int
|
||||
// MaxBitDepth caps the highest BitDepth advertised to the server.
|
||||
// 0 = no cap. See MaxSampleRate for the motivating case.
|
||||
MaxBitDepth int
|
||||
// ClientID is the already-resolved client_id to advertise in client/hello.
|
||||
// Required — callers should compute this once at startup (typically via
|
||||
// ResolveClientID) and thread the same value through reconnects.
|
||||
ClientID string
|
||||
DeviceInfo DeviceInfo
|
||||
DecoderFactory func(audio.Format) (decode.Decoder, error)
|
||||
// OnMetadata is invoked after each server metadata update is merged
|
||||
// onto the running snapshot. It may be called from either the
|
||||
// server-state reader goroutine (immediate updates) or the metadata
|
||||
// apply-loop goroutine (timestamp-deferred updates), and is invoked
|
||||
// while an internal mutex is held — callbacks must not block on
|
||||
// other Receiver methods. Implementations should serialize their
|
||||
// own state if needed.
|
||||
OnMetadata func(Metadata)
|
||||
OnStreamStart func(audio.Format)
|
||||
OnStreamEnd func()
|
||||
OnError func(error)
|
||||
// OnControl is invoked for each server/command (volume, mute) received
|
||||
// from the server. Runs on a dedicated goroutine; must not block.
|
||||
OnControl func(protocol.PlayerCommand)
|
||||
}
|
||||
|
||||
type ReceiverStats struct {
|
||||
Received int64
|
||||
Played int64
|
||||
Dropped int64
|
||||
BufferDepth int
|
||||
SyncRTT int64
|
||||
SyncQuality sync.Quality
|
||||
}
|
||||
|
||||
// Receiver handles connection, clock sync, decoding, and scheduling.
|
||||
// It emits decoded, time-stamped audio buffers via the Output() channel.
|
||||
type Receiver struct {
|
||||
config ReceiverConfig
|
||||
client *protocol.Client
|
||||
clockSync *sync.ClockSync
|
||||
scheduler *Scheduler
|
||||
decoder decode.Decoder
|
||||
format audio.Format
|
||||
output chan audio.Buffer
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
schedulerCtx context.Context
|
||||
schedulerCancel context.CancelFunc
|
||||
serverAddr string
|
||||
connected bool
|
||||
|
||||
// Metadata merge state. mergedMetadata is the running snapshot fed to
|
||||
// OnMetadata; pendingMetadata holds future-dated updates sorted by
|
||||
// ascending Timestamp until clockNow() crosses each one.
|
||||
metadataMu stdsync.Mutex
|
||||
mergedMetadata Metadata
|
||||
pendingMetadata []*protocol.MetadataState
|
||||
|
||||
// clockNow returns "current server time in microseconds". Indirected
|
||||
// from r.clockSync.ServerMicrosNow so tests can drive the
|
||||
// timestamp-deferral path with a fake clock.
|
||||
clockNow func() int64
|
||||
}
|
||||
|
||||
// NewReceiver creates a new Receiver with the given configuration.
|
||||
// ServerAddr is required; other fields have defaults.
|
||||
func NewReceiver(config ReceiverConfig) (*Receiver, error) {
|
||||
if config.ServerAddr == "" {
|
||||
return nil, fmt.Errorf("ReceiverConfig.ServerAddr is required")
|
||||
}
|
||||
|
||||
if config.BufferMs == 0 {
|
||||
config.BufferMs = 500
|
||||
}
|
||||
if config.BufferCapacity == 0 {
|
||||
config.BufferCapacity = 1048576 // 1MB default
|
||||
}
|
||||
if config.DeviceInfo.ProductName == "" {
|
||||
config.DeviceInfo.ProductName = "Sendspin Player"
|
||||
}
|
||||
if config.DeviceInfo.Manufacturer == "" {
|
||||
config.DeviceInfo.Manufacturer = "Sendspin"
|
||||
}
|
||||
if config.DeviceInfo.SoftwareVersion == "" {
|
||||
config.DeviceInfo.SoftwareVersion = "1.3.0"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
clockSync := sync.NewClockSync()
|
||||
|
||||
r := &Receiver{
|
||||
config: config,
|
||||
clockSync: clockSync,
|
||||
output: make(chan audio.Buffer, 10),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
serverAddr: config.ServerAddr,
|
||||
}
|
||||
r.clockNow = r.clockSync.ServerMicrosNow
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Output returns the channel that emits decoded, time-stamped audio buffers.
|
||||
func (r *Receiver) Output() <-chan audio.Buffer {
|
||||
return r.output
|
||||
}
|
||||
|
||||
// ClockSync returns the clock synchronization instance used by this Receiver.
|
||||
func (r *Receiver) ClockSync() *sync.ClockSync {
|
||||
return r.clockSync
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the receiver's context is
|
||||
// cancelled — either by Close() or by watchConnection detecting a dropped
|
||||
// protocol client. Callers can use this to implement reconnect loops.
|
||||
func (r *Receiver) Done() <-chan struct{} {
|
||||
return r.ctx.Done()
|
||||
}
|
||||
|
||||
// Stats returns current pipeline statistics from the scheduler and clock sync.
|
||||
func (r *Receiver) Stats() ReceiverStats {
|
||||
stats := ReceiverStats{}
|
||||
|
||||
if r.scheduler != nil {
|
||||
s := r.scheduler.Stats()
|
||||
stats.Received = s.Received
|
||||
stats.Played = s.Played
|
||||
stats.Dropped = s.Dropped
|
||||
stats.BufferDepth = r.scheduler.BufferDepth()
|
||||
}
|
||||
|
||||
if r.clockSync != nil {
|
||||
rtt, quality := r.clockSync.GetStats()
|
||||
stats.SyncRTT = rtt
|
||||
stats.SyncQuality = quality
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Connect establishes a connection to the server, performs initial clock sync,
|
||||
// and starts background goroutines for connection watching and clock sync.
|
||||
func (r *Receiver) Connect() error {
|
||||
if r.config.ClientID == "" {
|
||||
return fmt.Errorf("ReceiverConfig.ClientID is required (resolve via sendspin.ResolveClientID)")
|
||||
}
|
||||
|
||||
supportedFormats := buildSupportedFormats(r.config.PreferredCodec, r.config.MaxSampleRate, r.config.MaxBitDepth)
|
||||
logAdvertisedFormats(supportedFormats, r.config.MaxSampleRate, r.config.MaxBitDepth)
|
||||
|
||||
clientConfig := protocol.Config{
|
||||
ServerAddr: r.serverAddr,
|
||||
ClientID: r.config.ClientID,
|
||||
Name: r.config.PlayerName,
|
||||
Version: 1,
|
||||
DeviceInfo: protocol.DeviceInfo{
|
||||
ProductName: r.config.DeviceInfo.ProductName,
|
||||
Manufacturer: r.config.DeviceInfo.Manufacturer,
|
||||
SoftwareVersion: r.config.DeviceInfo.SoftwareVersion,
|
||||
},
|
||||
PlayerV1Support: protocol.PlayerV1Support{
|
||||
SupportedFormats: supportedFormats,
|
||||
BufferCapacity: r.config.BufferCapacity,
|
||||
SupportedCommands: []string{"volume", "mute"},
|
||||
},
|
||||
ArtworkV1Support: &protocol.ArtworkV1Support{
|
||||
Channels: []protocol.ArtworkChannel{
|
||||
{Source: "album", Format: "jpeg", MediaWidth: 600, MediaHeight: 600},
|
||||
},
|
||||
},
|
||||
VisualizerV1Support: &protocol.VisualizerV1Support{
|
||||
BufferCapacity: r.config.BufferCapacity,
|
||||
},
|
||||
}
|
||||
|
||||
r.client = protocol.NewClient(clientConfig)
|
||||
|
||||
if err := r.client.Connect(); err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Connected to server: %s", r.serverAddr)
|
||||
r.connected = true
|
||||
|
||||
if err := r.performInitialSync(); err != nil {
|
||||
log.Printf("Initial clock sync failed: %v", err)
|
||||
}
|
||||
|
||||
go r.watchConnection()
|
||||
go r.clockSyncLoop()
|
||||
go r.handleStreamStart()
|
||||
go r.handleStreamClear()
|
||||
go r.handleStreamEnd()
|
||||
go r.handleAudioChunks()
|
||||
go r.handleServerState()
|
||||
go r.handleGroupUpdates()
|
||||
go r.handleControl()
|
||||
go r.metadataApplyLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Receiver) handleStreamStart() {
|
||||
for {
|
||||
select {
|
||||
case start := <-r.client.StreamStart:
|
||||
if start.Player == nil {
|
||||
log.Printf("Received stream/start with no player info")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Stream starting: %s %dHz %dch %dbit",
|
||||
start.Player.Codec, start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth)
|
||||
|
||||
format := audio.Format{
|
||||
Codec: start.Player.Codec,
|
||||
SampleRate: start.Player.SampleRate,
|
||||
Channels: start.Player.Channels,
|
||||
BitDepth: start.Player.BitDepth,
|
||||
}
|
||||
|
||||
if start.Player.CodecHeader != "" {
|
||||
headerBytes, err := base64.StdEncoding.DecodeString(start.Player.CodecHeader)
|
||||
if err != nil {
|
||||
log.Printf("Failed to decode codec_header: %v", err)
|
||||
} else {
|
||||
format.CodecHeader = headerBytes
|
||||
}
|
||||
}
|
||||
|
||||
var decoder decode.Decoder
|
||||
var err error
|
||||
|
||||
if r.config.DecoderFactory != nil {
|
||||
decoder, err = r.config.DecoderFactory(format)
|
||||
} else {
|
||||
decoder, err = r.defaultDecoder(format)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
r.notifyError(fmt.Errorf("failed to create decoder: %w", err))
|
||||
continue
|
||||
}
|
||||
r.decoder = decoder
|
||||
r.format = format
|
||||
|
||||
if r.config.OnStreamStart != nil {
|
||||
r.config.OnStreamStart(format)
|
||||
}
|
||||
|
||||
if r.schedulerCancel != nil {
|
||||
r.schedulerCancel()
|
||||
}
|
||||
if r.scheduler != nil {
|
||||
r.scheduler.Stop()
|
||||
}
|
||||
|
||||
r.schedulerCtx, r.schedulerCancel = context.WithCancel(r.ctx)
|
||||
r.scheduler = NewScheduler(r.clockSync, r.config.BufferMs, r.config.StaticDelayMs)
|
||||
go r.scheduler.Run()
|
||||
go r.pumpSchedulerOutput(r.schedulerCtx)
|
||||
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) defaultDecoder(format audio.Format) (decode.Decoder, error) {
|
||||
switch format.Codec {
|
||||
case "pcm":
|
||||
return decode.NewPCM(format)
|
||||
case "opus":
|
||||
return decode.NewOpus(format)
|
||||
case "flac":
|
||||
return decode.NewFLAC(format)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported codec: %s", format.Codec)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleAudioChunks() {
|
||||
for {
|
||||
select {
|
||||
case chunk := <-r.client.AudioChunks:
|
||||
if r.decoder == nil || r.scheduler == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pcm, err := r.decoder.Decode(chunk.Data)
|
||||
if err != nil {
|
||||
r.notifyError(fmt.Errorf("decode error: %w", err))
|
||||
continue
|
||||
}
|
||||
if len(pcm) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
buf := audio.Buffer{
|
||||
Timestamp: chunk.Timestamp,
|
||||
Samples: pcm,
|
||||
Format: r.format,
|
||||
}
|
||||
r.scheduler.Schedule(buf)
|
||||
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) pumpSchedulerOutput(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case buf := <-r.scheduler.Output():
|
||||
select {
|
||||
case r.output <- buf:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleStreamClear() {
|
||||
for {
|
||||
select {
|
||||
case clear := <-r.client.StreamClear:
|
||||
log.Printf("Stream clear received for roles: %v", clear.Roles)
|
||||
if len(clear.Roles) == 0 || containsRole(clear.Roles, "player") {
|
||||
if r.scheduler != nil {
|
||||
r.scheduler.Clear()
|
||||
}
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleStreamEnd() {
|
||||
for {
|
||||
select {
|
||||
case end := <-r.client.StreamEnd:
|
||||
log.Printf("Stream end received for roles: %v", end.Roles)
|
||||
if len(end.Roles) == 0 || containsRole(end.Roles, "player") {
|
||||
if r.config.OnStreamEnd != nil {
|
||||
r.config.OnStreamEnd()
|
||||
}
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleServerState reads server/state messages from the protocol client
|
||||
// and feeds metadata updates into the merge layer. Non-metadata fields
|
||||
// of ServerStateMessage are not used today; if/when they grow handlers
|
||||
// they should branch off here.
|
||||
func (r *Receiver) handleServerState() {
|
||||
for {
|
||||
select {
|
||||
case state := <-r.client.ServerState:
|
||||
if state.Metadata != nil {
|
||||
r.enqueueMetadata(state.Metadata)
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueMetadata accepts a server metadata update and either applies it
|
||||
// immediately (timestamp <= current server time, or zero timestamp) or
|
||||
// queues it for future application sorted by ascending timestamp.
|
||||
//
|
||||
// Zero / negative timestamps apply immediately. Per spec, MetadataState
|
||||
// always carries a timestamp, but defending against malformed servers
|
||||
// costs nothing and keeps existing snapshot-style emitters working.
|
||||
func (r *Receiver) enqueueMetadata(m *protocol.MetadataState) {
|
||||
r.metadataMu.Lock()
|
||||
defer r.metadataMu.Unlock()
|
||||
|
||||
serverNow := r.clockNow()
|
||||
|
||||
if m.Timestamp <= 0 || m.Timestamp <= serverNow {
|
||||
r.applyMetadataLocked(m)
|
||||
return
|
||||
}
|
||||
|
||||
// Insertion-sort into pendingMetadata by ascending Timestamp.
|
||||
idx := sort.Search(len(r.pendingMetadata), func(i int) bool {
|
||||
return r.pendingMetadata[i].Timestamp >= m.Timestamp
|
||||
})
|
||||
r.pendingMetadata = append(r.pendingMetadata, nil)
|
||||
copy(r.pendingMetadata[idx+1:], r.pendingMetadata[idx:])
|
||||
r.pendingMetadata[idx] = m
|
||||
}
|
||||
|
||||
// applyMetadataLocked merges the update onto mergedMetadata per tristate
|
||||
// rules and fires OnMetadata. Caller must hold r.metadataMu.
|
||||
//
|
||||
// For each field: if the wire key was absent, preserve the prior value;
|
||||
// if present and null (pointer is nil after decode), reset to zero; if
|
||||
// present with a value, replace. The progress field is atomic per spec —
|
||||
// a non-null progress always carries all three fields, so we either take
|
||||
// TrackDuration or zero Duration.
|
||||
func (r *Receiver) applyMetadataLocked(m *protocol.MetadataState) {
|
||||
if m.HasField("title") {
|
||||
if m.Title != nil {
|
||||
r.mergedMetadata.Title = *m.Title
|
||||
} else {
|
||||
r.mergedMetadata.Title = ""
|
||||
}
|
||||
}
|
||||
if m.HasField("artist") {
|
||||
if m.Artist != nil {
|
||||
r.mergedMetadata.Artist = *m.Artist
|
||||
} else {
|
||||
r.mergedMetadata.Artist = ""
|
||||
}
|
||||
}
|
||||
if m.HasField("album") {
|
||||
if m.Album != nil {
|
||||
r.mergedMetadata.Album = *m.Album
|
||||
} else {
|
||||
r.mergedMetadata.Album = ""
|
||||
}
|
||||
}
|
||||
if m.HasField("album_artist") {
|
||||
if m.AlbumArtist != nil {
|
||||
r.mergedMetadata.AlbumArtist = *m.AlbumArtist
|
||||
} else {
|
||||
r.mergedMetadata.AlbumArtist = ""
|
||||
}
|
||||
}
|
||||
if m.HasField("artwork_url") {
|
||||
if m.ArtworkURL != nil {
|
||||
r.mergedMetadata.ArtworkURL = *m.ArtworkURL
|
||||
} else {
|
||||
r.mergedMetadata.ArtworkURL = ""
|
||||
}
|
||||
}
|
||||
if m.HasField("track") {
|
||||
if m.Track != nil {
|
||||
r.mergedMetadata.Track = *m.Track
|
||||
} else {
|
||||
r.mergedMetadata.Track = 0
|
||||
}
|
||||
}
|
||||
if m.HasField("year") {
|
||||
if m.Year != nil {
|
||||
r.mergedMetadata.Year = *m.Year
|
||||
} else {
|
||||
r.mergedMetadata.Year = 0
|
||||
}
|
||||
}
|
||||
if m.HasField("progress") {
|
||||
if m.Progress != nil {
|
||||
r.mergedMetadata.Duration = m.Progress.TrackDuration / 1000
|
||||
} else {
|
||||
r.mergedMetadata.Duration = 0
|
||||
}
|
||||
}
|
||||
|
||||
snapshot := r.mergedMetadata
|
||||
if r.config.OnMetadata != nil {
|
||||
r.config.OnMetadata(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// metadataApplyLoop drains pendingMetadata as server time crosses each
|
||||
// queued update's timestamp. Started as a goroutine in Connect.
|
||||
func (r *Receiver) metadataApplyLoop() {
|
||||
ticker := time.NewTicker(metadataApplyTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.drainPendingMetadata()
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainPendingMetadata applies every pending update whose timestamp has
|
||||
// elapsed, in ascending timestamp order. Pending is kept sorted by
|
||||
// enqueueMetadata, so we can stop at the first future-dated entry.
|
||||
func (r *Receiver) drainPendingMetadata() {
|
||||
r.metadataMu.Lock()
|
||||
defer r.metadataMu.Unlock()
|
||||
|
||||
serverNow := r.clockNow()
|
||||
applied := 0
|
||||
for _, m := range r.pendingMetadata {
|
||||
if m.Timestamp > serverNow {
|
||||
break
|
||||
}
|
||||
r.applyMetadataLocked(m)
|
||||
applied++
|
||||
}
|
||||
if applied > 0 {
|
||||
r.pendingMetadata = r.pendingMetadata[applied:]
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleGroupUpdates() {
|
||||
for {
|
||||
select {
|
||||
case update := <-r.client.GroupUpdate:
|
||||
if update.PlaybackState != nil {
|
||||
state := *update.PlaybackState
|
||||
log.Printf("Group playback state: %s", state)
|
||||
if (state == "paused" || state == "stopped") && r.scheduler != nil {
|
||||
r.scheduler.Clear()
|
||||
}
|
||||
}
|
||||
if update.GroupID != nil {
|
||||
log.Printf("Joined group: %s", *update.GroupID)
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleControl() {
|
||||
for {
|
||||
select {
|
||||
case cmd := <-r.client.ControlMsgs:
|
||||
if r.config.OnControl != nil {
|
||||
r.config.OnControl(cmd)
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchConnection monitors the protocol client and cancels the receiver context
|
||||
// if the connection is lost, ensuring all goroutines exit cleanly.
|
||||
func (r *Receiver) watchConnection() {
|
||||
select {
|
||||
case <-r.client.Done():
|
||||
log.Printf("Server connection lost, shutting down receiver")
|
||||
r.connected = false
|
||||
r.notifyError(fmt.Errorf("server connection lost"))
|
||||
r.cancel()
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// performInitialSync drives a single immediate burst so the filter has
|
||||
// multiple samples before the audio scheduler starts.
|
||||
func (r *Receiver) performInitialSync() error {
|
||||
log.Printf("Performing initial clock synchronization (burst of %d)...", timeSyncBurstSize)
|
||||
r.runTimeSyncBurst(timeSyncBurstSize)
|
||||
|
||||
rtt, quality := r.clockSync.GetStats()
|
||||
log.Printf("Initial clock sync complete: rtt=%dus, quality=%v", rtt, quality)
|
||||
return nil
|
||||
}
|
||||
|
||||
// clockSyncLoop fires a time-sync burst every timeSyncBurstInterval. The old
|
||||
// per-second single-message pattern was replaced by the burst-best strategy
|
||||
// recommended by the upstream Sendspin/time-filter README and implemented by
|
||||
// sendspin-cpp's TimeBurst — it converges faster and rejects high-RTT
|
||||
// outliers without an explicit threshold.
|
||||
func (r *Receiver) clockSyncLoop() {
|
||||
ticker := time.NewTicker(timeSyncBurstInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.runTimeSyncBurst(timeSyncBurstSize)
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runTimeSyncBurst sends `size` time messages back-to-back, each waiting for
|
||||
// its reply, tracks the sample with the lowest RTT, and feeds only that best
|
||||
// sample to the clock-sync filter at burst end. Mirrors sendspin-cpp's
|
||||
// TimeBurst loop. Strictly serial — bursts run on TCP/WebSocket where a
|
||||
// delayed earlier message also delays its successors, so parallel sends
|
||||
// would not give independent RTT measurements.
|
||||
func (r *Receiver) runTimeSyncBurst(size int) {
|
||||
// Drain any responses left over from a prior burst (e.g. a timed-out
|
||||
// reply that arrived after the per-message timeout fired). Keeps the
|
||||
// next-message recv from picking up a stale sample.
|
||||
drainLoop:
|
||||
for {
|
||||
select {
|
||||
case <-r.client.TimeSyncResp:
|
||||
default:
|
||||
break drainLoop
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
bestT1, bestT2, bestT3, bestT4 int64
|
||||
bestRTT int64 = math.MaxInt64
|
||||
valid = 0
|
||||
)
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
t1 := time.Now().UnixMicro()
|
||||
if err := r.client.SendTimeSync(t1); err != nil {
|
||||
log.Printf("Burst send %d/%d failed: %v", i+1, size, err)
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-r.client.TimeSyncResp:
|
||||
t4 := time.Now().UnixMicro()
|
||||
rtt := (t4 - resp.ClientTransmitted) - (resp.ServerTransmitted - resp.ServerReceived)
|
||||
if rtt < bestRTT {
|
||||
bestRTT = rtt
|
||||
bestT1 = resp.ClientTransmitted
|
||||
bestT2 = resp.ServerReceived
|
||||
bestT3 = resp.ServerTransmitted
|
||||
bestT4 = t4
|
||||
}
|
||||
valid++
|
||||
case <-time.After(timeSyncResponseTimeout):
|
||||
log.Printf("Burst sample %d/%d timed out", i+1, size)
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if valid == 0 {
|
||||
log.Printf("Burst produced 0 valid samples; filter not updated")
|
||||
return
|
||||
}
|
||||
|
||||
r.clockSync.ProcessSyncResponse(bestT1, bestT2, bestT3, bestT4)
|
||||
}
|
||||
|
||||
// buildSupportedFormats returns the player's advertised format list,
|
||||
// optionally filtered by maxSampleRate / maxBitDepth (0 = no cap) and
|
||||
// reordered so preferredCodec entries come first when set.
|
||||
//
|
||||
// Filter happens before reorder, so the surviving preferred-codec entries
|
||||
// stay grouped at the head. Returns an empty slice when the caps exclude
|
||||
// every format — callers can detect that and fail loudly. We do NOT
|
||||
// fabricate a fallback entry: if the user asks for caps no format can
|
||||
// satisfy, the honest response is empty, and the resulting handshake
|
||||
// failure is the right user-visible signal.
|
||||
func buildSupportedFormats(preferredCodec string, maxSampleRate, maxBitDepth int) []protocol.AudioFormat {
|
||||
allFormats := []protocol.AudioFormat{
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24},
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24},
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24},
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24},
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16},
|
||||
{Codec: "flac", Channels: 2, SampleRate: 192000, BitDepth: 24},
|
||||
{Codec: "flac", Channels: 2, SampleRate: 96000, BitDepth: 24},
|
||||
{Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 24},
|
||||
{Codec: "flac", Channels: 2, SampleRate: 44100, BitDepth: 16},
|
||||
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
}
|
||||
|
||||
filtered := make([]protocol.AudioFormat, 0, len(allFormats))
|
||||
for _, f := range allFormats {
|
||||
if maxSampleRate > 0 && f.SampleRate > maxSampleRate {
|
||||
continue
|
||||
}
|
||||
if maxBitDepth > 0 && f.BitDepth > maxBitDepth {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
|
||||
if preferredCodec == "" {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Move preferred codec formats to the front while preserving original
|
||||
// order within each group.
|
||||
preferred := make([]protocol.AudioFormat, 0, len(filtered))
|
||||
rest := make([]protocol.AudioFormat, 0, len(filtered))
|
||||
for _, f := range filtered {
|
||||
if f.Codec == preferredCodec {
|
||||
preferred = append(preferred, f)
|
||||
} else {
|
||||
rest = append(rest, f)
|
||||
}
|
||||
}
|
||||
return append(preferred, rest...)
|
||||
}
|
||||
|
||||
// logAdvertisedFormats writes one summary line describing what the player
|
||||
// is about to send in client/hello — codec set, max rate, max depth, and
|
||||
// the cap that produced the list. Operators reading the log can answer
|
||||
// "what did this player say it could do?" without having to inspect server
|
||||
// traces.
|
||||
//
|
||||
// Empty list goes out as a WARNING: the resulting handshake produces only
|
||||
// a generic negotiation failure, so surfacing the cause player-side saves
|
||||
// users from chasing the same symptom on the server.
|
||||
func logAdvertisedFormats(formats []protocol.AudioFormat, maxSampleRate, maxBitDepth int) {
|
||||
capDesc := "no cap"
|
||||
if maxSampleRate > 0 || maxBitDepth > 0 {
|
||||
capDesc = fmt.Sprintf("cap %dHz/%d-bit", maxSampleRate, maxBitDepth)
|
||||
}
|
||||
if len(formats) == 0 {
|
||||
log.Printf("WARNING: advertising 0 supported formats (%s) — handshake will fail; relax the caps", capDesc)
|
||||
return
|
||||
}
|
||||
codecsSeen := make(map[string]struct{}, 3)
|
||||
codecsOrdered := make([]string, 0, 3)
|
||||
var maxRate, maxDepth int
|
||||
for _, f := range formats {
|
||||
if _, ok := codecsSeen[f.Codec]; !ok {
|
||||
codecsSeen[f.Codec] = struct{}{}
|
||||
codecsOrdered = append(codecsOrdered, f.Codec)
|
||||
}
|
||||
if f.SampleRate > maxRate {
|
||||
maxRate = f.SampleRate
|
||||
}
|
||||
if f.BitDepth > maxDepth {
|
||||
maxDepth = f.BitDepth
|
||||
}
|
||||
}
|
||||
log.Printf("Advertising %d supported formats: codecs=[%s] max=%dHz/%d-bit (%s)",
|
||||
len(formats), strings.Join(codecsOrdered, ","), maxRate, maxDepth, capDesc)
|
||||
}
|
||||
|
||||
func (r *Receiver) notifyError(err error) {
|
||||
if r.config.OnError != nil {
|
||||
r.config.OnError(err)
|
||||
} else {
|
||||
log.Printf("Receiver error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) Close() error {
|
||||
// Send goodbye BEFORE cancelling the context so the message
|
||||
// reaches the server while the connection is still alive.
|
||||
if r.client != nil {
|
||||
r.client.SendGoodbye("shutdown")
|
||||
}
|
||||
|
||||
r.cancel()
|
||||
|
||||
if r.client != nil {
|
||||
r.client.Close()
|
||||
}
|
||||
|
||||
if r.scheduler != nil {
|
||||
r.scheduler.Stop()
|
||||
}
|
||||
|
||||
if r.decoder != nil {
|
||||
if err := r.decoder.Close(); err != nil {
|
||||
log.Printf("Receiver: decoder close error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
close(r.output)
|
||||
|
||||
return nil
|
||||
}
|
||||
595
third_party/sendspin-go/pkg/sendspin/receiver_test.go
vendored
Normal file
595
third_party/sendspin-go/pkg/sendspin/receiver_test.go
vendored
Normal file
@@ -0,0 +1,595 @@
|
||||
// ABOUTME: Tests for Receiver type construction and basic lifecycle
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestNewReceiver_Defaults(t *testing.T) {
|
||||
config := ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test Receiver",
|
||||
}
|
||||
|
||||
r, err := NewReceiver(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver failed: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if r == nil {
|
||||
t.Fatal("Expected non-nil Receiver")
|
||||
}
|
||||
|
||||
if r.clockSync == nil {
|
||||
t.Error("Expected clockSync to be initialized")
|
||||
}
|
||||
|
||||
if r.Output() == nil {
|
||||
t.Error("Expected Output() channel to be non-nil")
|
||||
}
|
||||
|
||||
if r.config.BufferMs != 500 {
|
||||
t.Errorf("Expected default BufferMs=500, got %d", r.config.BufferMs)
|
||||
}
|
||||
|
||||
if r.config.DeviceInfo.ProductName == "" {
|
||||
t.Error("Expected default DeviceInfo.ProductName to be set")
|
||||
}
|
||||
|
||||
if r.config.DeviceInfo.Manufacturer == "" {
|
||||
t.Error("Expected default DeviceInfo.Manufacturer to be set")
|
||||
}
|
||||
|
||||
if r.config.DeviceInfo.SoftwareVersion == "" {
|
||||
t.Error("Expected default DeviceInfo.SoftwareVersion to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReceiver_RequiresServerAddr(t *testing.T) {
|
||||
config := ReceiverConfig{
|
||||
PlayerName: "Test Receiver",
|
||||
// ServerAddr intentionally omitted
|
||||
}
|
||||
|
||||
r, err := NewReceiver(config)
|
||||
if err == nil {
|
||||
t.Error("Expected error when ServerAddr is empty")
|
||||
if r != nil {
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if r != nil {
|
||||
t.Error("Expected nil Receiver on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_Connect_BadAddress(t *testing.T) {
|
||||
recv, _ := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:99999",
|
||||
PlayerName: "Test",
|
||||
})
|
||||
|
||||
err := recv.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("expected connection error for bad address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_ClockSyncIsOwnInstance(t *testing.T) {
|
||||
config1 := ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Receiver One",
|
||||
}
|
||||
config2 := ReceiverConfig{
|
||||
ServerAddr: "localhost:8928",
|
||||
PlayerName: "Receiver Two",
|
||||
}
|
||||
|
||||
r1, err := NewReceiver(config1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver r1 failed: %v", err)
|
||||
}
|
||||
defer r1.Close()
|
||||
|
||||
r2, err := NewReceiver(config2)
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver r2 failed: %v", err)
|
||||
}
|
||||
defer r2.Close()
|
||||
|
||||
if r1.ClockSync() == r2.ClockSync() {
|
||||
t.Error("Expected each Receiver to have its own ClockSync instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_CloseBeforeConnect(t *testing.T) {
|
||||
recv, _ := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
})
|
||||
|
||||
// Should not panic
|
||||
err := recv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error closing unconnected receiver: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_StatsBeforeConnect(t *testing.T) {
|
||||
recv, _ := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
})
|
||||
|
||||
stats := recv.Stats()
|
||||
if stats.Received != 0 || stats.Played != 0 || stats.Dropped != 0 {
|
||||
t.Error("expected zero stats before connect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_OnStreamStartCallback(t *testing.T) {
|
||||
var receivedFormat audio.Format
|
||||
_, err := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
OnStreamStart: func(f audio.Format) {
|
||||
receivedFormat = f
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Callback is stored but not invoked until stream starts
|
||||
if receivedFormat.Codec != "" {
|
||||
t.Error("expected empty format before stream start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_CustomDecoderFactory(t *testing.T) {
|
||||
factoryCalled := false
|
||||
recv, _ := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Test",
|
||||
DecoderFactory: func(f audio.Format) (decode.Decoder, error) {
|
||||
factoryCalled = true
|
||||
return nil, fmt.Errorf("test decoder")
|
||||
},
|
||||
})
|
||||
|
||||
if recv.config.DecoderFactory == nil {
|
||||
t.Error("expected DecoderFactory to be stored")
|
||||
}
|
||||
if factoryCalled {
|
||||
t.Error("factory should not be called before connect")
|
||||
}
|
||||
}
|
||||
|
||||
// maxRateAndDepth scans formats and returns the largest sample rate and bit
|
||||
// depth present. Test helper: lets assertions describe the cap by intent
|
||||
// ("nothing above 48k/16") rather than counting list entries.
|
||||
func maxRateAndDepth(formats []protocol.AudioFormat) (int, int) {
|
||||
var maxRate, maxDepth int
|
||||
for _, f := range formats {
|
||||
if f.SampleRate > maxRate {
|
||||
maxRate = f.SampleRate
|
||||
}
|
||||
if f.BitDepth > maxDepth {
|
||||
maxDepth = f.BitDepth
|
||||
}
|
||||
}
|
||||
return maxRate, maxDepth
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_NoCaps(t *testing.T) {
|
||||
got := buildSupportedFormats("", 0, 0)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected non-empty format list with no caps")
|
||||
}
|
||||
maxRate, maxDepth := maxRateAndDepth(got)
|
||||
if maxRate != 192000 {
|
||||
t.Errorf("expected max rate 192000 with no caps, got %d", maxRate)
|
||||
}
|
||||
if maxDepth != 24 {
|
||||
t.Errorf("expected max depth 24 with no caps, got %d", maxDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_RateCap(t *testing.T) {
|
||||
got := buildSupportedFormats("", 48000, 0)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected non-empty list with 48000Hz cap")
|
||||
}
|
||||
for _, f := range got {
|
||||
if f.SampleRate > 48000 {
|
||||
t.Errorf("expected no rate > 48000, got %s @ %dHz", f.Codec, f.SampleRate)
|
||||
}
|
||||
}
|
||||
// Boundary: 48000Hz formats must survive.
|
||||
hasFortyEight := false
|
||||
for _, f := range got {
|
||||
if f.SampleRate == 48000 {
|
||||
hasFortyEight = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFortyEight {
|
||||
t.Error("expected at least one 48000Hz format to survive the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_BitDepthCap(t *testing.T) {
|
||||
got := buildSupportedFormats("", 0, 16)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected non-empty list with 16-bit cap")
|
||||
}
|
||||
for _, f := range got {
|
||||
if f.BitDepth > 16 {
|
||||
t.Errorf("expected no depth > 16, got %s @ %d-bit", f.Codec, f.BitDepth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_BothCaps(t *testing.T) {
|
||||
got := buildSupportedFormats("", 48000, 16)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected non-empty list with 48k/16 caps")
|
||||
}
|
||||
for _, f := range got {
|
||||
if f.SampleRate > 48000 || f.BitDepth > 16 {
|
||||
t.Errorf("expected no entries beyond 48000Hz/16-bit, got %s @ %dHz/%d-bit",
|
||||
f.Codec, f.SampleRate, f.BitDepth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_PreferredCodecAfterFilter(t *testing.T) {
|
||||
// Filter eliminates high-res FLAC; the survivor should still be ordered
|
||||
// with FLAC at the front when preferredCodec="flac".
|
||||
got := buildSupportedFormats("flac", 48000, 24)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected non-empty list")
|
||||
}
|
||||
if got[0].Codec != "flac" {
|
||||
t.Errorf("expected preferred codec at front, got %s", got[0].Codec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_CapsAtExactBoundary(t *testing.T) {
|
||||
// maxSampleRate=192000, maxBitDepth=24 should keep everything.
|
||||
full := buildSupportedFormats("", 0, 0)
|
||||
bounded := buildSupportedFormats("", 192000, 24)
|
||||
if len(bounded) != len(full) {
|
||||
t.Errorf("boundary cap should keep all formats: full=%d bounded=%d", len(full), len(bounded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportedFormats_AggressiveCapEmpty(t *testing.T) {
|
||||
// User asks for ≤ 8000Hz / ≤ 8-bit. Nothing in our list matches; we
|
||||
// honestly return empty rather than fabricating a fallback. The handshake
|
||||
// will fail, which is the right user-visible signal that the cap is wrong.
|
||||
got := buildSupportedFormats("", 8000, 8)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for impossible caps, got %d entries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// metadataStateWithKeys builds a MetadataState by JSON-marshaling the input
|
||||
// map and decoding through the real wire path so presentKeys is populated
|
||||
// correctly. This is the only way to construct a MetadataState whose
|
||||
// HasField returns false for unspecified keys, since presentKeys is
|
||||
// unexported. Tests that rely on tristate semantics must use this helper.
|
||||
func metadataStateWithKeys(t *testing.T, fields map[string]any) *protocol.MetadataState {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatalf("metadataStateWithKeys: marshal: %v", err)
|
||||
}
|
||||
var m protocol.MetadataState
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("metadataStateWithKeys: unmarshal: %v", err)
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
// receiverForMetadataTests builds a Receiver with a fixed fake clock and an
|
||||
// OnMetadata callback that captures every snapshot. The captured slice is
|
||||
// guarded by its own mutex because OnMetadata may be invoked from either
|
||||
// the channel reader goroutine or the apply-loop goroutine.
|
||||
type metadataCapture struct {
|
||||
mu sync.Mutex
|
||||
captured []Metadata
|
||||
}
|
||||
|
||||
func (mc *metadataCapture) record(m Metadata) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
mc.captured = append(mc.captured, m)
|
||||
}
|
||||
|
||||
func (mc *metadataCapture) latest() (Metadata, bool) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
if len(mc.captured) == 0 {
|
||||
return Metadata{}, false
|
||||
}
|
||||
return mc.captured[len(mc.captured)-1], true
|
||||
}
|
||||
|
||||
func (mc *metadataCapture) count() int {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
return len(mc.captured)
|
||||
}
|
||||
|
||||
// newMetadataReceiver returns a Receiver wired up for direct enqueue/apply
|
||||
// testing — no Connect, no protocol client, no scheduler. clockNow is
|
||||
// overridden to a fixed value so timestamp-deferral tests are deterministic.
|
||||
func newMetadataReceiver(t *testing.T, fakeNow int64, mc *metadataCapture) *Receiver {
|
||||
t.Helper()
|
||||
r, err := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Metadata Test",
|
||||
OnMetadata: mc.record,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver: %v", err)
|
||||
}
|
||||
r.clockNow = func() int64 { return fakeNow }
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
return r
|
||||
}
|
||||
|
||||
// TestReceiver_MetadataMergePreservesUnchangedFields is the headline
|
||||
// regression: a track A snapshot followed by a track B diff_update that
|
||||
// only carries `title` must not blank out artist/album. Pre-fix, the
|
||||
// receiver dereffed nil pointers and dispatched empty strings for both.
|
||||
func TestReceiver_MetadataMergePreservesUnchangedFields(t *testing.T) {
|
||||
mc := &metadataCapture{}
|
||||
r := newMetadataReceiver(t, 0, mc)
|
||||
|
||||
// Snapshot: title + artist + album set.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"title": "Song A",
|
||||
"artist": "Artist X",
|
||||
"album": "Album Y",
|
||||
}))
|
||||
|
||||
// Diff: only title changes. Artist + album omitted → must preserve.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"title": "Song B",
|
||||
}))
|
||||
|
||||
got, ok := mc.latest()
|
||||
if !ok {
|
||||
t.Fatal("OnMetadata was not invoked")
|
||||
}
|
||||
t.Logf("merged snapshot after diff: %+v", got)
|
||||
if got.Title != "Song B" {
|
||||
t.Errorf("Title = %q, want %q", got.Title, "Song B")
|
||||
}
|
||||
if got.Artist != "Artist X" {
|
||||
t.Errorf("Artist = %q, want %q (must be preserved across diff)", got.Artist, "Artist X")
|
||||
}
|
||||
if got.Album != "Album Y" {
|
||||
t.Errorf("Album = %q, want %q (must be preserved across diff)", got.Album, "Album Y")
|
||||
}
|
||||
if mc.count() != 2 {
|
||||
t.Errorf("OnMetadata invocation count = %d, want 2", mc.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiver_MetadataNullClearsField verifies the "null" leg of the
|
||||
// tristate contract: a wire-explicit null must reset the prior value.
|
||||
func TestReceiver_MetadataNullClearsField(t *testing.T) {
|
||||
mc := &metadataCapture{}
|
||||
r := newMetadataReceiver(t, 0, mc)
|
||||
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"title": "Song A",
|
||||
"artist": "Artist X",
|
||||
"artwork_url": "http://example/a.jpg",
|
||||
}))
|
||||
|
||||
// Explicit null on artwork_url and artist: must clear both.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"artist": nil,
|
||||
"artwork_url": nil,
|
||||
}))
|
||||
|
||||
got, ok := mc.latest()
|
||||
if !ok {
|
||||
t.Fatal("OnMetadata was not invoked")
|
||||
}
|
||||
t.Logf("merged snapshot after null-clear: %+v", got)
|
||||
if got.Title != "Song A" {
|
||||
t.Errorf("Title = %q, want %q (omitted, should be preserved)", got.Title, "Song A")
|
||||
}
|
||||
if got.Artist != "" {
|
||||
t.Errorf("Artist = %q, want empty (null on wire clears it)", got.Artist)
|
||||
}
|
||||
if got.ArtworkURL != "" {
|
||||
t.Errorf("ArtworkURL = %q, want empty (null on wire clears it)", got.ArtworkURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiver_MetadataFutureTimestampDeferred verifies a future-dated
|
||||
// update is held in pendingMetadata until the fake clock advances past
|
||||
// the timestamp, then drainPendingMetadata flushes it.
|
||||
func TestReceiver_MetadataFutureTimestampDeferred(t *testing.T) {
|
||||
mc := &metadataCapture{}
|
||||
// Fake clock starts at server-time = 1000 µs.
|
||||
r, err := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Defer Test",
|
||||
OnMetadata: mc.record,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
var now int64 = 1000
|
||||
r.clockNow = func() int64 { return now }
|
||||
|
||||
// Apply-now snapshot to set baseline.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"title": "Now Playing",
|
||||
"artist": "Current Artist",
|
||||
}))
|
||||
if mc.count() != 1 {
|
||||
t.Fatalf("expected 1 immediate apply, got %d", mc.count())
|
||||
}
|
||||
|
||||
// Future-dated update at server-time = 5000 µs. Should NOT apply yet.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 5000,
|
||||
"title": "Up Next",
|
||||
}))
|
||||
if mc.count() != 1 {
|
||||
t.Errorf("future-dated update applied early; OnMetadata count = %d, want 1", mc.count())
|
||||
}
|
||||
|
||||
r.metadataMu.Lock()
|
||||
pendingLen := len(r.pendingMetadata)
|
||||
r.metadataMu.Unlock()
|
||||
if pendingLen != 1 {
|
||||
t.Errorf("pendingMetadata length = %d, want 1", pendingLen)
|
||||
}
|
||||
|
||||
// Drain at clock = 4000 µs (still before timestamp): nothing applies.
|
||||
now = 4000
|
||||
r.drainPendingMetadata()
|
||||
if mc.count() != 1 {
|
||||
t.Errorf("drain at clock<timestamp applied prematurely; count = %d, want 1", mc.count())
|
||||
}
|
||||
|
||||
// Advance clock past the timestamp; drain should flush the queued update.
|
||||
now = 5500
|
||||
r.drainPendingMetadata()
|
||||
if mc.count() != 2 {
|
||||
t.Fatalf("expected drain to apply 1 deferred update; count = %d, want 2", mc.count())
|
||||
}
|
||||
|
||||
got, _ := mc.latest()
|
||||
t.Logf("post-defer-flush snapshot: %+v", got)
|
||||
if got.Title != "Up Next" {
|
||||
t.Errorf("Title = %q, want %q", got.Title, "Up Next")
|
||||
}
|
||||
// Artist was omitted in the future-dated diff → must still be the
|
||||
// baseline value from the first update.
|
||||
if got.Artist != "Current Artist" {
|
||||
t.Errorf("Artist = %q, want %q (omitted in diff, preserved)", got.Artist, "Current Artist")
|
||||
}
|
||||
|
||||
// Pending must be empty after drain.
|
||||
r.metadataMu.Lock()
|
||||
pendingLen = len(r.pendingMetadata)
|
||||
r.metadataMu.Unlock()
|
||||
if pendingLen != 0 {
|
||||
t.Errorf("pendingMetadata length after drain = %d, want 0", pendingLen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiver_MetadataPendingSortedByTimestamp confirms enqueue maintains
|
||||
// ascending-timestamp order even when callers send out-of-order updates.
|
||||
// drainPendingMetadata depends on this to short-circuit at the first
|
||||
// future-dated entry.
|
||||
func TestReceiver_MetadataPendingSortedByTimestamp(t *testing.T) {
|
||||
mc := &metadataCapture{}
|
||||
r, err := NewReceiver(ReceiverConfig{
|
||||
ServerAddr: "localhost:8927",
|
||||
PlayerName: "Sort Test",
|
||||
OnMetadata: mc.record,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewReceiver: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
r.clockNow = func() int64 { return 0 }
|
||||
|
||||
// Enqueue three future-dated updates out of order.
|
||||
for _, ts := range []int64{500, 100, 300} {
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": ts,
|
||||
"title": fmt.Sprintf("ts-%d", ts),
|
||||
}))
|
||||
}
|
||||
|
||||
r.metadataMu.Lock()
|
||||
got := make([]int64, len(r.pendingMetadata))
|
||||
for i, m := range r.pendingMetadata {
|
||||
got[i] = m.Timestamp
|
||||
}
|
||||
r.metadataMu.Unlock()
|
||||
t.Logf("pending timestamps after out-of-order enqueue: %v", got)
|
||||
|
||||
want := []int64{100, 300, 500}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("pending length = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("pending[%d].Timestamp = %d, want %d", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiver_MetadataProgressTristate exercises the progress field
|
||||
// across set / null / omitted, since it's the only nested struct in the
|
||||
// merge path. Per spec, progress is atomic — a non-null value always
|
||||
// carries TrackDuration; a null clears Duration; an omitted progress
|
||||
// preserves the prior Duration.
|
||||
func TestReceiver_MetadataProgressTristate(t *testing.T) {
|
||||
mc := &metadataCapture{}
|
||||
r := newMetadataReceiver(t, 0, mc)
|
||||
|
||||
// Set progress with track_duration = 60_000 ms (= 60s).
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"progress": map[string]any{
|
||||
"track_progress": 0,
|
||||
"track_duration": 60000,
|
||||
"playback_speed": 1000,
|
||||
},
|
||||
}))
|
||||
got, _ := mc.latest()
|
||||
if got.Duration != 60 {
|
||||
t.Errorf("Duration = %d, want 60 (60000ms / 1000)", got.Duration)
|
||||
}
|
||||
|
||||
// Diff with progress omitted → Duration preserved.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"title": "still playing",
|
||||
}))
|
||||
got, _ = mc.latest()
|
||||
if got.Duration != 60 {
|
||||
t.Errorf("Duration after omitted progress = %d, want 60 preserved", got.Duration)
|
||||
}
|
||||
|
||||
// Explicit null progress → Duration cleared.
|
||||
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
|
||||
"timestamp": 0,
|
||||
"progress": nil,
|
||||
}))
|
||||
got, _ = mc.latest()
|
||||
t.Logf("snapshot after null progress: %+v", got)
|
||||
if got.Duration != 0 {
|
||||
t.Errorf("Duration after null progress = %d, want 0", got.Duration)
|
||||
}
|
||||
}
|
||||
78
third_party/sendspin-go/pkg/sendspin/role_controller.go
vendored
Normal file
78
third_party/sendspin-go/pkg/sendspin/role_controller.go
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
// ABOUTME: ControllerGroupRole handles client/command messages
|
||||
// ABOUTME: Pushes controller capabilities to joining clients
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
// ControllerConfig configures the ControllerGroupRole.
|
||||
type ControllerConfig struct {
|
||||
// SupportedCommands lists the media commands this server supports
|
||||
// (e.g., "next", "previous", "play", "pause").
|
||||
SupportedCommands []string
|
||||
|
||||
// OnCommand is called when a client sends a controller command.
|
||||
// May be nil if the caller only wants to push capabilities.
|
||||
OnCommand func(client *ServerClient, command string)
|
||||
}
|
||||
|
||||
// ControllerGroupRole handles the "controller" role family. It pushes
|
||||
// controller capabilities (supported commands) to clients that
|
||||
// advertise the controller role on join, and dispatches incoming
|
||||
// client/command payloads to an OnCommand callback.
|
||||
type ControllerGroupRole struct {
|
||||
config ControllerConfig
|
||||
}
|
||||
|
||||
// NewControllerRole creates a ControllerGroupRole with the given config.
|
||||
func NewControllerRole(config ControllerConfig) *ControllerGroupRole {
|
||||
return &ControllerGroupRole{config: config}
|
||||
}
|
||||
|
||||
func (r *ControllerGroupRole) Role() string { return "controller" }
|
||||
|
||||
// OnClientJoin sends controller state (supported commands) to clients
|
||||
// that advertise the controller role. Clients without the controller
|
||||
// role are skipped.
|
||||
func (r *ControllerGroupRole) OnClientJoin(c *ServerClient) {
|
||||
if !c.HasRole("controller") {
|
||||
return
|
||||
}
|
||||
|
||||
state := protocol.ServerStateMessage{
|
||||
Controller: &protocol.ControllerState{
|
||||
SupportedCommands: r.config.SupportedCommands,
|
||||
},
|
||||
}
|
||||
|
||||
if err := c.Send("server/state", state); err != nil {
|
||||
log.Printf("ControllerRole: failed to send state to %s: %v", c.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ControllerGroupRole) OnClientLeave(id string, name string) {}
|
||||
|
||||
// HandleMessage parses a controller command payload and invokes the
|
||||
// OnCommand callback.
|
||||
func (r *ControllerGroupRole) HandleMessage(c *ServerClient, payload json.RawMessage) error {
|
||||
var cmd struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &cmd); err != nil {
|
||||
return fmt.Errorf("invalid controller command: %w", err)
|
||||
}
|
||||
if cmd.Command == "" {
|
||||
return fmt.Errorf("controller command missing 'command' field")
|
||||
}
|
||||
|
||||
if r.config.OnCommand != nil {
|
||||
r.config.OnCommand(c, cmd.Command)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
104
third_party/sendspin-go/pkg/sendspin/role_controller_test.go
vendored
Normal file
104
third_party/sendspin-go/pkg/sendspin/role_controller_test.go
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
// ABOUTME: Tests for the ControllerGroupRole implementation
|
||||
// ABOUTME: Verifies command dispatch and controller state push on join
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControllerRole_HandleCommand(t *testing.T) {
|
||||
var received string
|
||||
var receivedClient *ServerClient
|
||||
|
||||
ctrl := NewControllerRole(ControllerConfig{
|
||||
SupportedCommands: []string{"next", "previous"},
|
||||
OnCommand: func(c *ServerClient, command string) {
|
||||
receivedClient = c
|
||||
received = command
|
||||
},
|
||||
})
|
||||
|
||||
sc := &ServerClient{id: "c1", sendChan: make(chan interface{}, 10)}
|
||||
payload := json.RawMessage(`{"command":"next"}`)
|
||||
|
||||
err := ctrl.HandleMessage(sc, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleMessage: %v", err)
|
||||
}
|
||||
if received != "next" {
|
||||
t.Errorf("received command = %q, want %q", received, "next")
|
||||
}
|
||||
if receivedClient == nil || receivedClient.ID() != "c1" {
|
||||
t.Error("OnCommand did not receive the correct client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRole_HandleCommandNoCallback(t *testing.T) {
|
||||
ctrl := NewControllerRole(ControllerConfig{
|
||||
SupportedCommands: []string{"next"},
|
||||
})
|
||||
|
||||
err := ctrl.HandleMessage(&ServerClient{id: "c1"}, json.RawMessage(`{"command":"next"}`))
|
||||
if err != nil {
|
||||
t.Errorf("HandleMessage without callback should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRole_HandleCommandMissingField(t *testing.T) {
|
||||
ctrl := NewControllerRole(ControllerConfig{})
|
||||
|
||||
err := ctrl.HandleMessage(&ServerClient{id: "c1"}, json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Error("HandleMessage with empty command should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRole_OnClientJoinSendsState(t *testing.T) {
|
||||
ctrl := NewControllerRole(ControllerConfig{
|
||||
SupportedCommands: []string{"next", "previous", "play", "pause"},
|
||||
})
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
roles: []string{"controller@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
ctrl.OnClientJoin(sc)
|
||||
|
||||
select {
|
||||
case msg := <-sc.sendChan:
|
||||
_ = msg // Verifies a message was enqueued
|
||||
default:
|
||||
t.Fatal("OnClientJoin did not send controller state to client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRole_OnClientJoinSkipsNonControllerClient(t *testing.T) {
|
||||
ctrl := NewControllerRole(ControllerConfig{
|
||||
SupportedCommands: []string{"next"},
|
||||
})
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
roles: []string{"player@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
ctrl.OnClientJoin(sc)
|
||||
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
t.Error("OnClientJoin should not send state to non-controller client")
|
||||
default:
|
||||
// Correct — nothing sent.
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRole_Role(t *testing.T) {
|
||||
ctrl := NewControllerRole(ControllerConfig{})
|
||||
if ctrl.Role() != "controller" {
|
||||
t.Errorf("Role() = %q, want %q", ctrl.Role(), "controller")
|
||||
}
|
||||
}
|
||||
150
third_party/sendspin-go/pkg/sendspin/role_metadata.go
vendored
Normal file
150
third_party/sendspin-go/pkg/sendspin/role_metadata.go
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
// ABOUTME: MetadataGroupRole pushes track metadata to joining clients
|
||||
// ABOUTME: Sends server/state with metadata snapshot on join, transition, or on demand
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
// MetadataConfig configures the MetadataGroupRole.
|
||||
type MetadataConfig struct {
|
||||
// GetMetadata returns the current track metadata (title, artist, album).
|
||||
GetMetadata func() (title, artist, album string)
|
||||
|
||||
// ClockMicros returns the server's current clock in microseconds.
|
||||
ClockMicros func() int64
|
||||
}
|
||||
|
||||
// MetadataGroupRole handles the "metadata" role family. It pushes a
|
||||
// server/state message with the current track metadata to clients
|
||||
// that advertise the metadata role: on join, on stopped→playing
|
||||
// transitions, and on demand via BroadcastMetadata.
|
||||
type MetadataGroupRole struct {
|
||||
config MetadataConfig
|
||||
|
||||
mu sync.RWMutex
|
||||
group *Group
|
||||
}
|
||||
|
||||
// NewMetadataRole creates a MetadataGroupRole with the given config.
|
||||
// The role must be registered on a Group via Group.RegisterRole; the
|
||||
// group reference is captured so OnPlaybackStateChanged and
|
||||
// BroadcastMetadata can iterate the group's attached clients.
|
||||
func NewMetadataRole(config MetadataConfig) *MetadataGroupRole {
|
||||
return &MetadataGroupRole{config: config}
|
||||
}
|
||||
|
||||
// attachToGroup is called by Group.RegisterRole to give the role a
|
||||
// back-reference for client iteration. Unexported because role authors
|
||||
// must register via Group.RegisterRole rather than calling this directly.
|
||||
func (r *MetadataGroupRole) attachToGroup(g *Group) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.group = g
|
||||
}
|
||||
|
||||
// attachedGroup returns the group this role was registered on, or nil
|
||||
// if it has not yet been registered.
|
||||
func (r *MetadataGroupRole) attachedGroup() *Group {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.group
|
||||
}
|
||||
|
||||
func (r *MetadataGroupRole) Role() string { return "metadata" }
|
||||
|
||||
// snapshotMessage builds the current server/state metadata snapshot.
|
||||
// Returns nil if no GetMetadata callback is configured.
|
||||
func (r *MetadataGroupRole) snapshotMessage() *protocol.ServerStateMessage {
|
||||
if r.config.GetMetadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
title, artist, album := r.config.GetMetadata()
|
||||
|
||||
var clockMicros int64
|
||||
if r.config.ClockMicros != nil {
|
||||
clockMicros = r.config.ClockMicros()
|
||||
}
|
||||
|
||||
return &protocol.ServerStateMessage{
|
||||
Metadata: &protocol.MetadataState{
|
||||
Timestamp: clockMicros,
|
||||
Title: strPtr(title),
|
||||
Artist: strPtr(artist),
|
||||
Album: strPtr(album),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// OnClientJoin sends the current metadata snapshot to clients that
|
||||
// advertise the metadata role.
|
||||
func (r *MetadataGroupRole) OnClientJoin(c *ServerClient) {
|
||||
if !c.HasRole("metadata") {
|
||||
return
|
||||
}
|
||||
|
||||
state := r.snapshotMessage()
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Send("server/state", *state); err != nil {
|
||||
log.Printf("MetadataRole: failed to send state to %s: %v", c.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MetadataGroupRole) OnClientLeave(id string, name string) {}
|
||||
|
||||
// OnPlaybackStateChanged broadcasts the current metadata snapshot to
|
||||
// every attached metadata-role client when the group transitions to
|
||||
// "playing" from a non-playing state. This closes the gap where a
|
||||
// client connects while the group is stopped (and thus receives only
|
||||
// the empty-state on join), then never sees metadata when playback
|
||||
// subsequently begins.
|
||||
//
|
||||
// Other transitions (playing→paused, playing→stopped) are intentionally
|
||||
// not re-broadcast — the existing client state is already correct for
|
||||
// "the same track is paused/stopped".
|
||||
func (r *MetadataGroupRole) OnPlaybackStateChanged(oldState, newState string) {
|
||||
if newState != "playing" || oldState == "playing" {
|
||||
return
|
||||
}
|
||||
r.broadcast()
|
||||
}
|
||||
|
||||
// BroadcastMetadata pushes the current metadata snapshot to every
|
||||
// attached metadata-role client. Use this when the server's metadata
|
||||
// source has mutated and connected clients need to see the new value
|
||||
// (e.g., HLS playlist advance, controller-driven track change).
|
||||
//
|
||||
// Safe to call concurrently with the group's own event dispatch — uses
|
||||
// Group.Clients which returns a snapshot copy.
|
||||
func (r *MetadataGroupRole) BroadcastMetadata() {
|
||||
r.broadcast()
|
||||
}
|
||||
|
||||
// broadcast sends the snapshot to every metadata-role client in the
|
||||
// attached group. Logs and continues on per-client send errors so one
|
||||
// dropped client does not stall the rest.
|
||||
func (r *MetadataGroupRole) broadcast() {
|
||||
g := r.attachedGroup()
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
state := r.snapshotMessage()
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
for _, c := range g.Clients() {
|
||||
if !c.HasRole("metadata") {
|
||||
continue
|
||||
}
|
||||
if err := c.Send("server/state", *state); err != nil {
|
||||
log.Printf("MetadataRole: broadcast to %s failed: %v", c.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
416
third_party/sendspin-go/pkg/sendspin/role_metadata_test.go
vendored
Normal file
416
third_party/sendspin-go/pkg/sendspin/role_metadata_test.go
vendored
Normal file
@@ -0,0 +1,416 @@
|
||||
// ABOUTME: Tests for the MetadataGroupRole implementation
|
||||
// ABOUTME: Verifies metadata state push to joining clients and broadcast paths
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
// drainServerStateMessage reads one queued send from a fake client's
|
||||
// sendChan and returns the decoded protocol.Message + ServerStateMessage.
|
||||
// Fails the test if nothing is queued or if the queued payload is not a
|
||||
// server/state message.
|
||||
func drainServerStateMessage(t *testing.T, sc *ServerClient) (protocol.Message, protocol.ServerStateMessage) {
|
||||
t.Helper()
|
||||
select {
|
||||
case raw := <-sc.sendChan:
|
||||
msg, ok := raw.(protocol.Message)
|
||||
if !ok {
|
||||
t.Fatalf("queued send was %T, want protocol.Message", raw)
|
||||
}
|
||||
state, ok := msg.Payload.(protocol.ServerStateMessage)
|
||||
if !ok {
|
||||
t.Fatalf("Message.Payload was %T, want protocol.ServerStateMessage", msg.Payload)
|
||||
}
|
||||
return msg, state
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Fatal("expected a server/state send, none arrived")
|
||||
return protocol.Message{}, protocol.ServerStateMessage{}
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoSend fails the test if the client received any queued send
|
||||
// within the wait window. Used to assert non-metadata clients don't get
|
||||
// broadcasts.
|
||||
func expectNoSend(t *testing.T, sc *ServerClient, wait time.Duration, label string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case raw := <-sc.sendChan:
|
||||
t.Fatalf("%s: unexpected send queued: %T %+v", label, raw, raw)
|
||||
case <-time.After(wait):
|
||||
// Expected: nothing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataRole_OnClientJoinSendsState(t *testing.T) {
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) {
|
||||
return "Track Title", "Artist Name", "Album Name"
|
||||
},
|
||||
ClockMicros: func() int64 { return 12345 },
|
||||
})
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
roles: []string{"metadata@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
meta.OnClientJoin(sc)
|
||||
|
||||
select {
|
||||
case msg := <-sc.sendChan:
|
||||
_ = msg
|
||||
default:
|
||||
t.Fatal("OnClientJoin did not send metadata state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataRole_OnClientJoinSkipsNonMetadataClient(t *testing.T) {
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
|
||||
ClockMicros: func() int64 { return 0 },
|
||||
})
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
roles: []string{"player@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
meta.OnClientJoin(sc)
|
||||
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
t.Error("should not send metadata to non-metadata client")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataRole_Role(t *testing.T) {
|
||||
meta := NewMetadataRole(MetadataConfig{})
|
||||
if meta.Role() != "metadata" {
|
||||
t.Errorf("Role() = %q, want %q", meta.Role(), "metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients
|
||||
// builds a Group with two attached clients (one with the metadata role,
|
||||
// one without), registers a MetadataGroupRole, and invokes
|
||||
// OnPlaybackStateChanged("stopped", "playing"). The metadata client must
|
||||
// receive a server/state with the expected payload; the non-metadata
|
||||
// client must not.
|
||||
func TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) {
|
||||
return "Track Title", "Artist Name", "Album Name"
|
||||
},
|
||||
ClockMicros: func() int64 { return 42 },
|
||||
})
|
||||
g.RegisterRole(meta)
|
||||
|
||||
metaClient := &ServerClient{
|
||||
id: "meta-c",
|
||||
name: "metadata client",
|
||||
roles: []string{"metadata@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
playerClient := &ServerClient{
|
||||
id: "player-c",
|
||||
name: "player client",
|
||||
roles: []string{"player@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
// Attach via addClient so they're members of g.Clients(); drain the
|
||||
// group/update that addClient sends before exercising broadcast.
|
||||
g.addClient(metaClient)
|
||||
g.addClient(playerClient)
|
||||
<-metaClient.sendChan // group/update
|
||||
<-playerClient.sendChan // group/update
|
||||
|
||||
// Drain the OnClientJoin server/state that fires on the metadata
|
||||
// client (since meta was registered before the client joined).
|
||||
// Sleep briefly so the dispatcher goroutine processes the join.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
select {
|
||||
case raw := <-metaClient.sendChan:
|
||||
t.Logf("drained join-time send: %+v", raw)
|
||||
default:
|
||||
// Already drained or never sent — fine, this test exercises the
|
||||
// broadcast path, not the join path.
|
||||
}
|
||||
|
||||
// Direct invocation — independent of event dispatch.
|
||||
meta.OnPlaybackStateChanged("stopped", "playing")
|
||||
|
||||
msg, state := drainServerStateMessage(t, metaClient)
|
||||
t.Logf("metaClient received: type=%s payload=%+v metadata=%+v", msg.Type, state, state.Metadata)
|
||||
if msg.Type != "server/state" {
|
||||
t.Errorf("Message.Type = %q, want server/state", msg.Type)
|
||||
}
|
||||
if state.Metadata == nil {
|
||||
t.Fatal("state.Metadata is nil")
|
||||
}
|
||||
if state.Metadata.Title == nil || *state.Metadata.Title != "Track Title" {
|
||||
t.Errorf("Title = %v, want \"Track Title\"", state.Metadata.Title)
|
||||
}
|
||||
if state.Metadata.Artist == nil || *state.Metadata.Artist != "Artist Name" {
|
||||
t.Errorf("Artist = %v, want \"Artist Name\"", state.Metadata.Artist)
|
||||
}
|
||||
if state.Metadata.Album == nil || *state.Metadata.Album != "Album Name" {
|
||||
t.Errorf("Album = %v, want \"Album Name\"", state.Metadata.Album)
|
||||
}
|
||||
if state.Metadata.Timestamp != 42 {
|
||||
t.Errorf("Timestamp = %d, want 42", state.Metadata.Timestamp)
|
||||
}
|
||||
|
||||
expectNoSend(t, playerClient, 30*time.Millisecond, "non-metadata client")
|
||||
}
|
||||
|
||||
// TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions
|
||||
// asserts that transitions that aren't "* → playing" (excluding
|
||||
// playing→playing) do not broadcast.
|
||||
func TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
|
||||
ClockMicros: func() int64 { return 0 },
|
||||
})
|
||||
g.RegisterRole(meta)
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "meta-c",
|
||||
roles: []string{"metadata@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
g.addClient(sc)
|
||||
<-sc.sendChan // group/update
|
||||
|
||||
// Drain any OnClientJoin send that landed.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
default:
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
oldState, newState string
|
||||
}{
|
||||
{"playing", "paused"},
|
||||
{"playing", "stopped"},
|
||||
{"stopped", "paused"},
|
||||
{"playing", "playing"}, // would have been filtered by Group; pin role-level guard too
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Logf("trying transition %s -> %s (must not broadcast)", tc.oldState, tc.newState)
|
||||
meta.OnPlaybackStateChanged(tc.oldState, tc.newState)
|
||||
expectNoSend(t, sc, 30*time.Millisecond, tc.oldState+"->"+tc.newState)
|
||||
}
|
||||
|
||||
// Sanity: stopped -> playing IS broadcast.
|
||||
t.Log("sanity: stopped -> playing must broadcast")
|
||||
meta.OnPlaybackStateChanged("stopped", "playing")
|
||||
msg, state := drainServerStateMessage(t, sc)
|
||||
t.Logf("sanity broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata)
|
||||
}
|
||||
|
||||
// TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState confirms
|
||||
// that the public BroadcastMetadata method pushes the snapshot to every
|
||||
// metadata-role client unconditionally.
|
||||
func TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) {
|
||||
return "Updated Title", "Updated Artist", "Updated Album"
|
||||
},
|
||||
ClockMicros: func() int64 { return 100 },
|
||||
})
|
||||
g.RegisterRole(meta)
|
||||
|
||||
sc1 := &ServerClient{
|
||||
id: "meta-1",
|
||||
roles: []string{"metadata@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
sc2 := &ServerClient{
|
||||
id: "meta-2",
|
||||
roles: []string{"metadata@v2"}, // versioned form must still match
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
scPlayer := &ServerClient{
|
||||
id: "player-1",
|
||||
roles: []string{"player@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
g.addClient(sc1)
|
||||
g.addClient(sc2)
|
||||
g.addClient(scPlayer)
|
||||
<-sc1.sendChan
|
||||
<-sc2.sendChan
|
||||
<-scPlayer.sendChan
|
||||
|
||||
// Drain join-time server/state on the metadata clients.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
for _, sc := range []*ServerClient{sc1, sc2} {
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
meta.BroadcastMetadata()
|
||||
|
||||
msg1, state1 := drainServerStateMessage(t, sc1)
|
||||
t.Logf("sc1 (metadata@v1) received: type=%s metadata=%+v", msg1.Type, state1.Metadata)
|
||||
if state1.Metadata == nil || state1.Metadata.Title == nil || *state1.Metadata.Title != "Updated Title" {
|
||||
t.Errorf("sc1: title = %v, want \"Updated Title\"", state1.Metadata)
|
||||
}
|
||||
|
||||
msg2, state2 := drainServerStateMessage(t, sc2)
|
||||
t.Logf("sc2 (metadata@v2) received: type=%s metadata=%+v", msg2.Type, state2.Metadata)
|
||||
if state2.Metadata == nil || state2.Metadata.Title == nil || *state2.Metadata.Title != "Updated Title" {
|
||||
t.Errorf("sc2: title = %v, want \"Updated Title\"", state2.Metadata)
|
||||
}
|
||||
|
||||
expectNoSend(t, scPlayer, 30*time.Millisecond, "player-only client")
|
||||
}
|
||||
|
||||
// TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent is the
|
||||
// end-to-end wiring proof: register the role on a real Group, attach a
|
||||
// metadata-role client, call g.SetPlaybackState("stopped") then
|
||||
// g.SetPlaybackState("playing"), assert the client received the broadcast
|
||||
// via the dispatcher path.
|
||||
func TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) {
|
||||
return "E2E Title", "E2E Artist", "E2E Album"
|
||||
},
|
||||
ClockMicros: func() int64 { return 7 },
|
||||
})
|
||||
g.RegisterRole(meta)
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "meta-c",
|
||||
name: "metadata client",
|
||||
roles: []string{"metadata@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
g.addClient(sc)
|
||||
<-sc.sendChan // group/update
|
||||
|
||||
// Drain join-time server/state.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
select {
|
||||
case msg := <-sc.sendChan:
|
||||
t.Logf("drained join-time send: %+v", msg)
|
||||
default:
|
||||
}
|
||||
|
||||
// Default playback state is "playing"; transition through stopped to
|
||||
// trigger an actual stopped→playing edge.
|
||||
g.SetPlaybackState("stopped")
|
||||
g.SetPlaybackState("playing")
|
||||
|
||||
// Wait for the dispatcher to deliver, then assert.
|
||||
deadline := time.After(200 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case raw := <-sc.sendChan:
|
||||
msg, ok := raw.(protocol.Message)
|
||||
if !ok {
|
||||
t.Fatalf("queued send was %T, want protocol.Message", raw)
|
||||
}
|
||||
if msg.Type != "server/state" {
|
||||
continue
|
||||
}
|
||||
state, ok := msg.Payload.(protocol.ServerStateMessage)
|
||||
if !ok {
|
||||
t.Fatalf("Message.Payload was %T, want ServerStateMessage", msg.Payload)
|
||||
}
|
||||
t.Logf("E2E broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata)
|
||||
if state.Metadata == nil || state.Metadata.Title == nil || *state.Metadata.Title != "E2E Title" {
|
||||
t.Errorf("E2E broadcast title = %v, want \"E2E Title\"", state.Metadata)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for E2E broadcast via SetPlaybackState")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe runs many
|
||||
// parallel BroadcastMetadata calls and asserts no panics, no races, and
|
||||
// every call lands at least one send on the metadata client. The fake
|
||||
// client's sendChan has capacity 200; the test bounds itself to fewer
|
||||
// broadcasts so we don't need to drain in parallel.
|
||||
func TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
meta := NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
|
||||
ClockMicros: func() int64 { return 0 },
|
||||
})
|
||||
g.RegisterRole(meta)
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "meta-c",
|
||||
roles: []string{"metadata@v1"},
|
||||
sendChan: make(chan interface{}, 200),
|
||||
}
|
||||
g.addClient(sc)
|
||||
<-sc.sendChan
|
||||
|
||||
// Drain any join-time send.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
default:
|
||||
}
|
||||
|
||||
const callers = 10
|
||||
const perCaller = 5
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < callers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perCaller; j++ {
|
||||
meta.BroadcastMetadata()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
count := 0
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
count++
|
||||
default:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
t.Logf("concurrent broadcasts queued %d sends (callers=%d, perCaller=%d)", count, callers, perCaller)
|
||||
if count == 0 {
|
||||
t.Error("expected at least one queued send from concurrent broadcasts, got 0")
|
||||
}
|
||||
}
|
||||
161
third_party/sendspin-go/pkg/sendspin/role_player.go
vendored
Normal file
161
third_party/sendspin-go/pkg/sendspin/role_player.go
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
// ABOUTME: PlayerGroupRole handles player stream setup on client join
|
||||
// ABOUTME: Codec negotiation, encoder creation, and stream/start message
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/internal/server"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
// PlayerRoleConfig configures the PlayerGroupRole.
|
||||
type PlayerRoleConfig struct {
|
||||
SampleRate int
|
||||
Channels int
|
||||
BitDepth int
|
||||
|
||||
// NewEncoder creates an Opus encoder when needed. When nil, Opus
|
||||
// clients fall back to PCM. Typically set to server.NewOpusEncoder.
|
||||
NewEncoder func(sampleRate, channels, chunkSamples int) (*server.OpusEncoder, error)
|
||||
|
||||
// NewFLACEncoder creates a FLAC encoder when needed. When nil, FLAC
|
||||
// clients fall back to PCM.
|
||||
NewFLACEncoder func(sampleRate, channels, bitDepth, blockSize int) (*server.FLACEncoder, error)
|
||||
}
|
||||
|
||||
// PlayerGroupRole handles the "player" role family. On client join it
|
||||
// negotiates a codec, creates any needed encoder/resampler, and sends
|
||||
// stream/start. The audio chunk pipeline (generateAndSendChunk) stays
|
||||
// on Server — this role only handles the per-client setup.
|
||||
type PlayerGroupRole struct {
|
||||
config PlayerRoleConfig
|
||||
}
|
||||
|
||||
// NewPlayerRole creates a PlayerGroupRole with the given config.
|
||||
func NewPlayerRole(config PlayerRoleConfig) *PlayerGroupRole {
|
||||
return &PlayerGroupRole{config: config}
|
||||
}
|
||||
|
||||
func (r *PlayerGroupRole) Role() string { return "player" }
|
||||
|
||||
// OnClientJoin negotiates a codec, creates encoder/resampler if needed,
|
||||
// and sends stream/start to clients advertising the player role.
|
||||
func (r *PlayerGroupRole) OnClientJoin(c *ServerClient) {
|
||||
if !c.HasRole("player") {
|
||||
return
|
||||
}
|
||||
|
||||
if c.capabilities != nil {
|
||||
fmts := make([]string, 0, len(c.capabilities.SupportedFormats))
|
||||
for _, f := range c.capabilities.SupportedFormats {
|
||||
fmts = append(fmts, fmt.Sprintf("%s@%dHz/%dbit/%dch", f.Codec, f.SampleRate, f.BitDepth, f.Channels))
|
||||
}
|
||||
log.Printf("Client %s advertised formats (in order): %v", c.Name(), fmts)
|
||||
} else {
|
||||
log.Printf("Client %s advertised no capabilities", c.Name())
|
||||
}
|
||||
|
||||
codec := negotiateCodec(c, r.config.SampleRate)
|
||||
|
||||
var opusEncoder *server.OpusEncoder
|
||||
var flacEncoder *server.FLACEncoder
|
||||
var resampler *audio.Resampler
|
||||
flacBitDepth := r.config.BitDepth
|
||||
|
||||
switch codec {
|
||||
case "opus":
|
||||
if r.config.SampleRate != 48000 {
|
||||
resampler = audio.NewResampler(r.config.SampleRate, 48000, r.config.Channels)
|
||||
log.Printf("Created resampler: %dHz -> 48kHz for Opus (client: %s)", r.config.SampleRate, c.Name())
|
||||
}
|
||||
|
||||
opusChunkSamples := (48000 * ChunkDurationMs) / 1000
|
||||
if r.config.NewEncoder != nil {
|
||||
encoder, err := r.config.NewEncoder(48000, r.config.Channels, opusChunkSamples)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", c.Name(), err)
|
||||
codec = "pcm"
|
||||
resampler = nil
|
||||
} else {
|
||||
opusEncoder = encoder
|
||||
}
|
||||
} else {
|
||||
log.Printf("No Opus encoder factory for %s, falling back to PCM", c.Name())
|
||||
codec = "pcm"
|
||||
resampler = nil
|
||||
}
|
||||
case "flac":
|
||||
if c.capabilities != nil {
|
||||
for _, format := range c.capabilities.SupportedFormats {
|
||||
if format.Codec == "flac" && format.SampleRate == r.config.SampleRate {
|
||||
flacBitDepth = format.BitDepth
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
chunkSamples := (r.config.SampleRate * ChunkDurationMs) / 1000
|
||||
if r.config.NewFLACEncoder != nil {
|
||||
encoder, err := r.config.NewFLACEncoder(r.config.SampleRate, r.config.Channels, flacBitDepth, chunkSamples)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create FLAC encoder for %s, falling back to PCM: %v", c.Name(), err)
|
||||
codec = "pcm"
|
||||
} else {
|
||||
flacEncoder = encoder
|
||||
log.Printf("FLAC encoder for %s: %dHz/%dbit/%dch", c.Name(), r.config.SampleRate, flacBitDepth, r.config.Channels)
|
||||
}
|
||||
} else {
|
||||
log.Printf("No FLAC encoder factory for %s, falling back to PCM", c.Name())
|
||||
codec = "pcm"
|
||||
}
|
||||
}
|
||||
|
||||
// Create buffer tracker from client's advertised buffer_capacity.
|
||||
bufferCapacity := 1048576 // 1MB default
|
||||
if c.capabilities != nil && c.capabilities.BufferCapacity > 0 {
|
||||
bufferCapacity = c.capabilities.BufferCapacity
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.codec = codec
|
||||
c.opusEncoder = opusEncoder
|
||||
c.flacEncoder = flacEncoder
|
||||
c.resampler = resampler
|
||||
c.bufferTracker = NewBufferTracker(bufferCapacity)
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("Added client %s with codec %s", c.Name(), codec)
|
||||
|
||||
streamSampleRate := r.config.SampleRate
|
||||
streamBitDepth := r.config.BitDepth
|
||||
if codec == "opus" {
|
||||
streamSampleRate = 48000
|
||||
streamBitDepth = 16
|
||||
} else if codec == "flac" {
|
||||
streamBitDepth = flacBitDepth
|
||||
}
|
||||
|
||||
var codecHeaderB64 string
|
||||
if codec == "flac" && flacEncoder != nil {
|
||||
codecHeaderB64 = base64.StdEncoding.EncodeToString(flacEncoder.CodecHeader())
|
||||
}
|
||||
|
||||
streamStart := protocol.StreamStart{
|
||||
Player: &protocol.StreamStartPlayer{
|
||||
Codec: codec,
|
||||
SampleRate: streamSampleRate,
|
||||
Channels: r.config.Channels,
|
||||
BitDepth: streamBitDepth,
|
||||
CodecHeader: codecHeaderB64,
|
||||
},
|
||||
}
|
||||
|
||||
if err := c.Send("stream/start", streamStart); err != nil {
|
||||
log.Printf("Error sending stream/start to %s: %v", c.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PlayerGroupRole) OnClientLeave(id string, name string) {}
|
||||
70
third_party/sendspin-go/pkg/sendspin/role_player_test.go
vendored
Normal file
70
third_party/sendspin-go/pkg/sendspin/role_player_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
// ABOUTME: Tests for the PlayerGroupRole implementation
|
||||
// ABOUTME: Verifies codec negotiation, encoder setup, and stream/start on join
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestPlayerRole_OnClientJoinSendsStreamStart(t *testing.T) {
|
||||
role := NewPlayerRole(PlayerRoleConfig{
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
})
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
roles: []string{"player@v1"},
|
||||
capabilities: &protocol.PlayerV1Support{
|
||||
SupportedFormats: []protocol.AudioFormat{
|
||||
{Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24},
|
||||
},
|
||||
},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
role.OnClientJoin(sc)
|
||||
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
// stream/start was sent
|
||||
default:
|
||||
t.Fatal("OnClientJoin did not send stream/start")
|
||||
}
|
||||
|
||||
if sc.Codec() != "pcm" {
|
||||
t.Errorf("client codec = %q, want %q", sc.Codec(), "pcm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerRole_OnClientJoinSkipsNonPlayerClient(t *testing.T) {
|
||||
role := NewPlayerRole(PlayerRoleConfig{
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
})
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
roles: []string{"controller@v1"},
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
|
||||
role.OnClientJoin(sc)
|
||||
|
||||
select {
|
||||
case <-sc.sendChan:
|
||||
t.Error("should not send stream/start to non-player client")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerRole_Role(t *testing.T) {
|
||||
role := NewPlayerRole(PlayerRoleConfig{})
|
||||
if role.Role() != "player" {
|
||||
t.Errorf("Role() = %q, want %q", role.Role(), "player")
|
||||
}
|
||||
}
|
||||
229
third_party/sendspin-go/pkg/sendspin/scheduler.go
vendored
Normal file
229
third_party/sendspin-go/pkg/sendspin/scheduler.go
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
// ABOUTME: Timestamp-based playback scheduler for pkg/sendspin
|
||||
// ABOUTME: Schedules audio buffers for precise playback timing
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"log"
|
||||
gosync "sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
clockSync *sync.ClockSync
|
||||
bufferQ *BufferQueue
|
||||
bufferMu gosync.Mutex // Protects bufferQ and buffering
|
||||
output chan audio.Buffer
|
||||
jitterMs int
|
||||
staticDelay time.Duration // shifts scheduled play time forward to compensate for hardware latency
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
buffering bool
|
||||
bufferTarget int // Number of chunks to buffer before starting playback
|
||||
|
||||
received atomic.Int64
|
||||
played atomic.Int64
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
type SchedulerStats struct {
|
||||
Received int64
|
||||
Played int64
|
||||
Dropped int64
|
||||
}
|
||||
|
||||
// NewScheduler creates a playback scheduler.
|
||||
// bufferMs controls startup buffering (ms of audio to accumulate before playback).
|
||||
// staticDelayMs shifts every scheduled play time forward, compensating for
|
||||
// downstream hardware latency like Bluetooth sinks or AVRs. Zero means no shift.
|
||||
func NewScheduler(clockSync *sync.ClockSync, bufferMs int, staticDelayMs int) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Calculate buffer target from user config (bufferMs / ChunkDurationMs)
|
||||
bufferTarget := bufferMs / ChunkDurationMs
|
||||
if bufferTarget < 1 {
|
||||
bufferTarget = 1 // Minimum 1 chunk
|
||||
}
|
||||
|
||||
return &Scheduler{
|
||||
clockSync: clockSync,
|
||||
bufferQ: NewBufferQueue(),
|
||||
output: make(chan audio.Buffer, 10),
|
||||
jitterMs: bufferMs, // Store for potential future use
|
||||
staticDelay: time.Duration(staticDelayMs) * time.Millisecond,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
buffering: true,
|
||||
bufferTarget: bufferTarget,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Schedule(buf audio.Buffer) {
|
||||
buf.PlayAt = s.clockSync.ServerToLocalTime(buf.Timestamp).Add(s.staticDelay)
|
||||
|
||||
received := s.received.Add(1) - 1
|
||||
|
||||
// Sanity logs for first 5 chunks showing timing
|
||||
if received < 5 {
|
||||
serverNow := s.clockSync.ServerMicrosNow()
|
||||
diff := buf.Timestamp - serverNow
|
||||
rtt, quality := s.clockSync.GetStats()
|
||||
|
||||
log.Printf("Chunk #%d: timestamp=%dµs, serverNow=%dµs, diff=%dµs (%.1fms), rtt=%dµs, quality=%v",
|
||||
received, buf.Timestamp, serverNow, diff, float64(diff)/1000.0, rtt, quality)
|
||||
}
|
||||
|
||||
s.bufferMu.Lock()
|
||||
heap.Push(s.bufferQ, buf)
|
||||
s.bufferMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Run() {
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.processQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) processQueue() {
|
||||
s.bufferMu.Lock()
|
||||
|
||||
if s.buffering {
|
||||
if s.bufferQ.Len() >= s.bufferTarget {
|
||||
log.Printf("Startup buffering complete: %d chunks ready", s.bufferQ.Len())
|
||||
s.buffering = false
|
||||
} else {
|
||||
s.bufferMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for s.bufferQ.Len() > 0 {
|
||||
buf := s.bufferQ.Peek()
|
||||
|
||||
delay := buf.PlayAt.Sub(now)
|
||||
|
||||
if delay > 50*time.Millisecond {
|
||||
// Too early — wait for next tick
|
||||
break
|
||||
} else if delay < -50*time.Millisecond {
|
||||
// More than 50ms late: drop rather than play out of sync
|
||||
heap.Pop(s.bufferQ)
|
||||
s.dropped.Add(1)
|
||||
log.Printf("Dropped late buffer: %v late", -delay)
|
||||
} else {
|
||||
// Within ±50ms window: ready to play
|
||||
heap.Pop(s.bufferQ)
|
||||
// Unlock before sending to avoid blocking while holding lock
|
||||
s.bufferMu.Unlock()
|
||||
|
||||
select {
|
||||
case s.output <- buf:
|
||||
s.played.Add(1)
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
s.bufferMu.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
s.bufferMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Output() <-chan audio.Buffer {
|
||||
return s.output
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stats() SchedulerStats {
|
||||
return SchedulerStats{
|
||||
Received: s.received.Load(),
|
||||
Played: s.played.Load(),
|
||||
Dropped: s.dropped.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// BufferDepth returns the current buffer queue depth in milliseconds
|
||||
func (s *Scheduler) BufferDepth() int {
|
||||
s.bufferMu.Lock()
|
||||
depth := s.bufferQ.Len() * ChunkDurationMs
|
||||
s.bufferMu.Unlock()
|
||||
return depth
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
// Clear clears all buffered audio (used for seek operations)
|
||||
func (s *Scheduler) Clear() {
|
||||
s.bufferMu.Lock()
|
||||
defer s.bufferMu.Unlock()
|
||||
s.bufferQ = NewBufferQueue()
|
||||
s.buffering = true
|
||||
log.Printf("Scheduler buffers cleared, re-entering buffering mode")
|
||||
}
|
||||
|
||||
type BufferQueue struct {
|
||||
items []audio.Buffer
|
||||
}
|
||||
|
||||
func NewBufferQueue() *BufferQueue {
|
||||
q := &BufferQueue{}
|
||||
heap.Init(q)
|
||||
return q
|
||||
}
|
||||
|
||||
// Implement heap.Interface
|
||||
func (q *BufferQueue) Len() int { return len(q.items) }
|
||||
|
||||
func (q *BufferQueue) Less(i, j int) bool {
|
||||
// Bounds check to prevent crashes
|
||||
if i >= len(q.items) || j >= len(q.items) {
|
||||
return false
|
||||
}
|
||||
return q.items[i].PlayAt.Before(q.items[j].PlayAt)
|
||||
}
|
||||
|
||||
func (q *BufferQueue) Swap(i, j int) {
|
||||
// Bounds check to prevent crashes
|
||||
if i >= len(q.items) || j >= len(q.items) {
|
||||
return
|
||||
}
|
||||
q.items[i], q.items[j] = q.items[j], q.items[i]
|
||||
}
|
||||
|
||||
func (q *BufferQueue) Push(x interface{}) {
|
||||
q.items = append(q.items, x.(audio.Buffer))
|
||||
}
|
||||
|
||||
func (q *BufferQueue) Pop() interface{} {
|
||||
n := len(q.items)
|
||||
if n == 0 {
|
||||
return audio.Buffer{}
|
||||
}
|
||||
item := q.items[n-1]
|
||||
q.items = q.items[:n-1]
|
||||
return item
|
||||
}
|
||||
|
||||
func (q *BufferQueue) Peek() audio.Buffer {
|
||||
if len(q.items) == 0 {
|
||||
return audio.Buffer{}
|
||||
}
|
||||
return q.items[0]
|
||||
}
|
||||
70
third_party/sendspin-go/pkg/sendspin/scheduler_test.go
vendored
Normal file
70
third_party/sendspin-go/pkg/sendspin/scheduler_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
// ABOUTME: Tests for the playback scheduler
|
||||
// ABOUTME: Covers static-delay offset, buffer ordering, and drop semantics
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
)
|
||||
|
||||
// TestScheduler_StaticDelayShift is a regression guard for the
|
||||
// --static-delay-ms feature. With an unsynced ClockSync,
|
||||
// ServerToLocalTime returns time.Unix(0, serverTime*1000) deterministically,
|
||||
// which gives us an exact expected PlayAt we can check.
|
||||
//
|
||||
// For a 250ms static delay, a buffer whose timestamp would otherwise play
|
||||
// at local time T should actually play at T + 250ms.
|
||||
func TestScheduler_StaticDelayShift(t *testing.T) {
|
||||
const serverTimestampUs = int64(1_000_000_000) // 1000 seconds, arbitrary
|
||||
const bufferMs = 200
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
staticDelayMs int
|
||||
}{
|
||||
{"no delay", 0},
|
||||
{"250ms delay", 250},
|
||||
{"1000ms delay", 1000},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cs := sync.NewClockSync()
|
||||
sched := NewScheduler(cs, bufferMs, tc.staticDelayMs)
|
||||
defer sched.Stop()
|
||||
|
||||
buf := audio.Buffer{Timestamp: serverTimestampUs}
|
||||
sched.Schedule(buf)
|
||||
|
||||
// Schedule mutates a local copy; inspect the queue.
|
||||
sched.bufferMu.Lock()
|
||||
queued := sched.bufferQ.Peek()
|
||||
sched.bufferMu.Unlock()
|
||||
|
||||
baseline := time.Unix(0, serverTimestampUs*1000)
|
||||
want := baseline.Add(time.Duration(tc.staticDelayMs) * time.Millisecond)
|
||||
|
||||
if !queued.PlayAt.Equal(want) {
|
||||
t.Errorf("PlayAt = %v, want %v (static delay = %dms)",
|
||||
queued.PlayAt, want, tc.staticDelayMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduler_StaticDelayDefaultZero confirms that a scheduler built with
|
||||
// the old two-argument shape would still behave correctly — i.e. the delay
|
||||
// field defaults cleanly to 0. This is a belt-and-suspenders check against
|
||||
// future refactors that might forget to thread the parameter through.
|
||||
func TestScheduler_StaticDelayDefaultZero(t *testing.T) {
|
||||
cs := sync.NewClockSync()
|
||||
sched := NewScheduler(cs, 200, 0)
|
||||
defer sched.Stop()
|
||||
|
||||
if sched.staticDelay != 0 {
|
||||
t.Errorf("staticDelay with 0 arg = %v, want 0", sched.staticDelay)
|
||||
}
|
||||
}
|
||||
647
third_party/sendspin-go/pkg/sendspin/server.go
vendored
Normal file
647
third_party/sendspin-go/pkg/sendspin/server.go
vendored
Normal file
@@ -0,0 +1,647 @@
|
||||
// ABOUTME: High-level Server API for Sendspin streaming
|
||||
// ABOUTME: Wraps server components into a simple, user-friendly interface
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/internal/discovery"
|
||||
"github.com/Sendspin/sendspin-go/internal/server"
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolVersion = 1
|
||||
|
||||
// Binary message type IDs per spec (bits 7-2 for role, bits 1-0 for slot)
|
||||
// Player role: 000001xx (4-7), slot 0 = 4
|
||||
AudioChunkMessageType = 4
|
||||
|
||||
DefaultSampleRate = 192000
|
||||
DefaultChannels = 2
|
||||
DefaultBitDepth = 24
|
||||
|
||||
ChunkDurationMs = 20 // 20ms chunks
|
||||
BufferAheadMs = 500 // Send audio 500ms ahead
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
// Port to listen on (default: 8927)
|
||||
Port int
|
||||
|
||||
Name string
|
||||
|
||||
// Audio source to stream (required)
|
||||
Source AudioSource
|
||||
|
||||
// EnableMDNS enables mDNS service advertisement (default: true)
|
||||
EnableMDNS bool
|
||||
|
||||
Debug bool
|
||||
|
||||
// DiscoverClients enables server-initiated discovery: browse for
|
||||
// clients advertising _sendspin._tcp and dial out to them.
|
||||
// See https://www.sendspin-audio.com/spec/ — "server-initiated" mode.
|
||||
DiscoverClients bool
|
||||
|
||||
// SupportedRoles lists the role families this server activates.
|
||||
// When nil, defaults to ["player", "metadata"] for backward compat.
|
||||
// Roles registered via Group.RegisterRole are also activated
|
||||
// regardless of this list.
|
||||
SupportedRoles []string
|
||||
|
||||
// ClientFilter, if non-nil, is invoked after a client's hello has
|
||||
// been parsed and before the server/hello reply is sent. Returning
|
||||
// false causes the server to close the connection without joining
|
||||
// the client to the default group. Used by multi-preset servers to
|
||||
// admit only the speakers belonging to the currently active preset.
|
||||
ClientFilter func(clientID string) bool
|
||||
|
||||
// OnClientHello, if non-nil, is invoked for every parsed
|
||||
// client/hello, regardless of whether ClientFilter accepts the
|
||||
// connection. Lets the host application maintain a roster of
|
||||
// observed (clientID, name) pairs — useful when filtering rejects
|
||||
// most connections but the operator still wants to discover which
|
||||
// speakers exist on the network and what their stable IDs are.
|
||||
OnClientHello func(clientID, name string)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
config ServerConfig
|
||||
serverID string
|
||||
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
httpServer *http.Server
|
||||
mux *http.ServeMux
|
||||
boundAddr atomic.Pointer[net.TCPAddr]
|
||||
|
||||
clients map[string]*ServerClient
|
||||
clientsMu sync.RWMutex
|
||||
defaultGroup *Group
|
||||
|
||||
clockStart time.Time // monotonic microseconds origin
|
||||
|
||||
audioSource AudioSource
|
||||
consecutiveReadErrs int
|
||||
|
||||
mdnsManager *discovery.Manager
|
||||
|
||||
// server-initiated discovery dialer cancel
|
||||
dialerCancel context.CancelFunc
|
||||
|
||||
stopChan chan struct{}
|
||||
stopOnce sync.Once
|
||||
shutdownMu sync.RWMutex
|
||||
isShutdown bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
State string
|
||||
Volume int
|
||||
Muted bool
|
||||
Codec string
|
||||
}
|
||||
|
||||
func NewServer(config ServerConfig) (*Server, error) {
|
||||
// Port == 0 is honored as "let the OS pick an ephemeral port". The
|
||||
// CLI / config-file layers supply 8927 as the documented default
|
||||
// before reaching this point; library callers that omit Port get
|
||||
// ephemeral binding (and can read it back via Server.Addr after
|
||||
// Start). This is the substitution-free behavior tests rely on for
|
||||
// flake-free Port: 0 binding.
|
||||
if config.Name == "" {
|
||||
config.Name = "Sendspin Server"
|
||||
}
|
||||
if config.Source == nil {
|
||||
return nil, fmt.Errorf("audio source is required")
|
||||
}
|
||||
if config.SupportedRoles == nil {
|
||||
config.SupportedRoles = []string{"player", "metadata"}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
serverID: uuid.New().String(),
|
||||
mux: mux,
|
||||
audioSource: config.Source,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// TODO: For production, implement proper origin validation.
|
||||
// Currently permissive for local-network deployments.
|
||||
return true
|
||||
},
|
||||
},
|
||||
clients: make(map[string]*ServerClient),
|
||||
clockStart: time.Now(),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.defaultGroup = NewGroup(s.serverID)
|
||||
|
||||
s.defaultGroup.RegisterRole(NewMetadataRole(MetadataConfig{
|
||||
GetMetadata: func() (string, string, string) {
|
||||
return s.audioSource.Metadata()
|
||||
},
|
||||
ClockMicros: s.getClockMicros,
|
||||
}))
|
||||
|
||||
s.defaultGroup.RegisterRole(NewPlayerRole(PlayerRoleConfig{
|
||||
SampleRate: s.audioSource.SampleRate(),
|
||||
Channels: s.audioSource.Channels(),
|
||||
BitDepth: DefaultBitDepth,
|
||||
NewEncoder: func(sampleRate, channels, chunkSamples int) (*server.OpusEncoder, error) {
|
||||
return server.NewOpusEncoder(sampleRate, channels, chunkSamples)
|
||||
},
|
||||
NewFLACEncoder: func(sampleRate, channels, bitDepth, blockSize int) (*server.FLACEncoder, error) {
|
||||
return server.NewFLACEncoder(sampleRate, channels, bitDepth, blockSize)
|
||||
},
|
||||
}))
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
log.Printf("Server starting: %s (ID: %s)", s.config.Name, s.serverID)
|
||||
log.Printf("Audio source: %dHz/%dbit/%dch",
|
||||
s.audioSource.SampleRate(),
|
||||
DefaultBitDepth,
|
||||
s.audioSource.Channels())
|
||||
|
||||
if s.config.EnableMDNS {
|
||||
s.mdnsManager = discovery.NewManager(discovery.Config{
|
||||
ServiceName: s.config.Name,
|
||||
Port: s.config.Port,
|
||||
ServerMode: true,
|
||||
})
|
||||
|
||||
if err := s.mdnsManager.Advertise(); err != nil {
|
||||
log.Printf("Failed to start mDNS advertisement: %v", err)
|
||||
} else {
|
||||
log.Printf("mDNS advertisement started")
|
||||
}
|
||||
}
|
||||
|
||||
if s.config.DiscoverClients {
|
||||
if s.mdnsManager == nil {
|
||||
// If mDNS isn't running for advertising, start a manager just for browsing.
|
||||
s.mdnsManager = discovery.NewManager(discovery.Config{
|
||||
ServiceName: s.config.Name,
|
||||
Port: s.config.Port,
|
||||
ServerMode: true,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.mdnsManager.BrowseClients(); err != nil {
|
||||
log.Printf("Failed to start client discovery: %v", err)
|
||||
} else {
|
||||
log.Printf("Browsing for clients advertising _sendspin._tcp")
|
||||
|
||||
dialCtx, cancel := context.WithCancel(context.Background())
|
||||
s.dialerCancel = cancel
|
||||
|
||||
dialer := newClientDialer(s.mdnsManager.Clients(), func(ctx context.Context, info *discovery.ClientInfo) error {
|
||||
return dialAndHandle(ctx, info, s.handleConnection)
|
||||
})
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
dialer.run(dialCtx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
s.mux.HandleFunc("/sendspin", s.handleWebSocket)
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.streamAudio()
|
||||
}()
|
||||
|
||||
addr := fmt.Sprintf(":%d", s.config.Port)
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", addr, err)
|
||||
}
|
||||
tcpAddr, _ := listener.Addr().(*net.TCPAddr)
|
||||
s.boundAddr.Store(tcpAddr)
|
||||
log.Printf("WebSocket server listening on %s", listener.Addr())
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Handler: s.mux,
|
||||
}
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
if err := s.httpServer.Serve(listener); err != http.ErrServerClosed {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
log.Printf("Server shutting down...")
|
||||
case err := <-errChan:
|
||||
log.Printf("HTTP server error: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.shutdownMu.Lock()
|
||||
s.isShutdown = true
|
||||
s.shutdownMu.Unlock()
|
||||
|
||||
// Cancel in-flight client dials before stopping mDNS so they observe
|
||||
// context cancellation ahead of the discovery channel closing.
|
||||
if s.dialerCancel != nil {
|
||||
s.dialerCancel()
|
||||
}
|
||||
|
||||
if s.mdnsManager != nil {
|
||||
s.mdnsManager.Stop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("HTTP server shutdown error: %v", err)
|
||||
}
|
||||
|
||||
if err := s.audioSource.Close(); err != nil {
|
||||
log.Printf("Error closing audio source: %v", err)
|
||||
}
|
||||
|
||||
if s.defaultGroup != nil {
|
||||
s.defaultGroup.Close()
|
||||
}
|
||||
|
||||
s.wg.Wait()
|
||||
log.Printf("Server stopped cleanly")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopChan)
|
||||
})
|
||||
}
|
||||
|
||||
// getClockMicros returns server uptime in microseconds (monotonic, not wall clock).
|
||||
func (s *Server) getClockMicros() int64 {
|
||||
return time.Since(s.clockStart).Microseconds()
|
||||
}
|
||||
|
||||
// Group returns the server's default playback group. For M2 there is
|
||||
// exactly one implicit group per Server; the accessor exists so future
|
||||
// GroupRole implementations (M3) can subscribe to its event bus.
|
||||
func (s *Server) Group() *Group {
|
||||
return s.defaultGroup
|
||||
}
|
||||
|
||||
// Addr returns the network address the server is listening on, or nil if
|
||||
// Start has not yet bound a listener. Useful in tests that configure
|
||||
// Port: 0 (OS-assigned ephemeral port) and need to know the actual port
|
||||
// after Start runs.
|
||||
func (s *Server) Addr() net.Addr {
|
||||
if a := s.boundAddr.Load(); a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisconnectClient closes the WebSocket for the given client_id, causing
|
||||
// the per-client read loop to exit and standard teardown to run. Returns
|
||||
// true if a matching client was found.
|
||||
func (s *Server) DisconnectClient(id string) bool {
|
||||
s.clientsMu.RLock()
|
||||
c, ok := s.clients[id]
|
||||
s.clientsMu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Closing the underlying connection unblocks the conn.ReadMessage
|
||||
// loop in handleConnection, which then runs removeClient via defer.
|
||||
_ = c.conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// NotifyStreamStartAll re-runs the PlayerGroupRole join hook for every
|
||||
// connected client. Use after Resume() to send a fresh stream/start
|
||||
// (and recreate encoders) when transitioning paused → playing.
|
||||
func (s *Server) NotifyStreamStartAll() {
|
||||
if s.defaultGroup == nil {
|
||||
return
|
||||
}
|
||||
s.clientsMu.RLock()
|
||||
clients := make([]*ServerClient, 0, len(s.clients))
|
||||
for _, c := range s.clients {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
s.clientsMu.RUnlock()
|
||||
|
||||
s.defaultGroup.mu.RLock()
|
||||
roles := make([]GroupRole, 0, len(s.defaultGroup.roles))
|
||||
for _, r := range s.defaultGroup.roles {
|
||||
roles = append(roles, r)
|
||||
}
|
||||
s.defaultGroup.mu.RUnlock()
|
||||
|
||||
for _, c := range clients {
|
||||
for _, r := range roles {
|
||||
r.OnClientJoin(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyStreamEndAll sends stream/end to every connected player client.
|
||||
// Use when transitioning playing → paused so clients flush buffers and
|
||||
// stop emitting audio while the WebSocket stays open.
|
||||
func (s *Server) NotifyStreamEndAll() {
|
||||
s.notifyStreamEnd()
|
||||
}
|
||||
|
||||
func (s *Server) Clients() []ClientInfo {
|
||||
s.clientsMu.RLock()
|
||||
defer s.clientsMu.RUnlock()
|
||||
|
||||
clients := make([]ClientInfo, 0, len(s.clients))
|
||||
for _, c := range s.clients {
|
||||
clients = append(clients, ClientInfo{
|
||||
ID: c.ID(),
|
||||
Name: c.Name(),
|
||||
State: c.State(),
|
||||
Volume: c.Volume(),
|
||||
Muted: c.Muted(),
|
||||
Codec: c.Codec(),
|
||||
})
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// ErrClientFiltered is returned by handleConnection when ClientFilter
|
||||
// rejected the client_id from client/hello. The server-initiated dialer
|
||||
// uses this sentinel to distinguish "filter rejection — try again
|
||||
// later" from a successfully-handled session that should be latched.
|
||||
var ErrClientFiltered = fmt.Errorf("client rejected by ClientFilter")
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("New WebSocket connection from %s", r.RemoteAddr)
|
||||
_ = s.handleConnection(conn)
|
||||
}
|
||||
|
||||
func (s *Server) handleConnection(conn *websocket.Conn) error {
|
||||
defer conn.Close()
|
||||
conn.SetReadLimit(1 << 20) // 1MB
|
||||
|
||||
s.shutdownMu.RLock()
|
||||
if s.isShutdown {
|
||||
s.shutdownMu.RUnlock()
|
||||
log.Printf("Rejecting connection during shutdown")
|
||||
return nil
|
||||
}
|
||||
s.shutdownMu.RUnlock()
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("Error reading hello: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var msg protocol.Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Printf("Error unmarshaling message: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if msg.Type != "client/hello" {
|
||||
log.Printf("Expected client/hello, got %s", msg.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
helloData, err := json.Marshal(msg.Payload)
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling hello payload: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var hello protocol.ClientHello
|
||||
if err := json.Unmarshal(helloData, &hello); err != nil {
|
||||
log.Printf("Error unmarshaling client hello: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if hello.ClientID == "" || hello.Name == "" {
|
||||
log.Printf("Client hello missing required fields")
|
||||
return nil
|
||||
}
|
||||
if len(hello.ClientID) > 256 || len(hello.Name) > 256 || len(hello.SupportedRoles) > 20 {
|
||||
log.Printf("Client hello fields exceed size limits")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("Client hello: %s (ID: %s, Roles: %v)", hello.Name, hello.ClientID, hello.SupportedRoles)
|
||||
|
||||
if s.config.OnClientHello != nil {
|
||||
s.config.OnClientHello(hello.ClientID, hello.Name)
|
||||
}
|
||||
|
||||
if s.config.ClientFilter != nil && !s.config.ClientFilter(hello.ClientID) {
|
||||
log.Printf("Rejecting client %s (filtered out by ClientFilter)", hello.ClientID)
|
||||
return ErrClientFiltered
|
||||
}
|
||||
|
||||
c := &ServerClient{
|
||||
id: hello.ClientID,
|
||||
name: hello.Name,
|
||||
conn: conn,
|
||||
roles: hello.SupportedRoles,
|
||||
capabilities: hello.PlayerV1Support,
|
||||
state: "synchronized",
|
||||
volume: 100,
|
||||
muted: false,
|
||||
sendChan: make(chan interface{}, 100),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.clientsMu.Lock()
|
||||
if _, exists := s.clients[hello.ClientID]; exists {
|
||||
s.clientsMu.Unlock()
|
||||
log.Printf("Client ID %s already connected, rejecting duplicate", hello.ClientID)
|
||||
return nil
|
||||
}
|
||||
s.clients[c.id] = c
|
||||
s.clientsMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.removeClient(c)
|
||||
log.Printf("Client disconnected: %s", c.name)
|
||||
}()
|
||||
|
||||
activeRoles := s.activateRoles(hello.SupportedRoles)
|
||||
serverHello := protocol.ServerHello{
|
||||
ServerID: s.serverID,
|
||||
Name: s.config.Name,
|
||||
Version: ProtocolVersion,
|
||||
ActiveRoles: activeRoles,
|
||||
ConnectionReason: "playback",
|
||||
}
|
||||
|
||||
if err := c.Send("server/hello", serverHello); err != nil {
|
||||
log.Printf("Error sending server hello: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.clientWriter(c)
|
||||
}()
|
||||
|
||||
// Add to group AFTER server/hello and writer start so that:
|
||||
// 1. server/hello is the first message the client receives
|
||||
// 2. The writer goroutine is running to deliver group/update
|
||||
// (from addClient) and role-dispatched messages (stream/start,
|
||||
// server/state) via sendChan.
|
||||
s.defaultGroup.addClient(c)
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("WebSocket error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
s.handleClientMessage(c, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) clientWriter(c *ServerClient) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
const writeDeadline = 10 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.sendChan:
|
||||
switch v := msg.(type) {
|
||||
case []byte:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
|
||||
return
|
||||
}
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) removeClient(c *ServerClient) {
|
||||
c.mu.Lock()
|
||||
if c.opusEncoder != nil {
|
||||
c.opusEncoder.Close()
|
||||
c.opusEncoder = nil
|
||||
}
|
||||
if c.flacEncoder != nil {
|
||||
c.flacEncoder.Close()
|
||||
c.flacEncoder = nil
|
||||
}
|
||||
c.resampler = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
s.clientsMu.Lock()
|
||||
delete(s.clients, c.id)
|
||||
s.clientsMu.Unlock()
|
||||
|
||||
s.defaultGroup.removeClient(c)
|
||||
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
// activateRoles filters a client's advertised role list down to the roles
|
||||
// this server supports (via config + registered GroupRoles), keeping only
|
||||
// the first version of each role family so "player@v1" wins over a later
|
||||
// "player@v2" entry in the same hello.
|
||||
func (s *Server) activateRoles(supportedRoles []string) []string {
|
||||
// Build the set of role families this server supports.
|
||||
allowed := make(map[string]bool)
|
||||
for _, family := range s.config.SupportedRoles {
|
||||
allowed[family] = true
|
||||
}
|
||||
|
||||
// Roles registered on the Group are also allowed.
|
||||
if s.defaultGroup != nil {
|
||||
s.defaultGroup.mu.RLock()
|
||||
for family := range s.defaultGroup.roles {
|
||||
allowed[family] = true
|
||||
}
|
||||
s.defaultGroup.mu.RUnlock()
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
result := make([]string, 0, len(supportedRoles))
|
||||
|
||||
for _, role := range supportedRoles {
|
||||
family := role
|
||||
if idx := strings.Index(role, "@"); idx > 0 {
|
||||
family = role[:idx]
|
||||
}
|
||||
|
||||
if seen[family] {
|
||||
continue
|
||||
}
|
||||
|
||||
if allowed[family] {
|
||||
seen[family] = true
|
||||
result = append(result, role)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
240
third_party/sendspin-go/pkg/sendspin/server_client.go
vendored
Normal file
240
third_party/sendspin-go/pkg/sendspin/server_client.go
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
// ABOUTME: ServerClient represents one accepted connection on a sendspin Server
|
||||
// ABOUTME: Exposes ID/Name/Roles/Send surface for the Group/GroupRole layer
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/internal/server"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ServerClient represents one accepted WebSocket connection on a Server.
|
||||
// It owns the client's negotiated roles, capabilities, playback state,
|
||||
// per-codec encoder state, and the outbound send channel.
|
||||
//
|
||||
// Exposed accessors (ID, Name, Roles, HasRole, Send, SendBinary) are the
|
||||
// stable surface the future Group/GroupRole layer depends on. Internal
|
||||
// fields remain unexported so the server package can mutate them through
|
||||
// the mutex without leaking that detail to callers.
|
||||
type ServerClient struct {
|
||||
id string
|
||||
name string
|
||||
conn *websocket.Conn
|
||||
roles []string
|
||||
capabilities *protocol.PlayerV1Support
|
||||
|
||||
state string
|
||||
volume int
|
||||
muted bool
|
||||
|
||||
codec string
|
||||
opusEncoder *server.OpusEncoder
|
||||
flacEncoder *server.FLACEncoder
|
||||
resampler *audio.Resampler // non-nil only when source rate != 48kHz
|
||||
bufferTracker *BufferTracker
|
||||
|
||||
sendChan chan interface{}
|
||||
done chan struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ID returns the client-supplied unique identifier from client/hello.
|
||||
func (c *ServerClient) ID() string { return c.id }
|
||||
|
||||
// Name returns the human-friendly name from client/hello.
|
||||
func (c *ServerClient) Name() string { return c.name }
|
||||
|
||||
// Roles returns the client's advertised role list as a fresh slice.
|
||||
// Callers may mutate the returned slice without affecting the ServerClient.
|
||||
func (c *ServerClient) Roles() []string {
|
||||
out := make([]string, len(c.roles))
|
||||
copy(out, c.roles)
|
||||
return out
|
||||
}
|
||||
|
||||
// HasRole reports whether this client advertised a given role family.
|
||||
// It matches both exact ("player") and versioned ("player@v1") forms so
|
||||
// callers can query by family without tracking versions.
|
||||
func (c *ServerClient) HasRole(role string) bool {
|
||||
for _, r := range c.roles {
|
||||
if r == role || strings.HasPrefix(r, role+"@") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Send enqueues a typed control message for transmission. Returns an
|
||||
// error immediately if the client's send buffer is full rather than
|
||||
// blocking — callers decide whether to drop or disconnect.
|
||||
//
|
||||
// A nil return means the message was enqueued, not that it was delivered:
|
||||
// after the client's writer goroutine has exited (e.g., post-disconnect),
|
||||
// enqueued messages are dropped silently. Callers should treat this as
|
||||
// best-effort once they've observed a client leaving.
|
||||
func (c *ServerClient) Send(msgType string, payload interface{}) error {
|
||||
msg := protocol.Message{
|
||||
Type: msgType,
|
||||
Payload: payload,
|
||||
}
|
||||
select {
|
||||
case c.sendChan <- msg:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("client send buffer full")
|
||||
}
|
||||
}
|
||||
|
||||
// SendBinary enqueues a raw binary frame (e.g., an audio chunk) for
|
||||
// transmission. Same non-blocking semantics as Send.
|
||||
//
|
||||
// A nil return means the frame was enqueued, not that it was delivered:
|
||||
// after the client's writer goroutine has exited (e.g., post-disconnect),
|
||||
// enqueued frames are dropped silently. Callers should treat this as
|
||||
// best-effort once they've observed a client leaving.
|
||||
func (c *ServerClient) SendBinary(data []byte) error {
|
||||
select {
|
||||
case c.sendChan <- data:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("client send buffer full (depth=%d, cap=%d)",
|
||||
len(c.sendChan), cap(c.sendChan))
|
||||
}
|
||||
}
|
||||
|
||||
// State returns the client's current playback state ("synchronized",
|
||||
// "playing", "paused"). Safe to call concurrently with state updates.
|
||||
func (c *ServerClient) State() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.state
|
||||
}
|
||||
|
||||
// Volume returns the client's reported volume (0-100). Safe to call
|
||||
// concurrently with state updates.
|
||||
func (c *ServerClient) Volume() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.volume
|
||||
}
|
||||
|
||||
// Muted returns the client's mute state. Safe to call concurrently with
|
||||
// state updates.
|
||||
func (c *ServerClient) Muted() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.muted
|
||||
}
|
||||
|
||||
// Codec returns the currently-negotiated codec name ("pcm", "opus", "").
|
||||
// Returns the empty string before stream negotiation completes. Safe to
|
||||
// call concurrently with state updates.
|
||||
func (c *ServerClient) Codec() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.codec
|
||||
}
|
||||
|
||||
// NewServerClientFromConn wraps an existing WebSocket connection in a
|
||||
// ServerClient and starts a background writer goroutine. This is the
|
||||
// entry point for code that accepts its own WebSocket connections
|
||||
// (e.g., conformance adapters) and wants to use the typed Send/SendBinary
|
||||
// API without constructing a full Server.
|
||||
//
|
||||
// The caller is responsible for reading from the connection; the writer
|
||||
// goroutine handles outbound messages via Send/SendBinary. Call Close()
|
||||
// when done to stop the writer and release resources.
|
||||
func NewServerClientFromConn(conn *websocket.Conn, id, name string, roles []string, capabilities *protocol.PlayerV1Support) *ServerClient {
|
||||
c := &ServerClient{
|
||||
id: id,
|
||||
name: name,
|
||||
conn: conn,
|
||||
roles: roles,
|
||||
capabilities: capabilities,
|
||||
state: "synchronized",
|
||||
volume: 100,
|
||||
sendChan: make(chan interface{}, 100),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.runWriter()
|
||||
return c
|
||||
}
|
||||
|
||||
// runWriter drains the sendChan and writes messages to the WebSocket.
|
||||
// Exits when the done channel is closed (via Close). This is the
|
||||
// standalone equivalent of Server.clientWriter for ServerClients
|
||||
// created via NewServerClientFromConn.
|
||||
func (c *ServerClient) runWriter() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
const writeDeadline = 10 * time.Second
|
||||
|
||||
exit := func(reason string, err error) {
|
||||
log.Printf("runWriter %s exiting: %s: %v (queue depth %d)",
|
||||
c.name, reason, err, len(c.sendChan))
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.sendChan:
|
||||
queueDepth := len(c.sendChan)
|
||||
switch v := msg.(type) {
|
||||
case []byte:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
wStart := time.Now()
|
||||
if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
|
||||
exit("binary write", err)
|
||||
return
|
||||
}
|
||||
wDur := time.Since(wStart)
|
||||
if wDur > 5*time.Millisecond || queueDepth > 5 {
|
||||
log.Printf("ws write %s: %s for %d bytes (queue depth %d)",
|
||||
c.name, wDur.Round(time.Microsecond), len(v), queueDepth)
|
||||
}
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
exit("text write", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
|
||||
exit("ping", err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the writer goroutine and signals that this ServerClient
|
||||
// is done. Safe to call multiple times. Does NOT close the underlying
|
||||
// WebSocket connection — the caller owns that lifecycle.
|
||||
func (c *ServerClient) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
select {
|
||||
case <-c.done:
|
||||
// Already closed
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
}
|
||||
227
third_party/sendspin-go/pkg/sendspin/server_client_test.go
vendored
Normal file
227
third_party/sendspin-go/pkg/sendspin/server_client_test.go
vendored
Normal file
@@ -0,0 +1,227 @@
|
||||
// ABOUTME: Tests for the ServerClient exported accessor surface
|
||||
// ABOUTME: Guards the shape the Group/GroupRole layer will build on in M2+
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// TestServerClient_Accessors confirms that the six exported accessors on
|
||||
// ServerClient return the values the struct was constructed with. These are
|
||||
// the methods the future Group/GroupRole layer will depend on, so their
|
||||
// shape is load-bearing — adding a test now pins it.
|
||||
func TestServerClient_Accessors(t *testing.T) {
|
||||
sc := &ServerClient{
|
||||
id: "client-abc",
|
||||
name: "Living Room",
|
||||
roles: []string{"player@v1", "metadata@v1"},
|
||||
}
|
||||
|
||||
if got := sc.ID(); got != "client-abc" {
|
||||
t.Errorf("ID() = %q, want %q", got, "client-abc")
|
||||
}
|
||||
if got := sc.Name(); got != "Living Room" {
|
||||
t.Errorf("Name() = %q, want %q", got, "Living Room")
|
||||
}
|
||||
roles := sc.Roles()
|
||||
if len(roles) != 2 || roles[0] != "player@v1" || roles[1] != "metadata@v1" {
|
||||
t.Errorf("Roles() = %v, want [player@v1 metadata@v1]", roles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerClient_RolesReturnsCopy guards the defensive-copy contract.
|
||||
// Mutating the returned slice must not affect a subsequent Roles() call.
|
||||
func TestServerClient_RolesReturnsCopy(t *testing.T) {
|
||||
sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}}
|
||||
|
||||
first := sc.Roles()
|
||||
first[0] = "tampered"
|
||||
|
||||
second := sc.Roles()
|
||||
if second[0] != "player@v1" {
|
||||
t.Errorf("Roles() returned aliased slice: got %q after mutation, want %q", second[0], "player@v1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerClient_HasRole covers both exact matches and versioned matches
|
||||
// (e.g., "player" should match "player@v1"). This mirrors the existing
|
||||
// Server.hasRole behavior, which the accessor replaces.
|
||||
func TestServerClient_HasRole(t *testing.T) {
|
||||
sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}}
|
||||
|
||||
cases := []struct {
|
||||
role string
|
||||
want bool
|
||||
}{
|
||||
{"player", true},
|
||||
{"player@v1", true},
|
||||
{"metadata", true},
|
||||
{"controller", false},
|
||||
{"artwork", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := sc.HasRole(tc.role); got != tc.want {
|
||||
t.Errorf("HasRole(%q) = %v, want %v", tc.role, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerClient_SendBufferFull guards the back-pressure behavior: Send
|
||||
// must not block when the buffered sendChan is full. Returning an error
|
||||
// lets the caller decide whether to drop, log, or disconnect.
|
||||
func TestServerClient_SendBufferFull(t *testing.T) {
|
||||
sc := &ServerClient{
|
||||
sendChan: make(chan interface{}, 1),
|
||||
}
|
||||
|
||||
if err := sc.Send("server/state", map[string]string{"a": "b"}); err != nil {
|
||||
t.Fatalf("first Send should succeed, got %v", err)
|
||||
}
|
||||
if err := sc.Send("server/state", map[string]string{"c": "d"}); err == nil {
|
||||
t.Error("second Send to full buffer should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerClient_SendBinaryBufferFull is the same back-pressure check
|
||||
// for the binary path (audio chunks go through here).
|
||||
func TestServerClient_SendBinaryBufferFull(t *testing.T) {
|
||||
sc := &ServerClient{
|
||||
sendChan: make(chan interface{}, 1),
|
||||
}
|
||||
if err := sc.SendBinary([]byte{0x01}); err != nil {
|
||||
t.Fatalf("first SendBinary should succeed, got %v", err)
|
||||
}
|
||||
if err := sc.SendBinary([]byte{0x02}); err == nil {
|
||||
t.Error("second SendBinary to full buffer should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerClient_StateAccessors confirms that State/Volume/Muted/Codec
|
||||
// return the mutable playback fields under the client's RWMutex. These
|
||||
// are the fields the M2 Group event bus carries in ClientStateChangedEvent,
|
||||
// so their shape is load-bearing for M3's role handlers.
|
||||
func TestServerClient_StateAccessors(t *testing.T) {
|
||||
sc := &ServerClient{
|
||||
state: "synchronized",
|
||||
volume: 72,
|
||||
muted: true,
|
||||
codec: "opus",
|
||||
}
|
||||
|
||||
if got := sc.State(); got != "synchronized" {
|
||||
t.Errorf("State() = %q, want %q", got, "synchronized")
|
||||
}
|
||||
if got := sc.Volume(); got != 72 {
|
||||
t.Errorf("Volume() = %d, want 72", got)
|
||||
}
|
||||
if got := sc.Muted(); got != true {
|
||||
t.Errorf("Muted() = %v, want true", got)
|
||||
}
|
||||
if got := sc.Codec(); got != "opus" {
|
||||
t.Errorf("Codec() = %q, want %q", got, "opus")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerClient_StateAccessorsConcurrent is a light race-detector bait.
|
||||
// Running Write/Read in parallel under -race should flag any missing lock.
|
||||
func TestServerClient_StateAccessorsConcurrent(t *testing.T) {
|
||||
sc := &ServerClient{state: "synchronized", volume: 50}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
sc.mu.Lock()
|
||||
sc.volume = i % 100
|
||||
sc.mu.Unlock()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
_ = sc.Volume()
|
||||
_ = sc.State()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestNewServerClientFromConn_SendAndClose confirms the constructor +
|
||||
// writer + Close lifecycle works: messages enqueued via Send arrive on
|
||||
// the WebSocket, and Close stops the writer without panic.
|
||||
func TestNewServerClientFromConn_SendAndClose(t *testing.T) {
|
||||
// Use an in-process WebSocket pair via httptest.
|
||||
srv := 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()
|
||||
|
||||
// Read the one message we expect.
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("server read: %v", err)
|
||||
return
|
||||
}
|
||||
var msg protocol.Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Errorf("unmarshal: %v", err)
|
||||
return
|
||||
}
|
||||
if msg.Type != "server/hello" {
|
||||
t.Errorf("got type %q, want server/hello", msg.Type)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + srv.URL[len("http"):]
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sc := NewServerClientFromConn(conn, "test-id", "Test", []string{"player@v1"}, nil)
|
||||
|
||||
if err := sc.Send("server/hello", map[string]string{"name": "test"}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
// Give the writer time to flush.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
sc.Close()
|
||||
// Double-close should not panic.
|
||||
sc.Close()
|
||||
}
|
||||
|
||||
// TestCreateAudioChunk confirms the exported helper produces the
|
||||
// correct binary frame format.
|
||||
func TestCreateAudioChunk(t *testing.T) {
|
||||
chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB})
|
||||
if chunk[0] != AudioChunkMessageType {
|
||||
t.Errorf("type byte = %d, want %d", chunk[0], AudioChunkMessageType)
|
||||
}
|
||||
if len(chunk) != 9+2 {
|
||||
t.Errorf("len = %d, want 11", len(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateArtworkChunk confirms channel mapping and frame format.
|
||||
func TestCreateArtworkChunk(t *testing.T) {
|
||||
chunk := CreateArtworkChunk(2, 5000000, []byte{0xFF})
|
||||
expectedType := byte(protocol.ArtworkChannel0MessageType + 2)
|
||||
if chunk[0] != expectedType {
|
||||
t.Errorf("type byte = %d, want %d", chunk[0], expectedType)
|
||||
}
|
||||
if len(chunk) != protocol.BinaryMessageHeaderSize+1 {
|
||||
t.Errorf("len = %d, want %d", len(chunk), protocol.BinaryMessageHeaderSize+1)
|
||||
}
|
||||
}
|
||||
131
third_party/sendspin-go/pkg/sendspin/server_dispatch.go
vendored
Normal file
131
third_party/sendspin-go/pkg/sendspin/server_dispatch.go
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
// ABOUTME: Message dispatch for incoming client control frames
|
||||
// ABOUTME: Routes client/time, client/state, client/goodbye, client/command to handlers
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
func (s *Server) handleClientMessage(c *ServerClient, data []byte) {
|
||||
var msg protocol.Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Printf("Error unmarshaling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "client/time":
|
||||
s.handleTimeSync(c, msg.Payload)
|
||||
case "client/state":
|
||||
s.handleClientState(c, msg.Payload)
|
||||
case "client/goodbye":
|
||||
s.handleClientGoodbye(c, msg.Payload)
|
||||
case "client/command":
|
||||
s.handleClientCommand(c, msg.Payload)
|
||||
default:
|
||||
if s.config.Debug {
|
||||
log.Printf("Unknown message type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTimeSync(c *ServerClient, payload interface{}) {
|
||||
serverRecv := s.getClockMicros()
|
||||
|
||||
timeData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var clientTime protocol.ClientTime
|
||||
if err := json.Unmarshal(timeData, &clientTime); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
serverSend := s.getClockMicros()
|
||||
|
||||
response := protocol.ServerTime{
|
||||
ClientTransmitted: clientTime.ClientTransmitted,
|
||||
ServerReceived: serverRecv,
|
||||
ServerTransmitted: serverSend,
|
||||
}
|
||||
|
||||
if err := c.Send("server/time", response); err != nil {
|
||||
if s.config.Debug {
|
||||
log.Printf("Error sending server/time to %s: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleClientState applies a client's player state update per spec.
|
||||
func (s *Server) handleClientState(c *ServerClient, payload interface{}) {
|
||||
stateData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var stateMsg protocol.ClientStateMessage
|
||||
if err := json.Unmarshal(stateData, &stateMsg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if stateMsg.Player != nil {
|
||||
c.mu.Lock()
|
||||
c.state = stateMsg.Player.State
|
||||
c.volume = stateMsg.Player.Volume
|
||||
c.muted = stateMsg.Player.Muted
|
||||
c.mu.Unlock()
|
||||
|
||||
if s.config.Debug {
|
||||
log.Printf("Client %s state: %s (vol: %d, muted: %v)", c.name, stateMsg.Player.State, stateMsg.Player.Volume, stateMsg.Player.Muted)
|
||||
}
|
||||
|
||||
s.defaultGroup.publish(ClientStateChangedEvent{
|
||||
Client: c,
|
||||
State: stateMsg.Player.State,
|
||||
Volume: stateMsg.Player.Volume,
|
||||
Muted: stateMsg.Player.Muted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleClientCommand(c *ServerClient, payload interface{}) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling client/command payload: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// client/command payloads are role-keyed: {"controller": {...}, "player": {...}}
|
||||
var rolePayloads map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &rolePayloads); err != nil {
|
||||
log.Printf("Error unmarshaling client/command: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for roleFamily, roleData := range rolePayloads {
|
||||
if err := s.defaultGroup.RouteMessage(c, roleFamily, roleData); err != nil {
|
||||
if s.config.Debug {
|
||||
log.Printf("client/command routing error for role %s: %v", roleFamily, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleClientGoodbye(c *ServerClient, payload interface{}) {
|
||||
goodbyeData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var goodbye protocol.ClientGoodbye
|
||||
if err := json.Unmarshal(goodbyeData, &goodbye); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Client %s goodbye: %s", c.name, goodbye.Reason)
|
||||
// Connection close happens in handleConnection's read loop once this returns.
|
||||
}
|
||||
260
third_party/sendspin-go/pkg/sendspin/server_stream.go
vendored
Normal file
260
third_party/sendspin-go/pkg/sendspin/server_stream.go
vendored
Normal file
@@ -0,0 +1,260 @@
|
||||
// ABOUTME: Audio streaming orchestration for Server
|
||||
// ABOUTME: Tick-driven chunk generation, codec negotiation, per-client encode/send
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
)
|
||||
|
||||
func (s *Server) streamAudio() {
|
||||
log.Printf("Audio streaming started")
|
||||
|
||||
ticker := time.NewTicker(time.Duration(ChunkDurationMs) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
tickBudget := time.Duration(ChunkDurationMs) * time.Millisecond
|
||||
var lastTick time.Time
|
||||
for {
|
||||
select {
|
||||
case t := <-ticker.C:
|
||||
// Tick slip = our own scheduler is late picking up the tick
|
||||
// (Go runtime contention, GC, etc.). Anything significantly
|
||||
// past the budget means we're missing real-time deadlines
|
||||
// before audio even leaves the box.
|
||||
if !lastTick.IsZero() {
|
||||
gap := t.Sub(lastTick)
|
||||
if gap > tickBudget*2 {
|
||||
log.Printf("audio ticker slip: %s gap between ticks (budget %s)",
|
||||
gap.Round(time.Microsecond), tickBudget)
|
||||
}
|
||||
}
|
||||
lastTick = t
|
||||
|
||||
s.generateAndSendChunk()
|
||||
case <-s.stopChan:
|
||||
log.Printf("Audio streaming stopping")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) generateAndSendChunk() {
|
||||
t0 := time.Now()
|
||||
// Timestamp invariants — do not weaken without re-analysis:
|
||||
// 1. playbackTime is sampled fresh from the monotonic clock on every
|
||||
// tick. It is NOT a running counter like `pending += chunkDurationUs`.
|
||||
// 2. ChunkDurationMs × sampleRate must divide evenly by 1000 at every
|
||||
// supported rate. At 20ms this holds: 44.1k→882, 48k→960, 88.2k→1764,
|
||||
// 96k→1920. At 25ms, 44.1k→1102.5 (fractional) — do NOT change the
|
||||
// constant without also re-working the chunk-size/sample math.
|
||||
// Weakening either invariant re-introduces the drift class described in
|
||||
// aiosendspin#217 (500ms cliff after ~17 minutes at 44.1k/25ms). See also
|
||||
// issue #91 for converting the linear resampler to integer-rational math.
|
||||
currentTime := s.getClockMicros()
|
||||
playbackTime := currentTime + (BufferAheadMs * 1000)
|
||||
|
||||
chunkSamples := (s.audioSource.SampleRate() * ChunkDurationMs) / 1000
|
||||
totalSamples := chunkSamples * s.audioSource.Channels()
|
||||
|
||||
samples := make([]int32, totalSamples)
|
||||
tRead := time.Now()
|
||||
n, err := s.audioSource.Read(samples)
|
||||
readDur := time.Since(tRead)
|
||||
if err == nil && n == 0 {
|
||||
// Source has nothing to emit right now (e.g., live-source
|
||||
// idle while preset is paused). Skip without resetting the
|
||||
// error counter; the audio engine wakes up on next tick.
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.consecutiveReadErrs++
|
||||
// Log every error for the first few, then throttle
|
||||
if s.consecutiveReadErrs <= 3 || s.consecutiveReadErrs%50 == 0 {
|
||||
log.Printf("Error reading audio source (%d consecutive): %v", s.consecutiveReadErrs, err)
|
||||
}
|
||||
// After 1 second of failures (50 ticks at 20ms), notify clients
|
||||
if s.consecutiveReadErrs == 50 {
|
||||
log.Printf("Audio source failed for 1s, sending stream/end to all clients")
|
||||
s.notifyStreamEnd()
|
||||
}
|
||||
return
|
||||
}
|
||||
s.consecutiveReadErrs = 0
|
||||
|
||||
s.clientsMu.RLock()
|
||||
defer s.clientsMu.RUnlock()
|
||||
|
||||
for _, c := range s.clients {
|
||||
var audioData []byte
|
||||
var encodeErr error
|
||||
|
||||
c.mu.RLock()
|
||||
codec := c.codec
|
||||
opusEncoder := c.opusEncoder
|
||||
flacEncoder := c.flacEncoder
|
||||
resampler := c.resampler
|
||||
tracker := c.bufferTracker
|
||||
c.mu.RUnlock()
|
||||
|
||||
switch codec {
|
||||
case "opus":
|
||||
if opusEncoder != nil {
|
||||
samplesToEncode := samples[:n]
|
||||
|
||||
// Resample when source rate != 48kHz (Opus is locked to 48kHz)
|
||||
if resampler != nil {
|
||||
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
|
||||
resampled := make([]int32, outputSamples)
|
||||
samplesWritten := resampler.Resample(samplesToEncode, resampled)
|
||||
samplesToEncode = resampled[:samplesWritten]
|
||||
}
|
||||
|
||||
samples16 := convertToInt16(samplesToEncode)
|
||||
audioData, encodeErr = opusEncoder.Encode(samples16)
|
||||
if encodeErr != nil {
|
||||
log.Printf("Opus encode error for %s: %v", c.name, encodeErr)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
case "flac":
|
||||
if flacEncoder != nil {
|
||||
audioData, encodeErr = flacEncoder.Encode(samples[:n])
|
||||
if encodeErr != nil {
|
||||
log.Printf("FLAC encode error for %s: %v", c.name, encodeErr)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
case "pcm":
|
||||
audioData = encodePCM(samples[:n])
|
||||
default:
|
||||
audioData = encodePCM(samples[:n])
|
||||
}
|
||||
|
||||
chunk := CreateAudioChunk(playbackTime, audioData)
|
||||
|
||||
if tracker != nil {
|
||||
chunkDurationUs := int64(ChunkDurationMs) * 1000
|
||||
tracker.PruneConsumed(currentTime)
|
||||
if !tracker.CanSend(len(chunk), chunkDurationUs) {
|
||||
log.Printf("Buffer full for %s, skipping chunk (%d bytes buffered, %dms)",
|
||||
c.name, tracker.BufferedBytes(), tracker.BufferedDurationUs()/1000)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.SendBinary(chunk); err != nil {
|
||||
log.Printf("Error sending audio to %s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if tracker != nil {
|
||||
chunkDurationUs := int64(ChunkDurationMs) * 1000
|
||||
chunkEndTimeUs := playbackTime + chunkDurationUs
|
||||
tracker.Register(chunkEndTimeUs, len(chunk), chunkDurationUs)
|
||||
}
|
||||
}
|
||||
|
||||
total := time.Since(t0)
|
||||
if total > time.Duration(ChunkDurationMs)*time.Millisecond {
|
||||
log.Printf("chunk breakdown: total=%s read=%s rest=%s",
|
||||
total.Round(time.Microsecond),
|
||||
readDur.Round(time.Microsecond),
|
||||
(total - readDur).Round(time.Microsecond),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) notifyStreamEnd() {
|
||||
streamEnd := protocol.StreamEnd{
|
||||
Roles: []string{"player"},
|
||||
}
|
||||
|
||||
s.clientsMu.RLock()
|
||||
defer s.clientsMu.RUnlock()
|
||||
|
||||
for _, c := range s.clients {
|
||||
if c.HasRole("player") {
|
||||
if err := c.Send("stream/end", streamEnd); err != nil {
|
||||
log.Printf("Error sending stream/end to %s: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// negotiateCodec picks the best codec by scanning the client's advertised
|
||||
// formats in order. The client controls preference (via --preferred-codec);
|
||||
// the server accepts the first codec it can handle.
|
||||
//
|
||||
// Supported: pcm (at source rate), flac, opus. Falls back to pcm.
|
||||
func negotiateCodec(c *ServerClient, sourceSampleRate int) string {
|
||||
if c.capabilities == nil {
|
||||
return "pcm"
|
||||
}
|
||||
|
||||
for _, format := range c.capabilities.SupportedFormats {
|
||||
switch format.Codec {
|
||||
case "pcm":
|
||||
if format.SampleRate == sourceSampleRate && format.BitDepth == DefaultBitDepth {
|
||||
return "pcm"
|
||||
}
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "opus":
|
||||
return "opus"
|
||||
}
|
||||
}
|
||||
|
||||
return "pcm"
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// CreateAudioChunk packs timestamp + payload into a Sendspin binary frame:
|
||||
// [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes].
|
||||
func CreateAudioChunk(timestamp int64, audioData []byte) []byte {
|
||||
chunk := make([]byte, 1+8+len(audioData))
|
||||
chunk[0] = AudioChunkMessageType
|
||||
binary.BigEndian.PutUint64(chunk[1:9], uint64(timestamp))
|
||||
copy(chunk[9:], audioData)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte timestamp (us)][image bytes].
|
||||
// Channel is 0-3, mapping to the artwork channel message types.
|
||||
func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte {
|
||||
chunk := make([]byte, protocol.BinaryMessageHeaderSize+len(imageData))
|
||||
chunk[0] = byte(protocol.ArtworkChannel0MessageType + channel)
|
||||
binary.BigEndian.PutUint64(chunk[1:protocol.BinaryMessageHeaderSize], uint64(timestamp))
|
||||
copy(chunk[protocol.BinaryMessageHeaderSize:], imageData)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// convertToInt16 converts int32 samples to int16 (for Opus encoding)
|
||||
func convertToInt16(samples []int32) []int16 {
|
||||
result := make([]int16, len(samples))
|
||||
for i, s := range samples {
|
||||
result[i] = int16(s >> 8)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// encodePCM encodes int32 samples as 24-bit PCM bytes
|
||||
func encodePCM(samples []int32) []byte {
|
||||
output := make([]byte, len(samples)*3)
|
||||
for i, sample := range samples {
|
||||
output[i*3] = byte(sample)
|
||||
output[i*3+1] = byte(sample >> 8)
|
||||
output[i*3+2] = byte(sample >> 16)
|
||||
}
|
||||
return output
|
||||
}
|
||||
985
third_party/sendspin-go/pkg/sendspin/server_test.go
vendored
Normal file
985
third_party/sendspin-go/pkg/sendspin/server_test.go
vendored
Normal file
@@ -0,0 +1,985 @@
|
||||
// ABOUTME: Integration tests for Server API
|
||||
// ABOUTME: Tests server creation, startup, client connections, and streaming
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// waitForServerPort polls until server.Addr() returns the bound TCP port
|
||||
// after Start runs with Port: 0. Fails the test if the listener isn't
|
||||
// bound within 2s.
|
||||
func waitForServerPort(t *testing.T, server *Server) int {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for server.Addr() == nil {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("server did not bind within 2s")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return server.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func TestNewServer(t *testing.T) {
|
||||
source := NewTestTone(48000, 2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config ServerConfig
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing source",
|
||||
config: ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Test Server",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "ephemeral port (omitted)",
|
||||
config: ServerConfig{
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "default name",
|
||||
config: ServerConfig{
|
||||
Port: 0,
|
||||
Source: source,
|
||||
},
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server, err := NewServer(tt.config)
|
||||
|
||||
if tt.expectErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if server == nil {
|
||||
t.Fatal("expected server to be created")
|
||||
}
|
||||
|
||||
// NewServer no longer substitutes a default Port — Port: 0 is
|
||||
// honored as "let the OS pick" so tests bind ephemerally.
|
||||
// Name still defaults when omitted.
|
||||
if server.config.Name == "" {
|
||||
t.Error("name should have been set to default")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerStartStop(t *testing.T) {
|
||||
source := NewTestTone(48000, 2)
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- server.Start()
|
||||
}()
|
||||
|
||||
// Wait for listener to bind, then give the rest of Start a moment.
|
||||
waitForServerPort(t, server)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Stop server
|
||||
server.Stop()
|
||||
|
||||
// Wait for server to stop
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
t.Errorf("server error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerClientConnection(t *testing.T) {
|
||||
source := NewTestTone(48000, 2)
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Start server
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- server.Start()
|
||||
}()
|
||||
|
||||
// Wait for listener to bind, then give the rest of Start a moment.
|
||||
port := waitForServerPort(t, server)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Connect as client
|
||||
wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to server: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Send client/hello with versioned roles per spec
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "test-client-1",
|
||||
Name: "Test Client",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1", "metadata@v1"},
|
||||
PlayerV1Support: &protocol.PlayerV1Support{
|
||||
SupportedFormats: []protocol.AudioFormat{
|
||||
{
|
||||
Codec: "pcm",
|
||||
Channels: 2,
|
||||
SampleRate: 48000,
|
||||
BitDepth: 24,
|
||||
},
|
||||
},
|
||||
BufferCapacity: 1048576,
|
||||
SupportedCommands: []string{"volume", "mute"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("failed to send hello: %v", err)
|
||||
}
|
||||
|
||||
// Read server/hello response
|
||||
var msg protocol.Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("failed to read server hello: %v", err)
|
||||
}
|
||||
|
||||
if msg.Type != "server/hello" {
|
||||
t.Errorf("expected server/hello, got %s", msg.Type)
|
||||
}
|
||||
|
||||
// Parse server hello
|
||||
helloData, _ := json.Marshal(msg.Payload)
|
||||
var serverHello protocol.ServerHello
|
||||
if err := json.Unmarshal(helloData, &serverHello); err != nil {
|
||||
t.Fatalf("failed to unmarshal server hello: %v", err)
|
||||
}
|
||||
|
||||
if serverHello.Name != "Test Server" {
|
||||
t.Errorf("expected server name 'Test Server', got %s", serverHello.Name)
|
||||
}
|
||||
|
||||
// Verify active_roles is present per spec
|
||||
if len(serverHello.ActiveRoles) == 0 {
|
||||
t.Error("expected active_roles to be set")
|
||||
}
|
||||
|
||||
// Verify connection_reason is present per spec
|
||||
if serverHello.ConnectionReason == "" {
|
||||
t.Error("expected connection_reason to be set")
|
||||
}
|
||||
|
||||
// Read the three control messages in any order. After the role
|
||||
// dispatcher migration (M5), group/update comes from Group.addClient
|
||||
// (synchronous) while stream/start and server/state come from role
|
||||
// handlers dispatched asynchronously — so the order is not guaranteed.
|
||||
// Audio binary chunks may also arrive interleaved with the control
|
||||
// messages since the streaming ticker runs independently.
|
||||
expectedMsgs := map[string]bool{
|
||||
"group/update": false,
|
||||
"stream/start": false,
|
||||
"server/state": false,
|
||||
}
|
||||
var firstAudioChunk []byte
|
||||
controlCount := 0
|
||||
for controlCount < 3 {
|
||||
msgType, rawData, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read message (have %d of 3 control msgs): %v", controlCount, err)
|
||||
}
|
||||
if msgType == websocket.BinaryMessage {
|
||||
// Audio chunk arrived before all control messages; save the
|
||||
// first one so we can validate it below.
|
||||
if firstAudioChunk == nil {
|
||||
firstAudioChunk = rawData
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(rawData, &msg); err != nil {
|
||||
t.Fatalf("failed to unmarshal control message: %v", err)
|
||||
}
|
||||
if _, ok := expectedMsgs[msg.Type]; !ok {
|
||||
t.Fatalf("unexpected message type: %s", msg.Type)
|
||||
}
|
||||
expectedMsgs[msg.Type] = true
|
||||
controlCount++
|
||||
}
|
||||
for msgType, received := range expectedMsgs {
|
||||
if !received {
|
||||
t.Errorf("never received expected %s message", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
// Read audio chunk — either we already captured one above or read next.
|
||||
var data []byte
|
||||
if firstAudioChunk != nil {
|
||||
data = firstAudioChunk
|
||||
} else {
|
||||
var readMsgType int
|
||||
readMsgType, data, err = conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read audio chunk: %v", err)
|
||||
}
|
||||
if readMsgType != websocket.BinaryMessage {
|
||||
t.Errorf("expected binary message, got type %d", readMsgType)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify chunk format: [type:1][timestamp:8][audio_data:N]
|
||||
if len(data) < 9 {
|
||||
t.Errorf("audio chunk too small: %d bytes", len(data))
|
||||
}
|
||||
|
||||
// Per spec: audio chunks use message type 4 (player role, slot 0)
|
||||
if data[0] != AudioChunkMessageType {
|
||||
t.Errorf("expected message type %d, got %d", AudioChunkMessageType, data[0])
|
||||
}
|
||||
|
||||
// Check that clients list includes our client
|
||||
clients := server.Clients()
|
||||
if len(clients) != 1 {
|
||||
t.Errorf("expected 1 client, got %d", len(clients))
|
||||
}
|
||||
|
||||
if clients[0].ID != "test-client-1" {
|
||||
t.Errorf("expected client ID 'test-client-1', got %s", clients[0].ID)
|
||||
}
|
||||
|
||||
// Close connection
|
||||
conn.Close()
|
||||
|
||||
// Give server time to handle disconnect
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify client was removed
|
||||
clients = server.Clients()
|
||||
if len(clients) != 0 {
|
||||
t.Errorf("expected 0 clients after disconnect, got %d", len(clients))
|
||||
}
|
||||
|
||||
// Stop server
|
||||
server.Stop()
|
||||
|
||||
select {
|
||||
case <-errChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerMultipleClients(t *testing.T) {
|
||||
source := NewTestTone(48000, 2)
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Start server
|
||||
go server.Start()
|
||||
port := waitForServerPort(t, server)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Connect multiple clients
|
||||
clients := make([]*websocket.Conn, 3)
|
||||
wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port)
|
||||
for i := 0; i < 3; i++ {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect client %d: %v", i, err)
|
||||
}
|
||||
clients[i] = conn
|
||||
|
||||
// Send hello with versioned roles
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: fmt.Sprintf("test-client-%d", i),
|
||||
Name: fmt.Sprintf("Test Client %d", i),
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
PlayerV1Support: &protocol.PlayerV1Support{
|
||||
SupportedFormats: []protocol.AudioFormat{
|
||||
{
|
||||
Codec: "pcm",
|
||||
Channels: 2,
|
||||
SampleRate: 48000,
|
||||
BitDepth: 24,
|
||||
},
|
||||
},
|
||||
BufferCapacity: 1048576,
|
||||
SupportedCommands: []string{"volume", "mute"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("failed to send hello from client %d: %v", i, err)
|
||||
}
|
||||
|
||||
// Read server/hello
|
||||
var msg protocol.Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("failed to read server hello for client %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Give server time to register all clients
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Check that all clients are registered
|
||||
serverClients := server.Clients()
|
||||
if len(serverClients) != 3 {
|
||||
t.Errorf("expected 3 clients, got %d", len(serverClients))
|
||||
}
|
||||
|
||||
// Close all connections
|
||||
for i, conn := range clients {
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Errorf("failed to close client %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Give server time to handle disconnects
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify all clients were removed
|
||||
serverClients = server.Clients()
|
||||
if len(serverClients) != 0 {
|
||||
t.Errorf("expected 0 clients after disconnect, got %d", len(serverClients))
|
||||
}
|
||||
|
||||
// Stop server
|
||||
server.Stop()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestServer_GroupReceivesEventsFromRealHandshake is the real wiring
|
||||
// guard for M2: it stands up an actual Server, subscribes to the
|
||||
// default group BEFORE the server starts accepting connections, then
|
||||
// drives a full WebSocket handshake via the gorilla client and asserts
|
||||
// that ClientJoinedEvent and ClientLeftEvent are delivered through the
|
||||
// event bus. A future refactor that moved defaultGroup.addClient out of
|
||||
// handleConnection (or routed it through a different Group) would fail
|
||||
// this test, which is exactly the guarantee the helper-level test cannot
|
||||
// provide.
|
||||
func TestServer_GroupReceivesEventsFromRealHandshake(t *testing.T) {
|
||||
server, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Group Wiring Test",
|
||||
Source: NewTestTone(48000, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
// Subscribe BEFORE Start so the join event is guaranteed to land.
|
||||
events, unsubscribe := server.Group().Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() { errChan <- server.Start() }()
|
||||
defer func() {
|
||||
server.Stop()
|
||||
select {
|
||||
case <-errChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop within timeout")
|
||||
}
|
||||
}()
|
||||
|
||||
port := waitForServerPort(t, server)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "wiring-test-client",
|
||||
Name: "Wiring Test Client",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
PlayerV1Support: &protocol.PlayerV1Support{
|
||||
SupportedFormats: []protocol.AudioFormat{
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 24},
|
||||
},
|
||||
BufferCapacity: 1048576,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(hello); err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("write hello: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the ClientJoinedEvent delivered through the real wiring.
|
||||
select {
|
||||
case evt := <-events:
|
||||
joined, ok := evt.(ClientJoinedEvent)
|
||||
if !ok {
|
||||
conn.Close()
|
||||
t.Fatalf("first event = %T, want ClientJoinedEvent", evt)
|
||||
}
|
||||
if joined.Client.ID() != "wiring-test-client" {
|
||||
t.Errorf("joined Client.ID() = %q, want %q", joined.Client.ID(), "wiring-test-client")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
conn.Close()
|
||||
t.Fatal("timed out waiting for ClientJoinedEvent from real handshake")
|
||||
}
|
||||
|
||||
// Disconnect and assert the matching ClientLeftEvent fires.
|
||||
conn.Close()
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
left, ok := evt.(ClientLeftEvent)
|
||||
if !ok {
|
||||
t.Fatalf("second event = %T, want ClientLeftEvent", evt)
|
||||
}
|
||||
if left.ClientID != "wiring-test-client" {
|
||||
t.Errorf("left ClientID = %q, want %q", left.ClientID, "wiring-test-client")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for ClientLeftEvent after disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ControllerCommandEndToEnd exercises the full client/command
|
||||
// pipeline: Server + ControllerGroupRole + real WebSocket handshake +
|
||||
// client/command message → OnCommand callback fires.
|
||||
func TestServer_ControllerCommandEndToEnd(t *testing.T) {
|
||||
commandReceived := make(chan string, 1)
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Controller Test",
|
||||
Source: NewTestTone(48000, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
ctrl := NewControllerRole(ControllerConfig{
|
||||
SupportedCommands: []string{"next", "previous"},
|
||||
OnCommand: func(c *ServerClient, command string) {
|
||||
commandReceived <- command
|
||||
},
|
||||
})
|
||||
server.Group().RegisterRole(ctrl)
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() { errChan <- server.Start() }()
|
||||
defer func() {
|
||||
server.Stop()
|
||||
select {
|
||||
case <-errChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server stop timeout")
|
||||
}
|
||||
}()
|
||||
|
||||
port := waitForServerPort(t, server)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "ctrl-test-client",
|
||||
Name: "Controller Test Client",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("write hello: %v", err)
|
||||
}
|
||||
|
||||
// Drain the server/hello response.
|
||||
var msg protocol.Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("read server/hello: %v", err)
|
||||
}
|
||||
|
||||
// Give the join event time to dispatch to the controller role.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Send a controller command.
|
||||
cmd := protocol.Message{
|
||||
Type: "client/command",
|
||||
Payload: map[string]interface{}{
|
||||
"controller": map[string]interface{}{
|
||||
"command": "next",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(cmd); err != nil {
|
||||
t.Fatalf("write client/command: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-commandReceived:
|
||||
if got != "next" {
|
||||
t.Errorf("command = %q, want %q", got, "next")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for OnCommand callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ActivateRolesDefaultsToPlayerMetadata confirms backward
|
||||
// compat: when ServerConfig.SupportedRoles is nil, activateRoles still
|
||||
// accepts "player" and "metadata" and rejects unknown families.
|
||||
func TestServer_ActivateRolesDefault(t *testing.T) {
|
||||
s, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "test",
|
||||
Source: NewTestTone(48000, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
got := s.activateRoles([]string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"})
|
||||
|
||||
want := map[string]bool{"player@v1": true, "metadata@v1": true}
|
||||
gotSet := make(map[string]bool)
|
||||
for _, r := range got {
|
||||
gotSet[r] = true
|
||||
}
|
||||
if len(gotSet) != len(want) {
|
||||
t.Errorf("activateRoles default = %v, want player+metadata only", got)
|
||||
}
|
||||
for r := range want {
|
||||
if !gotSet[r] {
|
||||
t.Errorf("missing expected role %s in %v", r, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ActivateRolesFromConfig confirms that explicitly listing
|
||||
// roles in ServerConfig.SupportedRoles controls what gets activated.
|
||||
// Note: NewServer always registers player and metadata GroupRoles on
|
||||
// the default group, so those families are always active via the group
|
||||
// registry regardless of SupportedRoles. This test verifies that
|
||||
// SupportedRoles adds additional families (artwork) beyond those.
|
||||
func TestServer_ActivateRolesFromConfig(t *testing.T) {
|
||||
s, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "test",
|
||||
Source: NewTestTone(48000, 2),
|
||||
SupportedRoles: []string{"player", "artwork"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
got := s.activateRoles([]string{"player@v1", "metadata@v1", "artwork@v1"})
|
||||
|
||||
gotSet := make(map[string]bool)
|
||||
for _, r := range got {
|
||||
gotSet[r] = true
|
||||
}
|
||||
// metadata is active because NewServer registers MetadataGroupRole
|
||||
// on the default group, which auto-activates it.
|
||||
if !gotSet["metadata@v1"] {
|
||||
t.Error("metadata should be active (registered via GroupRole in NewServer)")
|
||||
}
|
||||
if !gotSet["player@v1"] || !gotSet["artwork@v1"] {
|
||||
t.Errorf("expected player+artwork, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ActivateRolesFromGroupRegistry confirms that registering a
|
||||
// GroupRole on the server's Group auto-activates that role family even
|
||||
// when it's not in ServerConfig.SupportedRoles.
|
||||
func TestServer_ActivateRolesFromGroupRegistry(t *testing.T) {
|
||||
s, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "test",
|
||||
Source: NewTestTone(48000, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
// Register a controller role — should auto-activate "controller".
|
||||
ctrl := NewControllerRole(ControllerConfig{
|
||||
SupportedCommands: []string{"next"},
|
||||
})
|
||||
s.Group().RegisterRole(ctrl)
|
||||
|
||||
got := s.activateRoles([]string{"player@v1", "controller@v1"})
|
||||
|
||||
gotSet := make(map[string]bool)
|
||||
for _, r := range got {
|
||||
gotSet[r] = true
|
||||
}
|
||||
if !gotSet["player@v1"] {
|
||||
t.Error("player should be active (default)")
|
||||
}
|
||||
if !gotSet["controller@v1"] {
|
||||
t.Error("controller should be active (registered via GroupRole)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDuplicateClientID(t *testing.T) {
|
||||
source := NewTestTone(48000, 2)
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Test Server",
|
||||
Source: source,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Start server
|
||||
go server.Start()
|
||||
port := waitForServerPort(t, server)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Connect first client
|
||||
wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port)
|
||||
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect first client: %v", err)
|
||||
}
|
||||
defer conn1.Close()
|
||||
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "duplicate-id",
|
||||
Name: "First Client",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn1.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("failed to send hello: %v", err)
|
||||
}
|
||||
|
||||
// Read server/hello
|
||||
var msg protocol.Message
|
||||
if err := conn1.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("failed to read server hello: %v", err)
|
||||
}
|
||||
|
||||
// Try to connect second client with same ID
|
||||
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect second client: %v", err)
|
||||
}
|
||||
defer conn2.Close()
|
||||
|
||||
if err := conn2.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("failed to send hello from second client: %v", err)
|
||||
}
|
||||
|
||||
// Second client should be rejected - connection should close
|
||||
// Try to read a message - should get an error
|
||||
conn2.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||
err = conn2.ReadJSON(&msg)
|
||||
if err == nil {
|
||||
// If we got a message, it should be an error message
|
||||
if msg.Type == "server/error" {
|
||||
// This is expected
|
||||
} else {
|
||||
t.Errorf("expected error or connection close for duplicate ID, got message type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify only one client is registered
|
||||
serverClients := server.Clients()
|
||||
if len(serverClients) != 1 {
|
||||
t.Errorf("expected 1 client, got %d", len(serverClients))
|
||||
}
|
||||
|
||||
// Stop server
|
||||
server.Stop()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestServer_FLACStreamNegotiation verifies that a client advertising
|
||||
// FLAC as its preferred codec receives stream/start with codec="flac"
|
||||
// and a valid codec_header, followed by binary FLAC audio chunks.
|
||||
func TestServer_FLACStreamNegotiation(t *testing.T) {
|
||||
s, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "FLAC Test Server",
|
||||
Source: NewTestTone(48000, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() { errChan <- s.Start() }()
|
||||
defer func() {
|
||||
s.Stop()
|
||||
select {
|
||||
case <-errChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server stop timeout")
|
||||
}
|
||||
}()
|
||||
|
||||
port := waitForServerPort(t, s)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Advertise FLAC as first format (simulates --preferred-codec flac)
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "flac-test-client",
|
||||
Name: "FLAC Test Client",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1", "metadata@v1"},
|
||||
PlayerV1Support: &protocol.PlayerV1Support{
|
||||
SupportedFormats: []protocol.AudioFormat{
|
||||
{Codec: "flac", SampleRate: 48000, Channels: 2, BitDepth: 24},
|
||||
{Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24},
|
||||
},
|
||||
BufferCapacity: 1048576,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("write hello: %v", err)
|
||||
}
|
||||
|
||||
// Read messages looking for stream/start with FLAC codec.
|
||||
// The order of messages after server/hello is non-deterministic
|
||||
// (group/update, stream/start, server/state may interleave).
|
||||
var foundStreamStart bool
|
||||
var codecHeader string
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
conn.SetReadDeadline(deadline)
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
if msgType == websocket.BinaryMessage {
|
||||
// Got a binary audio chunk before finding stream/start
|
||||
if !foundStreamStart {
|
||||
t.Fatal("received binary chunk before stream/start")
|
||||
}
|
||||
// Verify it's a valid audio chunk (at least header + some data)
|
||||
if len(data) < 10 {
|
||||
t.Errorf("audio chunk too small: %d bytes", len(data))
|
||||
}
|
||||
t.Logf("Received FLAC audio chunk: %d bytes", len(data))
|
||||
break // Success — we got stream/start + at least one chunk
|
||||
}
|
||||
|
||||
// Text message — parse the envelope
|
||||
var msg protocol.Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if msg.Type == "stream/start" {
|
||||
startData, _ := json.Marshal(msg.Payload)
|
||||
var streamStart protocol.StreamStart
|
||||
if err := json.Unmarshal(startData, &streamStart); err != nil {
|
||||
t.Fatalf("unmarshal stream/start: %v", err)
|
||||
}
|
||||
|
||||
if streamStart.Player == nil {
|
||||
t.Fatal("stream/start has no player info")
|
||||
}
|
||||
if streamStart.Player.Codec != "flac" {
|
||||
t.Errorf("codec = %q, want flac", streamStart.Player.Codec)
|
||||
}
|
||||
if streamStart.Player.CodecHeader == "" {
|
||||
t.Error("codec_header is empty — FLAC requires STREAMINFO")
|
||||
}
|
||||
if streamStart.Player.SampleRate != 48000 {
|
||||
t.Errorf("sample_rate = %d, want 48000", streamStart.Player.SampleRate)
|
||||
}
|
||||
if streamStart.Player.BitDepth != 24 {
|
||||
t.Errorf("bit_depth = %d, want 24", streamStart.Player.BitDepth)
|
||||
}
|
||||
|
||||
codecHeader = streamStart.Player.CodecHeader
|
||||
foundStreamStart = true
|
||||
t.Logf("stream/start received: codec=flac, codec_header=%d chars (base64)", len(codecHeader))
|
||||
}
|
||||
}
|
||||
|
||||
if !foundStreamStart {
|
||||
t.Fatal("never received stream/start with FLAC")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_BufferTrackerLimitsChunks verifies that the server's
|
||||
// BufferTracker prevents buffer overflow. A client advertising a tiny
|
||||
// buffer_capacity should receive fewer chunks than one with a large
|
||||
// capacity, because the server skips sends when the tracker is full.
|
||||
func TestServer_BufferTrackerLimitsChunks(t *testing.T) {
|
||||
s, err := NewServer(ServerConfig{
|
||||
Port: 0,
|
||||
Name: "Buffer Test",
|
||||
Source: NewTestTone(48000, 2),
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() { errChan <- s.Start() }()
|
||||
defer func() {
|
||||
s.Stop()
|
||||
select {
|
||||
case <-errChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server stop timeout")
|
||||
}
|
||||
}()
|
||||
|
||||
port := waitForServerPort(t, s)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Connect with a small buffer capacity (20000 bytes).
|
||||
// At 48kHz stereo 24-bit PCM, one 20ms chunk is ~5760 bytes of audio
|
||||
// plus the 9-byte chunk header (~5769 total). So 20000 bytes fits ~3
|
||||
// chunks at a time. As playback time advances, PruneConsumed frees
|
||||
// slots, but the tracker still throttles the send rate well below 50
|
||||
// chunks/second.
|
||||
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
hello := protocol.Message{
|
||||
Type: "client/hello",
|
||||
Payload: protocol.ClientHello{
|
||||
ClientID: "buffer-test-client",
|
||||
Name: "Buffer Test Client",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"player@v1", "metadata@v1"},
|
||||
PlayerV1Support: &protocol.PlayerV1Support{
|
||||
SupportedFormats: []protocol.AudioFormat{
|
||||
{Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24},
|
||||
},
|
||||
BufferCapacity: 20000, // Small — fits ~3 PCM chunks at a time
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(hello); err != nil {
|
||||
t.Fatalf("write hello: %v", err)
|
||||
}
|
||||
|
||||
// Read messages for 1 second and count binary chunks received.
|
||||
binaryCount := 0
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn.SetReadDeadline(deadline)
|
||||
msgType, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if msgType == websocket.BinaryMessage {
|
||||
binaryCount++
|
||||
}
|
||||
}
|
||||
|
||||
// At 50 chunks/second for 1 second, an unlimited client would get ~50 chunks.
|
||||
// With a 5000-byte capacity, the tracker should limit this significantly.
|
||||
// The exact number depends on timing (PruneConsumed frees slots as
|
||||
// playback time passes), but it should be well under 50.
|
||||
t.Logf("Received %d binary chunks in 1s (unlimited would be ~50)", binaryCount)
|
||||
|
||||
// We just verify the tracker is working — some chunks should arrive
|
||||
// (the first one always fits), but far fewer than 50.
|
||||
if binaryCount >= 50 {
|
||||
t.Errorf("received %d chunks — buffer tracker doesn't seem to be limiting", binaryCount)
|
||||
}
|
||||
if binaryCount == 0 {
|
||||
t.Error("received 0 chunks — at least the first should have been sent")
|
||||
}
|
||||
}
|
||||
102
third_party/sendspin-go/pkg/sendspin/source.go
vendored
Normal file
102
third_party/sendspin-go/pkg/sendspin/source.go
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
// ABOUTME: Audio source abstraction for Sendspin streaming
|
||||
// ABOUTME: Provides AudioSource interface and common implementations
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// AudioSource provides PCM audio samples for streaming
|
||||
type AudioSource interface {
|
||||
// Read reads PCM samples into the buffer (int32 for 24-bit support).
|
||||
// Returns number of samples read or error.
|
||||
Read(samples []int32) (int, error)
|
||||
|
||||
// SampleRate returns the sample rate of the audio
|
||||
SampleRate() int
|
||||
|
||||
// Channels returns the number of channels
|
||||
Channels() int
|
||||
|
||||
// Metadata returns title, artist, album
|
||||
Metadata() (title, artist, album string)
|
||||
|
||||
// Close closes the audio source
|
||||
Close() error
|
||||
}
|
||||
|
||||
type TestToneSource struct {
|
||||
sampleIndex uint64
|
||||
sampleMu sync.Mutex
|
||||
frequency float64
|
||||
sampleRate int
|
||||
channels int
|
||||
}
|
||||
|
||||
// NewTestTone creates a new test tone generator
|
||||
// Generates a 440Hz sine wave at the specified sample rate and channels
|
||||
func NewTestTone(sampleRate, channels int) *TestToneSource {
|
||||
if sampleRate == 0 {
|
||||
sampleRate = DefaultSampleRate
|
||||
}
|
||||
if channels == 0 {
|
||||
channels = DefaultChannels
|
||||
}
|
||||
|
||||
return &TestToneSource{
|
||||
frequency: 440.0, // A4 note
|
||||
sampleRate: sampleRate,
|
||||
channels: channels,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TestToneSource) Read(samples []int32) (int, error) {
|
||||
s.sampleMu.Lock()
|
||||
defer s.sampleMu.Unlock()
|
||||
|
||||
numSamples := len(samples) / s.channels
|
||||
|
||||
for i := 0; i < numSamples; i++ {
|
||||
t := float64(s.sampleIndex+uint64(i)) / float64(s.sampleRate)
|
||||
sample := math.Sin(2 * math.Pi * s.frequency * t)
|
||||
|
||||
// Scale to 24-bit range; 50% amplitude avoids clipping on decode
|
||||
const max24bit = 8388607 // 2^23 - 1
|
||||
pcmValue := int32(sample * max24bit * 0.5)
|
||||
|
||||
for ch := 0; ch < s.channels; ch++ {
|
||||
samples[i*s.channels+ch] = pcmValue
|
||||
}
|
||||
}
|
||||
|
||||
s.sampleIndex += uint64(numSamples)
|
||||
|
||||
return len(samples), nil
|
||||
}
|
||||
|
||||
func (s *TestToneSource) SampleRate() int { return s.sampleRate }
|
||||
func (s *TestToneSource) Channels() int { return s.channels }
|
||||
func (s *TestToneSource) Metadata() (string, string, string) {
|
||||
return "Test Tone", "Sendspin", "Test Signal"
|
||||
}
|
||||
func (s *TestToneSource) Close() error { return nil }
|
||||
|
||||
// FileSource streams audio from a file
|
||||
// Note: This is a placeholder - users should use server.NewAudioSource() for file streaming
|
||||
// which supports MP3, FLAC, and other formats
|
||||
type FileSource struct {
|
||||
// This is intentionally not implemented in the public API yet
|
||||
// Users can use internal/server.NewAudioSource() for now
|
||||
// TODO: Move file source implementations to pkg/audio/decode
|
||||
}
|
||||
|
||||
// NewFileSource creates an audio source from a file
|
||||
// Supported formats: MP3, FLAC
|
||||
// Returns an error if the file cannot be opened or decoded
|
||||
func NewFileSource(path string) (AudioSource, error) {
|
||||
// For now, we use the internal implementation
|
||||
// TODO: Migrate file sources to pkg/audio/decode
|
||||
return nil, fmt.Errorf("file source not implemented: use internal/server.NewAudioSource()")
|
||||
}
|
||||
Reference in New Issue
Block a user