package main import ( "encoding/json" "fmt" "os" "strconv" "strings" "sync" "sync/atomic" ) // SourceConfig is the serialisable description of one data source (for file save/load). type SourceConfig struct { Label string `json:"label"` Addr string `json:"addr"` MulticastGroup string `json:"multicastGroup,omitempty"` // "" = unicast mode DataPort int `json:"dataPort,omitempty"` // 0 = Addr port+1 } // managedSource is the SourceManager's view of one running source. type managedSource struct { id string label string addr string multicastGroup string dataPort int client *UDPClient } // SourceManager owns the lifecycle of all active data sources. // It is the only place where UDPClient instances are created and stopped. type SourceManager struct { mu sync.RWMutex sources map[string]*managedSource hub *Hub filePath string // empty = save not configured nextID atomic.Int32 } // NewSourceManager creates a SourceManager bound to the given hub. // filePath is the default path for Save(); may be empty. func NewSourceManager(hub *Hub, filePath string) *SourceManager { return &SourceManager{ sources: make(map[string]*managedSource), hub: hub, filePath: filePath, } } func (sm *SourceManager) genID() string { return fmt.Sprintf("s%d", sm.nextID.Add(1)) } // Add creates a new source, registers it in the hub, and starts connecting. // multicastGroup: multicast IP (e.g. "239.0.0.1") or "" for unicast mode. // dataPort: UDP data port in multicast mode; 0 = Addr port+1. // Returns the source ID. func (sm *SourceManager) Add(label, addr, multicastGroup string, dataPort int) string { if label == "" { label = addr } id := sm.genID() c := NewUDPClient(addr, id, sm.hub, multicastGroup, dataPort) ms := &managedSource{ id: id, label: label, addr: addr, multicastGroup: multicastGroup, dataPort: dataPort, client: c, } sm.mu.Lock() sm.sources[id] = ms sm.mu.Unlock() // Register in hub (creates hub-side state, broadcasts sources list). sm.hub.AddSource(id, label, addr) go c.Run() return id } // Remove stops the source's client and removes it from the hub. func (sm *SourceManager) Remove(id string) { sm.mu.Lock() ms, ok := sm.sources[id] if ok { delete(sm.sources, id) } sm.mu.Unlock() if ok { ms.client.Stop() sm.hub.RemoveSource(id) } } // Save writes the current source list to filePath. func (sm *SourceManager) Save() error { if sm.filePath == "" { return fmt.Errorf("no sources-file configured (start with --sources-file path)") } sm.mu.RLock() cfgs := make([]SourceConfig, 0, len(sm.sources)) for _, ms := range sm.sources { cfgs = append(cfgs, SourceConfig{ Label: ms.label, Addr: ms.addr, MulticastGroup: ms.multicastGroup, DataPort: ms.dataPort, }) } sm.mu.RUnlock() data, err := json.MarshalIndent(cfgs, "", " ") if err != nil { return err } return os.WriteFile(sm.filePath, data, 0644) } // Load reads sources from path and adds them. Sets filePath for future saves. func (sm *SourceManager) Load(path string) error { data, err := os.ReadFile(path) if err != nil { return err } var cfgs []SourceConfig if err := json.Unmarshal(data, &cfgs); err != nil { return err } sm.filePath = path for _, cfg := range cfgs { sm.Add(cfg.Label, cfg.Addr, cfg.MulticastGroup, cfg.DataPort) } return nil } // ParseSourceArg parses "label@host:port" or "host:port". func ParseSourceArg(s string) (label, addr string) { label, addr, _, _ = ParseSourceArgFull(s) return } // ParseSourceArgFull parses "[label@]host:port[/multicastGroup:dataPort]". // Examples: // // "Streamer@127.0.0.1:44500/239.0.0.1:44503" → label="Streamer", addr="127.0.0.1:44500", group="239.0.0.1", port=44503 // "127.0.0.1:44501" → label="", addr="127.0.0.1:44501", group="", port=0 func ParseSourceArgFull(s string) (label, addr, multicastGroup string, dataPort int) { s = strings.TrimSpace(s) rest := s if idx := strings.Index(s, "@"); idx >= 0 { label = strings.TrimSpace(s[:idx]) rest = strings.TrimSpace(s[idx+1:]) } if idx := strings.Index(rest, "/"); idx >= 0 { addr = strings.TrimSpace(rest[:idx]) mcastPart := strings.TrimSpace(rest[idx+1:]) if lastColon := strings.LastIndex(mcastPart, ":"); lastColon >= 0 { multicastGroup = strings.TrimSpace(mcastPart[:lastColon]) dataPort, _ = strconv.Atoi(strings.TrimSpace(mcastPart[lastColon+1:])) } else { multicastGroup = mcastPart } } else { addr = rest } return }