396 lines
13 KiB
Go
396 lines
13 KiB
Go
// ABOUTME: YAML config-file support for the player and server (paths, env overlay, write-back)
|
|
// ABOUTME: Flat keys 1:1 with CLI flags; precedence CLI > env > file > built-in default
|
|
package sendspin
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// PlayerEnvPrefix is the namespace for environment overrides of player config
|
|
// values. Env key = PlayerEnvPrefix + upper-snake(flag name). Example:
|
|
// "-buffer-ms" -> SENDSPIN_PLAYER_BUFFER_MS.
|
|
const PlayerEnvPrefix = "SENDSPIN_PLAYER_"
|
|
|
|
// PlayerConfigFile mirrors the player's CLI flags. Fields with "zero" values
|
|
// that could reasonably be meaningful (bool, int) are pointers so absence in
|
|
// the YAML can be distinguished from an explicit false/0.
|
|
type PlayerConfigFile struct {
|
|
Name string `yaml:"name,omitempty"`
|
|
Server string `yaml:"server,omitempty"`
|
|
Port *int `yaml:"port,omitempty"`
|
|
BufferMs *int `yaml:"buffer_ms,omitempty"`
|
|
StaticDelayMs *int `yaml:"static_delay_ms,omitempty"`
|
|
LogFile string `yaml:"log_file,omitempty"`
|
|
NoTUI *bool `yaml:"no_tui,omitempty"`
|
|
StreamLogs *bool `yaml:"stream_logs,omitempty"`
|
|
ProductName string `yaml:"product_name,omitempty"`
|
|
Manufacturer string `yaml:"manufacturer,omitempty"`
|
|
NoReconnect *bool `yaml:"no_reconnect,omitempty"`
|
|
Daemon *bool `yaml:"daemon,omitempty"`
|
|
PreferredCodec string `yaml:"preferred_codec,omitempty"`
|
|
BufferCapacity *int `yaml:"buffer_capacity,omitempty"`
|
|
ClientID string `yaml:"client_id,omitempty"`
|
|
AudioDevice string `yaml:"audio_device,omitempty"`
|
|
MaxSampleRate *int `yaml:"max_sample_rate,omitempty"`
|
|
MaxBitDepth *int `yaml:"max_bit_depth,omitempty"`
|
|
}
|
|
|
|
// loadYAMLConfig walks searchPaths, and for the first one that exists opens
|
|
// the file and unmarshals into out. It returns the path that was loaded, or
|
|
// empty if no candidate existed. A missing file is not an error; I/O or
|
|
// parse errors are returned as-is with the offending path attached.
|
|
func loadYAMLConfig(searchPaths []string, out any) (string, error) {
|
|
for _, candidate := range searchPaths {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(candidate)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return candidate, fmt.Errorf("read %s: %w", candidate, err)
|
|
}
|
|
if err := yaml.Unmarshal(data, out); err != nil {
|
|
return candidate, fmt.Errorf("parse %s: %w", candidate, err)
|
|
}
|
|
return candidate, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// LoadPlayerConfig searches for a player.yaml and returns its parsed contents
|
|
// along with the path that was loaded (empty if none was found).
|
|
//
|
|
// Search order (first existing wins):
|
|
// 1. explicitPath if non-empty
|
|
// 2. $SENDSPIN_PLAYER_CONFIG if set
|
|
// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/player.yaml
|
|
// 4. /etc/sendspin/player.yaml
|
|
//
|
|
// A missing file is not an error; the caller gets (nil, "", nil).
|
|
func LoadPlayerConfig(explicitPath string) (*PlayerConfigFile, string, error) {
|
|
var cfg PlayerConfigFile
|
|
used, err := loadYAMLConfig(playerConfigSearchPaths(explicitPath), &cfg)
|
|
if err != nil {
|
|
return nil, used, err
|
|
}
|
|
if used == "" {
|
|
return nil, "", nil
|
|
}
|
|
return &cfg, used, nil
|
|
}
|
|
|
|
// userConfigPath returns <UserConfigDir>/sendspin/<relative>. Matches the
|
|
// canonical path layout for both player.yaml and server.yaml.
|
|
func userConfigPath(relative string) (string, error) {
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("user config dir: %w", err)
|
|
}
|
|
return filepath.Join(dir, "sendspin", relative), nil
|
|
}
|
|
|
|
// DefaultPlayerConfigPath returns the canonical user-level player.yaml path
|
|
// for this OS. Used when we need to auto-create the config for write-back.
|
|
func DefaultPlayerConfigPath() (string, error) {
|
|
return userConfigPath("player.yaml")
|
|
}
|
|
|
|
func playerConfigSearchPaths(explicit string) []string {
|
|
paths := make([]string, 0, 4)
|
|
if explicit != "" {
|
|
paths = append(paths, explicit)
|
|
}
|
|
if env := os.Getenv("SENDSPIN_PLAYER_CONFIG"); env != "" {
|
|
paths = append(paths, env)
|
|
}
|
|
if p, err := userConfigPath("player.yaml"); err == nil {
|
|
paths = append(paths, p)
|
|
}
|
|
paths = append(paths, "/etc/sendspin/player.yaml")
|
|
return paths
|
|
}
|
|
|
|
// ApplyEnvAndFile overlays <envPrefix> env vars and YAML config-file values
|
|
// into the given FlagSet, but only for flags the user did NOT set on the CLI.
|
|
// Precedence: CLI (untouched here) > env > file > flag default.
|
|
//
|
|
// envPrefix is the namespace for env-var lookups (e.g. "SENDSPIN_PLAYER_").
|
|
// fileValues is the flat flag-key → value map the caller derives from its
|
|
// typed config struct (see PlayerConfigFile.AsStringMap and
|
|
// ServerConfigFile.AsStringMap). A nil map is treated as empty.
|
|
//
|
|
// setByUser is typically built with flag.Visit before calling this.
|
|
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error {
|
|
var firstErr error
|
|
fs.VisitAll(func(f *flag.Flag) {
|
|
if firstErr != nil || setByUser[f.Name] {
|
|
return
|
|
}
|
|
envKey := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
|
|
if val, ok := os.LookupEnv(envKey); ok {
|
|
if err := fs.Set(f.Name, val); err != nil {
|
|
firstErr = fmt.Errorf("env %s -> -%s: %w", envKey, f.Name, err)
|
|
}
|
|
return
|
|
}
|
|
configKey := strings.ReplaceAll(f.Name, "-", "_")
|
|
if val, ok := fileValues[configKey]; ok {
|
|
if err := fs.Set(f.Name, val); err != nil {
|
|
firstErr = fmt.Errorf("config %s -> -%s: %w", configKey, f.Name, err)
|
|
}
|
|
}
|
|
})
|
|
return firstErr
|
|
}
|
|
|
|
// AsStringMap returns only the keys the user actually set in the YAML, as
|
|
// strings suitable for flag.Set. Absent keys are omitted so the overlay
|
|
// correctly falls through to the flag default.
|
|
func (c *PlayerConfigFile) AsStringMap() map[string]string {
|
|
m := make(map[string]string)
|
|
if c == nil {
|
|
return m
|
|
}
|
|
if c.Name != "" {
|
|
m["name"] = c.Name
|
|
}
|
|
if c.Server != "" {
|
|
m["server"] = c.Server
|
|
}
|
|
if c.Port != nil {
|
|
m["port"] = strconv.Itoa(*c.Port)
|
|
}
|
|
if c.BufferMs != nil {
|
|
m["buffer_ms"] = strconv.Itoa(*c.BufferMs)
|
|
}
|
|
if c.StaticDelayMs != nil {
|
|
m["static_delay_ms"] = strconv.Itoa(*c.StaticDelayMs)
|
|
}
|
|
if c.LogFile != "" {
|
|
m["log_file"] = c.LogFile
|
|
}
|
|
if c.NoTUI != nil {
|
|
m["no_tui"] = strconv.FormatBool(*c.NoTUI)
|
|
}
|
|
if c.StreamLogs != nil {
|
|
m["stream_logs"] = strconv.FormatBool(*c.StreamLogs)
|
|
}
|
|
if c.ProductName != "" {
|
|
m["product_name"] = c.ProductName
|
|
}
|
|
if c.Manufacturer != "" {
|
|
m["manufacturer"] = c.Manufacturer
|
|
}
|
|
if c.NoReconnect != nil {
|
|
m["no_reconnect"] = strconv.FormatBool(*c.NoReconnect)
|
|
}
|
|
if c.Daemon != nil {
|
|
m["daemon"] = strconv.FormatBool(*c.Daemon)
|
|
}
|
|
if c.PreferredCodec != "" {
|
|
m["preferred_codec"] = c.PreferredCodec
|
|
}
|
|
if c.BufferCapacity != nil {
|
|
m["buffer_capacity"] = strconv.Itoa(*c.BufferCapacity)
|
|
}
|
|
if c.ClientID != "" {
|
|
m["client_id"] = c.ClientID
|
|
}
|
|
if c.AudioDevice != "" {
|
|
m["audio_device"] = c.AudioDevice
|
|
}
|
|
if c.MaxSampleRate != nil {
|
|
m["max_sample_rate"] = strconv.Itoa(*c.MaxSampleRate)
|
|
}
|
|
if c.MaxBitDepth != nil {
|
|
m["max_bit_depth"] = strconv.Itoa(*c.MaxBitDepth)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// WriteStringKey reads the YAML at path (if any), sets the given top-level
|
|
// string key to value, and atomically writes the result back. Comments and
|
|
// existing keys are preserved via yaml.Node round-tripping. Used to persist
|
|
// the auto-generated client_id and the --client-id override.
|
|
func WriteStringKey(path, key, value string) error {
|
|
var root yaml.Node
|
|
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
|
return fmt.Errorf("parse existing config %s: %w", path, err)
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
mapping := topLevelMapping(&root)
|
|
|
|
setOrAppendStringKey(mapping, key, value)
|
|
|
|
buf, err := yaml.Marshal(&root)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal config: %w", err)
|
|
}
|
|
return atomicWriteFile(path, buf)
|
|
}
|
|
|
|
// topLevelMapping returns the mapping node that backs the top of a YAML
|
|
// document. If root is empty or non-document, it's initialized in place.
|
|
func topLevelMapping(root *yaml.Node) *yaml.Node {
|
|
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 && root.Content[0].Kind == yaml.MappingNode {
|
|
return root.Content[0]
|
|
}
|
|
mapping := &yaml.Node{Kind: yaml.MappingNode}
|
|
root.Kind = yaml.DocumentNode
|
|
root.Content = []*yaml.Node{mapping}
|
|
return mapping
|
|
}
|
|
|
|
// setOrAppendStringKey updates the value for key in a MappingNode, or appends
|
|
// a new key/value pair if the key is not present. Leaves all other entries
|
|
// (and their comments) untouched.
|
|
func setOrAppendStringKey(mapping *yaml.Node, key, value string) {
|
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
|
if mapping.Content[i].Value == key {
|
|
mapping.Content[i+1].Kind = yaml.ScalarNode
|
|
mapping.Content[i+1].Tag = "!!str"
|
|
mapping.Content[i+1].Value = value
|
|
mapping.Content[i+1].Style = 0
|
|
return
|
|
}
|
|
}
|
|
mapping.Content = append(mapping.Content,
|
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
|
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value},
|
|
)
|
|
}
|
|
|
|
// ServerEnvPrefix is the namespace for environment overrides of server
|
|
// config values. Env key = ServerEnvPrefix + upper-snake(flag name).
|
|
// Example: "-no-mdns" -> SENDSPIN_SERVER_NO_MDNS.
|
|
const ServerEnvPrefix = "SENDSPIN_SERVER_"
|
|
|
|
// ServerConfigFile mirrors the server's CLI flags. Fields with "zero" values
|
|
// that could reasonably be meaningful (bool) are pointers so absence in
|
|
// the YAML can be distinguished from an explicit false.
|
|
type ServerConfigFile struct {
|
|
Name string `yaml:"name,omitempty"`
|
|
Port *int `yaml:"port,omitempty"`
|
|
LogFile string `yaml:"log_file,omitempty"`
|
|
Debug *bool `yaml:"debug,omitempty"`
|
|
NoMDNS *bool `yaml:"no_mdns,omitempty"`
|
|
NoTUI *bool `yaml:"no_tui,omitempty"`
|
|
Audio string `yaml:"audio,omitempty"`
|
|
DiscoverClients *bool `yaml:"discover_clients,omitempty"`
|
|
Daemon *bool `yaml:"daemon,omitempty"`
|
|
}
|
|
|
|
// LoadServerConfig searches for a server.yaml and returns its parsed contents
|
|
// along with the path that was loaded (empty if none was found).
|
|
//
|
|
// Search order (first existing wins):
|
|
// 1. explicitPath if non-empty
|
|
// 2. $SENDSPIN_SERVER_CONFIG if set
|
|
// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/server.yaml
|
|
// 4. /etc/sendspin/server.yaml
|
|
//
|
|
// A missing file is not an error; the caller gets (nil, "", nil).
|
|
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error) {
|
|
var cfg ServerConfigFile
|
|
used, err := loadYAMLConfig(serverConfigSearchPaths(explicitPath), &cfg)
|
|
if err != nil {
|
|
return nil, used, err
|
|
}
|
|
if used == "" {
|
|
return nil, "", nil
|
|
}
|
|
return &cfg, used, nil
|
|
}
|
|
|
|
// DefaultServerConfigPath returns the canonical user-level server.yaml path
|
|
// for this OS.
|
|
func DefaultServerConfigPath() (string, error) {
|
|
return userConfigPath("server.yaml")
|
|
}
|
|
|
|
func serverConfigSearchPaths(explicit string) []string {
|
|
paths := make([]string, 0, 4)
|
|
if explicit != "" {
|
|
paths = append(paths, explicit)
|
|
}
|
|
if env := os.Getenv("SENDSPIN_SERVER_CONFIG"); env != "" {
|
|
paths = append(paths, env)
|
|
}
|
|
if p, err := userConfigPath("server.yaml"); err == nil {
|
|
paths = append(paths, p)
|
|
}
|
|
paths = append(paths, "/etc/sendspin/server.yaml")
|
|
return paths
|
|
}
|
|
|
|
// AsStringMap returns only the keys the user actually set in the YAML, as
|
|
// strings suitable for flag.Set. Absent keys are omitted so the overlay
|
|
// correctly falls through to the flag default.
|
|
func (c *ServerConfigFile) AsStringMap() map[string]string {
|
|
m := make(map[string]string)
|
|
if c == nil {
|
|
return m
|
|
}
|
|
if c.Name != "" {
|
|
m["name"] = c.Name
|
|
}
|
|
if c.Port != nil {
|
|
m["port"] = strconv.Itoa(*c.Port)
|
|
}
|
|
if c.LogFile != "" {
|
|
m["log_file"] = c.LogFile
|
|
}
|
|
if c.Debug != nil {
|
|
m["debug"] = strconv.FormatBool(*c.Debug)
|
|
}
|
|
if c.NoMDNS != nil {
|
|
m["no_mdns"] = strconv.FormatBool(*c.NoMDNS)
|
|
}
|
|
if c.NoTUI != nil {
|
|
m["no_tui"] = strconv.FormatBool(*c.NoTUI)
|
|
}
|
|
if c.Audio != "" {
|
|
m["audio"] = c.Audio
|
|
}
|
|
if c.DiscoverClients != nil {
|
|
m["discover_clients"] = strconv.FormatBool(*c.DiscoverClients)
|
|
}
|
|
if c.Daemon != nil {
|
|
m["daemon"] = strconv.FormatBool(*c.Daemon)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// atomicWriteFile writes data to path via tempfile + rename. Matches the
|
|
// atomicity guarantees the old writePersistedClientID used to provide.
|
|
func atomicWriteFile(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", dir, err)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return fmt.Errorf("write temp: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|