🎉 live server seems to be working now
This commit is contained in:
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user