🎉 live server seems to be working now
This commit is contained in:
423
third_party/sendspin-go/main.go
vendored
Normal file
423
third_party/sendspin-go/main.go
vendored
Normal file
@@ -0,0 +1,423 @@
|
||||
// ABOUTME: Entry point for Sendspin Protocol player
|
||||
// ABOUTME: Parses CLI flags and starts the player application
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/internal/discovery"
|
||||
"github.com/Sendspin/sendspin-go/internal/ui"
|
||||
"github.com/Sendspin/sendspin-go/internal/version"
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sendspin"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
var (
|
||||
serverAddr = flag.String("server", "", "Manual server address (skip mDNS)")
|
||||
port = flag.Int("port", 8927, "Port for mDNS advertisement")
|
||||
name = flag.String("name", "", "Player friendly name (default: hostname-sendspin-player)")
|
||||
bufferMs = flag.Int("buffer-ms", 150, "Jitter buffer size in milliseconds")
|
||||
staticDelayMs = flag.Int("static-delay-ms", 0, "Static playback delay in milliseconds for hardware latency compensation")
|
||||
logFile = flag.String("log-file", "sendspin-player.log", "Log file path")
|
||||
noTUI = flag.Bool("no-tui", false, "Disable TUI, use streaming logs instead")
|
||||
streamLogs = flag.Bool("stream-logs", false, "Alias for -no-tui")
|
||||
productName = flag.String("product-name", "", "Override the product name sent in device_info (default: compiled-in identity)")
|
||||
manufacturer = flag.String("manufacturer", "", "Override the manufacturer sent in device_info (default: compiled-in identity)")
|
||||
noReconnect = flag.Bool("no-reconnect", false, "Disable automatic reconnect on connection loss")
|
||||
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
|
||||
preferredCodec = flag.String("preferred-codec", "", "Preferred codec: pcm (default), opus, or flac")
|
||||
bufferCapacity = flag.Int("buffer-capacity", 1048576, "Buffer capacity in bytes advertised to server (default: 1MB)")
|
||||
clientID = flag.String("client-id", "", "Override the persisted client_id. When set, the value is written to the config file and reused on subsequent launches.")
|
||||
configPath = flag.String("config", "", "Path to player.yaml config file. Default search: $SENDSPIN_PLAYER_CONFIG, ~/.config/sendspin/player.yaml, /etc/sendspin/player.yaml.")
|
||||
audioDevice = flag.String("audio-device", "", "Playback device name (see --list-audio-devices). Empty = miniaudio default.")
|
||||
listAudio = flag.Bool("list-audio-devices", false, "List available playback devices and exit.")
|
||||
maxSampleRate = flag.Int("max-sample-rate", 0, "Cap the highest sample rate advertised to the server. 0 = auto-probe the audio device. Set explicitly when the auto-probe is wrong (e.g. Pi3 onboard headphones report 192k but can't actually drain it).")
|
||||
maxBitDepth = flag.Int("max-bit-depth", 0, "Cap the highest bit depth advertised to the server. 0 = auto-probe. Setting either --max-sample-rate or --max-bit-depth disables auto-probe entirely.")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *listAudio {
|
||||
if err := printPlaybackDevices(os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to list audio devices: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Overlay YAML file and SENDSPIN_PLAYER_* env vars onto flag vars for
|
||||
// anything the user didn't set on the CLI. client_id is handled
|
||||
// separately below because it has its own resolver and persistence.
|
||||
// list-audio-devices is already handled above and exits early.
|
||||
setByUser := map[string]bool{"client-id": true, "config": true, "list-audio-devices": true}
|
||||
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
|
||||
|
||||
cfg, loadedConfigPath, err := sendspin.LoadPlayerConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
log.Fatalf("config overlay: %v", err)
|
||||
}
|
||||
|
||||
// Use TUI if not explicitly disabled; -stream-logs and -daemon both imply -no-tui
|
||||
useTUI := !(*noTUI || *streamLogs || *daemon)
|
||||
|
||||
if *daemon {
|
||||
// Daemon mode: log to stdout only. systemd/journalctl captures stdout
|
||||
// and adds its own timestamps, so we keep ours for grep-ability.
|
||||
log.SetOutput(os.Stdout)
|
||||
} else {
|
||||
f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("error opening log file: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if useTUI {
|
||||
// Log to file only when TUI is running; otherwise the log would stomp the TUI
|
||||
log.SetOutput(f)
|
||||
} else {
|
||||
log.SetOutput(io.MultiWriter(os.Stdout, f))
|
||||
}
|
||||
}
|
||||
|
||||
playerName := *name
|
||||
if playerName == "" {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = "unknown"
|
||||
}
|
||||
playerName = fmt.Sprintf("%s-sendspin-player", hostname)
|
||||
}
|
||||
|
||||
// Set up sigChan before discovery so the select loop can catch Ctrl+C during browsing
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
if !useTUI {
|
||||
log.Printf("Starting Sendspin Player: %s (version %s)", playerName, version.Version)
|
||||
if *daemon {
|
||||
log.Printf("Daemon mode: logging to stdout only")
|
||||
}
|
||||
}
|
||||
|
||||
var tuiProg *tea.Program
|
||||
var volumeCtrl *ui.VolumeControl
|
||||
var transportCtrl *ui.TransportControl
|
||||
|
||||
if useTUI {
|
||||
volumeCtrl = ui.NewVolumeControl()
|
||||
transportCtrl = ui.NewTransportControl()
|
||||
var err error
|
||||
// Hand the picker a writable config path — fall back to the OS
|
||||
// default location if nothing was loaded, so selections still persist
|
||||
// on first-ever launch.
|
||||
uiConfigPath := loadedConfigPath
|
||||
if uiConfigPath == "" {
|
||||
if p, perr := sendspin.DefaultPlayerConfigPath(); perr == nil {
|
||||
uiConfigPath = p
|
||||
}
|
||||
}
|
||||
tuiProg, err = ui.Run(ui.Config{
|
||||
VolumeCtrl: volumeCtrl,
|
||||
TransportCtrl: transportCtrl,
|
||||
ConfigPath: uiConfigPath,
|
||||
AudioDevice: *audioDevice,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start TUI: %v", err)
|
||||
}
|
||||
go tuiProg.Run()
|
||||
}
|
||||
|
||||
updateTUI := func(msg ui.StatusMsg) {
|
||||
if tuiProg != nil {
|
||||
tuiProg.Send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
var serverAddress string
|
||||
var disc *discovery.Manager
|
||||
if *serverAddr == "" {
|
||||
log.Printf("Searching for servers via mDNS (press Ctrl+C to quit)...")
|
||||
disc = discovery.NewManager(discovery.Config{
|
||||
ServiceName: playerName,
|
||||
Port: *port,
|
||||
})
|
||||
disc.Advertise()
|
||||
disc.Browse()
|
||||
|
||||
// Wait for server discovery or shutdown
|
||||
select {
|
||||
case server := <-disc.Servers():
|
||||
serverAddress = fmt.Sprintf("%s:%d", server.Host, server.Port)
|
||||
log.Printf("Discovered server at %s", serverAddress)
|
||||
case sig := <-sigChan:
|
||||
log.Printf("Received %v during discovery, shutting down", sig)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
serverAddress = *serverAddr
|
||||
}
|
||||
|
||||
deviceProduct := version.Product
|
||||
if *productName != "" {
|
||||
deviceProduct = *productName
|
||||
}
|
||||
deviceManufacturer := version.Manufacturer
|
||||
if *manufacturer != "" {
|
||||
deviceManufacturer = *manufacturer
|
||||
}
|
||||
|
||||
resolvedClientID, err := resolveClientID(*clientID, cfg, loadedConfigPath)
|
||||
if err != nil {
|
||||
log.Fatalf("client_id: %v", err)
|
||||
}
|
||||
|
||||
config := sendspin.PlayerConfig{
|
||||
ServerAddr: serverAddress,
|
||||
PlayerName: playerName,
|
||||
Volume: 100,
|
||||
BufferMs: *bufferMs,
|
||||
StaticDelayMs: *staticDelayMs,
|
||||
PreferredCodec: *preferredCodec,
|
||||
BufferCapacity: *bufferCapacity,
|
||||
ClientID: resolvedClientID,
|
||||
AudioDevice: *audioDevice,
|
||||
MaxSampleRate: *maxSampleRate,
|
||||
MaxBitDepth: *maxBitDepth,
|
||||
DeviceInfo: sendspin.DeviceInfo{
|
||||
ProductName: deviceProduct,
|
||||
Manufacturer: deviceManufacturer,
|
||||
SoftwareVersion: version.Version,
|
||||
},
|
||||
OnStateChange: func(state sendspin.PlayerState) {
|
||||
updateTUI(ui.StatusMsg{
|
||||
Codec: state.Codec,
|
||||
SampleRate: state.SampleRate,
|
||||
Channels: state.Channels,
|
||||
BitDepth: state.BitDepth,
|
||||
PlaybackState: state.State,
|
||||
})
|
||||
connected := state.Connected
|
||||
serverLabel := serverAddress
|
||||
if state.State == "reconnecting" {
|
||||
serverLabel = "reconnecting..."
|
||||
}
|
||||
updateTUI(ui.StatusMsg{
|
||||
Connected: &connected,
|
||||
ServerName: serverLabel,
|
||||
})
|
||||
},
|
||||
OnMetadata: func(meta sendspin.Metadata) {
|
||||
updateTUI(ui.StatusMsg{
|
||||
Title: meta.Title,
|
||||
Artist: meta.Artist,
|
||||
Album: meta.Album,
|
||||
})
|
||||
},
|
||||
OnError: func(err error) {
|
||||
log.Printf("Player error: %v", err)
|
||||
},
|
||||
Reconnect: sendspin.ReconnectConfig{
|
||||
Enabled: !*noReconnect,
|
||||
},
|
||||
}
|
||||
|
||||
if !*noReconnect && disc != nil {
|
||||
config.Reconnect.Rediscover = func(ctx context.Context) (string, error) {
|
||||
disc.Browse()
|
||||
select {
|
||||
case server := <-disc.Servers():
|
||||
addr := fmt.Sprintf("%s:%d", server.Host, server.Port)
|
||||
log.Printf("Rediscovered server at %s", addr)
|
||||
return addr, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
return "", fmt.Errorf("rediscover timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player, err := sendspin.NewPlayer(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create player: %v", err)
|
||||
}
|
||||
|
||||
if err := player.Connect(); err != nil {
|
||||
log.Fatalf("Connection failed: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Connected to server: %s", serverAddress)
|
||||
|
||||
if volumeCtrl != nil {
|
||||
go handleVolumeControl(player, volumeCtrl)
|
||||
}
|
||||
|
||||
if transportCtrl != nil {
|
||||
go handleTransportControl(player, transportCtrl)
|
||||
}
|
||||
|
||||
if tuiProg != nil {
|
||||
go statsUpdateLoop(player, updateTUI)
|
||||
}
|
||||
|
||||
if volumeCtrl != nil {
|
||||
select {
|
||||
case <-volumeCtrl.Quit:
|
||||
log.Printf("Received quit signal from TUI")
|
||||
case <-sigChan:
|
||||
log.Printf("Shutdown signal received")
|
||||
}
|
||||
} else {
|
||||
<-sigChan
|
||||
log.Printf("Shutdown signal received")
|
||||
}
|
||||
|
||||
if err := player.Close(); err != nil {
|
||||
log.Printf("Error closing player: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Player stopped")
|
||||
}
|
||||
|
||||
func handleVolumeControl(player *sendspin.Player, volumeCtrl *ui.VolumeControl) {
|
||||
for {
|
||||
select {
|
||||
case vol := <-volumeCtrl.Changes:
|
||||
log.Printf("Volume change: %d%%, muted=%v", vol.Volume, vol.Muted)
|
||||
player.SetVolume(vol.Volume)
|
||||
player.Mute(vol.Muted)
|
||||
case <-volumeCtrl.Quit:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleTransportControl(player *sendspin.Player, ctrl *ui.TransportControl) {
|
||||
for cmd := range ctrl.Commands {
|
||||
if !player.Status().Connected {
|
||||
log.Printf("Transport command %q ignored: not connected", cmd.Command)
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
switch cmd.Command {
|
||||
case "toggle":
|
||||
// Toggle sends "pause" if server is playing, "play" otherwise.
|
||||
// The server decides the actual state; we just request it.
|
||||
status := player.Status()
|
||||
if status.State == "playing" {
|
||||
err = player.SendCommand("pause")
|
||||
} else {
|
||||
err = player.SendCommand("play")
|
||||
}
|
||||
case "play":
|
||||
err = player.SendCommand("play")
|
||||
case "pause":
|
||||
err = player.SendCommand("pause")
|
||||
case "next":
|
||||
err = player.SendCommand("next")
|
||||
case "previous":
|
||||
err = player.SendCommand("previous")
|
||||
case "reconnect":
|
||||
log.Printf("Manual reconnect requested")
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Transport command %q failed: %v", cmd.Command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printPlaybackDevices enumerates every playback device miniaudio can see
|
||||
// and prints them in a format a user can copy/paste into --audio-device or
|
||||
// the audio_device: key in player.yaml. The '[*]' marker identifies the
|
||||
// device miniaudio considers its current default.
|
||||
func printPlaybackDevices(w io.Writer) error {
|
||||
devices, err := output.ListPlaybackDevices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
fmt.Fprintln(w, "No playback devices found.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(w, "Playback devices:")
|
||||
for _, d := range devices {
|
||||
marker := "[ ]"
|
||||
if d.IsDefault {
|
||||
marker = "[*]"
|
||||
}
|
||||
fmt.Fprintf(w, " %s %q\n", marker, d.Name)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "[*] = current default. Use --audio-device \"<name>\" or set audio_device: in player.yaml.")
|
||||
fmt.Fprintln(w, "On Linux, miniaudio names look like \"<short>, <description>\"; either the full name or just the short part before the comma will match.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveClientID chooses the player's client_id and arranges for it to be
|
||||
// persisted to the config file when a new value is generated or when a CLI
|
||||
// override differs from the on-disk value. Separated from main() purely for
|
||||
// readability — this is the only place the three inputs (flag, file, MAC)
|
||||
// and the write-back path come together.
|
||||
func resolveClientID(override string, cfg *sendspin.PlayerConfigFile, loadedConfigPath string) (string, error) {
|
||||
persistPath := loadedConfigPath
|
||||
if persistPath == "" {
|
||||
p, err := sendspin.DefaultPlayerConfigPath()
|
||||
if err != nil {
|
||||
// No writable config dir. Proceed without write-back; a generated
|
||||
// UUID will be stable for this session only, and the log line from
|
||||
// ResolveClientID will show (source: generated) with no "persisted".
|
||||
log.Printf("client_id: no config path available for persistence: %v", err)
|
||||
} else {
|
||||
persistPath = p
|
||||
}
|
||||
}
|
||||
|
||||
var persist sendspin.PersistFn
|
||||
if persistPath != "" {
|
||||
persist = func(id string) error {
|
||||
return sendspin.WriteStringKey(persistPath, "client_id", id)
|
||||
}
|
||||
}
|
||||
|
||||
var fromConfig string
|
||||
if cfg != nil {
|
||||
fromConfig = cfg.ClientID
|
||||
}
|
||||
|
||||
return sendspin.ResolveClientID(override, fromConfig, persist)
|
||||
}
|
||||
|
||||
func statsUpdateLoop(player *sendspin.Player, updateTUI func(ui.StatusMsg)) {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
stats := player.Stats()
|
||||
|
||||
// NumGoroutine is cheap; ReadMemStats removed to avoid stop-the-world pauses
|
||||
updateTUI(ui.StatusMsg{
|
||||
Received: stats.Received,
|
||||
Played: stats.Played,
|
||||
Dropped: stats.Dropped,
|
||||
BufferDepth: stats.BufferDepth,
|
||||
SyncRTT: stats.SyncRTT,
|
||||
SyncQuality: stats.SyncQuality,
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user