🎉 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

118
cmd/client/main.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"rpi-sendspin/internal/pipewire"
)
func main() {
server := flag.String("server", "localhost:8927", "server address")
name := flag.String("name", hostname(), "player name")
device := flag.String("device", "", "PipeWire sink node name (default: system default)")
listDevices := flag.Bool("list-devices", false, "list available PipeWire sinks and exit")
volume := flag.Int("volume", 80, "initial volume (0-100)")
flag.Parse()
if *listDevices {
sinks, err := pipewire.ListSinks()
if err != nil {
log.Fatalf("list sinks: %v", err)
}
if len(sinks) == 0 {
fmt.Println("no PipeWire sinks found")
return
}
fmt.Println("Available PipeWire sinks:")
for _, s := range sinks {
fmt.Printf(" %s\n", s)
}
return
}
idFile := clientIDFile()
savedID, _ := os.ReadFile(idFile)
clientID, err := sendspin.ResolveClientID("", string(savedID), func(id string) error {
if err := os.MkdirAll(filepath.Dir(idFile), 0700); err != nil {
return err
}
return os.WriteFile(idFile, []byte(id), 0600)
})
if err != nil {
log.Fatalf("resolve client ID: %v", err)
}
output := pipewire.NewOutput(*device, *name)
player, err := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: *server,
PlayerName: *name,
ClientID: clientID,
Volume: *volume,
Output: output,
Reconnect: sendspin.ReconnectConfig{
Enabled: true,
InitialDelay: 2 * time.Second,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
},
OnMetadata: func(meta sendspin.Metadata) {
if meta.Artist != "" || meta.Title != "" {
log.Printf("now playing: %s — %s", meta.Artist, meta.Title)
}
},
OnError: func(err error) {
log.Printf("error: %v", err)
},
})
if err != nil {
log.Fatalf("create player: %v", err)
}
defer player.Close()
if *device != "" {
log.Printf("output: %s", *device)
} else {
log.Printf("output: PipeWire default sink")
}
log.Printf("connecting to %s as %q...", *server, *name)
if err := player.Connect(); err != nil {
log.Fatalf("connect: %v", err)
}
if err := player.Play(); err != nil {
log.Fatalf("play: %v", err)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
log.Println("stopping")
player.Stop()
}
func hostname() string {
h, _ := os.Hostname()
if h == "" {
return "sendspin-player"
}
return h
}
func clientIDFile() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = os.TempDir()
}
return filepath.Join(dir, "rpi-sendspin", "client_id")
}

200
cmd/live-server/main.go Normal file
View File

@@ -0,0 +1,200 @@
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
)
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")
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)
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)
}
}

88
cmd/server/main.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"rpi-sendspin/internal/pipewire"
)
func main() {
port := flag.Int("port", 8927, "listen port")
name := flag.String("name", "Sendspin Server", "server name")
source := flag.String("source", "", "PipeWire source node name")
listSources := flag.Bool("list-sources", false, "list available PipeWire sources and exit")
rate := flag.Int("rate", 48000, "capture sample rate")
channels := flag.Int("channels", 2, "capture channels")
flag.Parse()
if *listSources {
printSources()
return
}
if *source == "" {
sources, err := pipewire.ListSources()
if err != nil {
log.Fatalf("list sources: %v", err)
}
if len(sources) == 0 {
log.Fatal("no PipeWire sources found; is PipeWire running?")
}
fmt.Println("Available PipeWire sources:")
for i, s := range sources {
fmt.Printf(" %d. %s\n", i+1, s)
}
fmt.Printf("\nSpecify a source with --source <node-name>\n")
fmt.Printf("Example: --source %s\n", sources[0].Name)
os.Exit(1)
}
src := pipewire.NewSource(*source, *rate, *channels)
log.Printf("capturing from %q at %d Hz %d ch", *source, *rate, *channels)
server, err := sendspin.NewServer(sendspin.ServerConfig{
Port: *port,
Name: *name,
Source: src,
})
if err != nil {
log.Fatalf("create server: %v", err)
}
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("server %q listening on :%d", *name, *port)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
log.Println("stopping")
server.Stop()
src.Close()
}
func printSources() {
sources, err := pipewire.ListSources()
if err != nil {
log.Fatalf("list sources: %v", err)
}
if len(sources) == 0 {
fmt.Println("no PipeWire sources found")
return
}
fmt.Println("Available PipeWire sources:")
for _, s := range sources {
fmt.Printf(" %s\n", s)
}
}