Files

424 lines
13 KiB
Go

// 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(),
})
}
}