🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View 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,
})
}