package main import ( "context" "flag" "fmt" "log" "net/http" "os" "os/signal" "path/filepath" "sort" "strconv" "syscall" "time" "github.com/Sendspin/sendspin-go/pkg/sendspin" "rpi-sendspin/internal/live" ) // flagSpec describes one user-facing option. The flag is registered // under all names; help output (custom Usage below) groups them onto a // single line so `-s, --source` reads as one option rather than two. type flagSpec struct { long string short string // optional usage string } var specs []flagSpec func addStr(target *string, def, long, short, usage string) { flag.StringVar(target, long, def, usage) if short != "" { flag.StringVar(target, short, def, usage) } specs = append(specs, flagSpec{long, short, usage}) } func addInt(target *int, def int, long, short, usage string) { flag.IntVar(target, long, def, usage) if short != "" { flag.IntVar(target, short, def, usage) } specs = append(specs, flagSpec{long, short, usage}) } func main() { var ( port int name string httpPort int configPath string source string mqttURL string mqttUser string mqttPass string mqttBase string mqttDiscovery string rate int channels int preset string ) addInt(&port, 8927, "port", "p", "Sendspin WebSocket port") addStr(&name, "Live Audio", "name", "n", "advertised server name") addInt(&httpPort, 8080, "http-port", "", "HTTP/API port") addStr(&configPath, defaultConfigPath(), "config", "c", "presets file path") addStr(&source, "", "source", "s", "PipeWire source node (e.g. alsa_input.usb-...pro-input-0.monitor); when set, overrides preset.source") addStr(&mqttURL, "", "mqtt-url", "", "MQTT broker URL (e.g. tcp://broker.local:1883); empty disables MQTT") addStr(&mqttUser, "", "mqtt-username", "", "MQTT username") addStr(&mqttPass, "", "mqtt-password", "", "MQTT password") addStr(&mqttBase, "sendspin/live", "mqtt-base", "", "MQTT base topic") addStr(&mqttDiscovery, "homeassistant", "mqtt-discovery", "", "Home Assistant discovery prefix") addInt(&rate, 48000, "rate", "r", "capture sample rate") addInt(&channels, 2, "channels", "", "capture channels") addStr(&preset, "", "preset", "", "activate this preset id at startup (begins playback immediately)") flag.Usage = usage flag.Parse() log.SetFlags(log.LstdFlags | log.Lmicroseconds) store, err := live.NewStore(configPath) if err != nil { log.Fatalf("load presets: %v", err) } src := live.NewSource(rate, channels) roster := live.NewRoster() var mgr *live.Manager server, err := sendspin.NewServer(sendspin.ServerConfig{ Port: port, Name: name, Source: src, EnableMDNS: true, DiscoverClients: true, ClientFilter: func(id string) bool { if mgr == nil { return false } return mgr.ClientAllowed(id) }, OnClientHello: roster.Record, }) if err != nil { log.Fatalf("create sendspin server: %v", err) } mgr = live.NewManager(store, server, src) mgr.SetSourceOverride(source) if source != "" { log.Printf("source override: %s (preset.source ignored)", source) } disc := live.NewDiscovery() disc.Start() defer disc.Stop() httpSrv := &http.Server{ Addr: ":" + strconv.Itoa(httpPort), Handler: live.NewHTTPServer(mgr, store, disc, roster, server).Handler(), ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } go func() { log.Printf("HTTP API listening on %s", httpSrv.Addr) if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("http: %v", err) } }() var bridge *live.MQTTBridge if mqttURL != "" { bridge = live.NewMQTTBridge(live.MQTTConfig{ URL: mqttURL, Username: mqttUser, Password: mqttPass, BaseTopic: mqttBase, DiscoveryPrefix: mqttDiscovery, }, mgr) if err := bridge.Start(); err != nil { log.Fatalf("mqtt: %v", err) } } serverDone := make(chan error, 1) go func() { serverDone <- server.Start() }() log.Printf("Sendspin server %q on :%d", name, port) if preset != "" { if err := mgr.SetActivePreset(preset); err != nil { log.Fatalf("activate preset %q: %v", preset, err) } log.Printf("preset %q activated at startup", preset) } sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) select { case <-sig: log.Printf("signal received, shutting down") case err := <-serverDone: log.Printf("sendspin server exited: %v", err) } if bridge != nil { bridge.Stop() } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = httpSrv.Shutdown(ctx) server.Stop() _ = src.Close() } func defaultConfigPath() string { home, err := os.UserHomeDir() if err != nil { return "presets.json" } return filepath.Join(home, ".config", "sendspin-live", "presets.json") } // usage renders one line per logical option, pairing the short alias // with the long form (e.g. "-s, --source"). The default flag.Usage // would list each registration separately and double-print everything. func usage() { out := flag.CommandLine.Output() fmt.Fprintf(out, "Usage: %s [flags]\n\nFlags:\n", os.Args[0]) sort.Slice(specs, func(i, j int) bool { return specs[i].long < specs[j].long }) for _, s := range specs { f := flag.Lookup(s.long) if f == nil { continue } head := " --" + s.long if s.short != "" { head = " -" + s.short + ", --" + s.long } if f.DefValue != "" { head += "=" + f.DefValue } fmt.Fprintf(out, "%-44s %s\n", head, s.usage) } }