FLAC encoder now tracks frame numbers and reuses per-channel sample buffers to reduce allocations. Client dialer always frees the active slot on release so mDNS re-emissions can reconnect cleanly. Server send buffer uses drop-oldest instead of hard failure, with lateness tracking. Added engine stats logging (chunks/sec, kbps) and writer diagnostics. UI simplified to a single play/pause toggle that auto-starts playback on preset selection. Added --preset CLI flag for headless startup. Removed stale systemd service files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
662 lines
17 KiB
Go
662 lines
17 KiB
Go
// 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
|
|
|
|
// Accumulates bytes-on-wire across one streamAudio rate window
|
|
// (encoded chunk size, not raw PCM) so the periodic stats line can
|
|
// report both chunks/sec and kbps. Touched only from the streamAudio
|
|
// goroutine so no locking needed.
|
|
lastWindowBytes int64
|
|
|
|
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:
|
|
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 {
|
|
log.Printf("clientWriter %s: binary write error: %v (queue %d)", c.name, err, queueDepth)
|
|
return
|
|
}
|
|
if wDur := time.Since(wStart); wDur > 5*time.Millisecond || queueDepth > 5 {
|
|
log.Printf("clientWriter %s: %s for %d bytes (queue %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 {
|
|
log.Printf("clientWriter %s: text write error: %v", c.name, err)
|
|
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
|
|
}
|