12 KiB
sendspin-server: YAML config file + daemon mode
Status: Design approved 2026-04-20
Tracks: parity with sendspin-player config (closes equivalent of #40 for the server side)
Problem
sendspin-server has no config-file support and no --daemon flag. To run it
under systemd today, an operator has to stuff every option into ExecStart or
an env file, and logging fights journalctl because the binary always opens
sendspin-server.log. The player solved this with a three-layer precedence
model (CLI > env > YAML > default), WriteStringKey for comment-preserving
round-trips, and a --daemon flag that logs to stdout only. The server should
get the same treatment.
Goals
- YAML config file at
/etc/sendspin/server.yamlor~/.config/sendspin/server.yamlwith the same search-order contract asplayer.yaml. SENDSPIN_SERVER_*env var overlay with the same precedence rules as the player.- A
--daemonflag that logs to stdout only (journalctl-friendly) and suppresses both the TUI and the log file. systemctl enable --now sendspin-serverworks aftermake install-daemon.- Zero behavior change for users who don't opt in to any of the above.
Non-goals
- No write-back. The server has no analog of the player's
client_id; mDNS rediscovery handles instance identity at the network layer.WriteStringKeystays unused by the server (and stays generic in case future features need it). - No
-stream-logsalias. The player has it for historical reasons; the server starts clean without the redundant alias. - No unified
ConfigFilestruct. Each binary keeps its own typed struct. Only the machinery (search paths, flag overlay) is shared.
Approach
Refactor pkg/sendspin/config.go so the generic machinery takes a
(envPrefix, map[string]string) pair instead of a typed *PlayerConfigFile,
then add a parallel ServerConfigFile / LoadServerConfig /
DefaultServerConfigPath surface alongside the player's. The player's
asStringMap() already produces exactly the map the refactored function
needs, so the call-site change is one line.
No public API outside pkg/sendspin uses the old ApplyEnvAndFile signature
(verified by grep: only main.go, config.go, and config_test.go). The
technically-breaking signature change is contained.
Architecture
pkg/sendspin/config.go — refactor
Change:
// Before
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, cfg *PlayerConfigFile) error
// After
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error
New unexported helpers:
loadYAMLConfig(searchPaths []string, out any) (string, error)— opens the first existing file, unmarshals intoout. Missing files are not an error ((nil, "", nil)equivalent).userConfigPath(relative string) (string, error)— wrapsos.UserConfigDir() + "/sendspin/" + relative. Used by bothDefaultPlayerConfigPathandDefaultServerConfigPath.
Existing public surface (WriteStringKey, topLevelMapping, setOrAppendStringKey, atomicWriteFile) is already generic and unchanged.
pkg/sendspin/config.go — new server surface
const ServerEnvPrefix = "SENDSPIN_SERVER_"
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"`
}
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error)
func DefaultServerConfigPath() (string, error)
func (c *ServerConfigFile) asStringMap() map[string]string
LoadServerConfig search order (first existing wins; missing is not an error):
explicitPathif non-empty$SENDSPIN_SERVER_CONFIG<UserConfigDir>/sendspin/server.yaml/etc/sendspin/server.yaml
Player call-site update
// main.go (player)
if err := sendspin.ApplyEnvAndFile(
flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap(),
); err != nil { ... }
cfg.asStringMap() on a nil *PlayerConfigFile must keep returning an empty map (existing behavior — confirmed in TestApplyEnvAndFile_NilConfigStillHonorsEnv). Preserve that contract.
cmd/sendspin-server/main.go — new flags & wiring
Add:
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
configPath = flag.String("config", "", "Path to server.yaml config file. Default search: $SENDSPIN_SERVER_CONFIG, ~/.config/sendspin/server.yaml, /etc/sendspin/server.yaml.")
After flag.Parse():
setByUser := map[string]bool{"config": true}
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
cfg, _, err := sendspin.LoadServerConfig(*configPath)
if err != nil { log.Fatalf("config: %v", err) }
if err := sendspin.ApplyEnvAndFile(
flag.CommandLine, setByUser, sendspin.ServerEnvPrefix, cfg.asStringMap(),
); err != nil { log.Fatalf("config overlay: %v", err) }
Daemon mode
useTUI := !(*noTUI || *daemon)- Logging branch:
-daemon→log.SetOutput(os.Stdout); skip opening*logFile.- TUI on → log to file only (unchanged).
- Neither →
io.MultiWriter(os.Stdout, f)(unchanged).
- Non-TUI startup banner logs
"Starting Sendspin Server: %s (port %d)"and, when-daemon, adds"Daemon mode: logging to stdout only".
Distribution artifacts
dist/systemd/sendspin-server.service — shape mirrors sendspin-player.service:
[Unit]
Description=Sendspin Server
Documentation=https://github.com/Sendspin/sendspin-go
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sendspin-server --daemon
Restart=on-failure
RestartSec=5
EnvironmentFile=-/etc/default/sendspin-server
ExecStart=
ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true
[Install]
WantedBy=multi-user.target
ProtectHome=read-only (not yes) so -audio /home/user/Music/... still works.
dist/systemd/sendspin-server.env — operator-editable env file installed to /etc/default/sendspin-server:
# sendspin-server: extra CLI flags appended to ExecStart
# Prefer /etc/sendspin/server.yaml for structured config; use this for
# ad-hoc overrides or keys that aren't (yet) in the YAML.
# SENDSPIN_SERVER_OPTS="--debug --discover-clients"
SENDSPIN_SERVER_OPTS=""
dist/config/server.example.yaml — annotated starter, every key commented out so the file is a no-op until edited:
# sendspin-server configuration
#
# Search order (first existing file wins; missing is not an error):
# 1. --config <path>
# 2. $SENDSPIN_SERVER_CONFIG
# 3. ~/.config/sendspin/server.yaml (user install)
# 4. /etc/sendspin/server.yaml (daemon / system-wide)
#
# Per-value precedence for every key below:
# CLI flag > SENDSPIN_SERVER_<UPPER_SNAKE> env > this file > built-in default
# --- Identity ---
# name: "Living Room Server"
# --- Network ---
# port: 8927
# no_mdns: false
# discover_clients: false
# --- Audio source ---
# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone.
# audio: "/srv/music/radio.m3u8"
# --- Logging / runtime ---
# daemon: false
# no_tui: false
# log_file: "sendspin-server.log"
# debug: false
Makefile
Split install-daemon / uninstall-daemon into leaf + aggregate targets:
install-player-daemon— existing player install logic, renamed.install-server-daemon— new; installs binary to/usr/local/bin, unit file to/etc/systemd/system, env file to/etc/default/sendspin-server, example YAML to/etc/sendspin/server.yaml. Each of the two editable files is installed only if absent (guard pattern from the player).install-daemon— aggregate:install-player-daemon install-server-daemon.uninstall-server-daemon— stops/disables unit, removes binary and unit file, leaves/etc/default/sendspin-serverand/etc/sendspin/server.yamlintact.uninstall-daemon— aggregate.
clean already removes sendspin-server; no change.
Flag → YAML key mapping
| Flag | YAML key | Type | Default |
|---|---|---|---|
-port |
port |
*int |
8927 |
-name |
name |
string |
<hostname>-sendspin-server |
-log-file |
log_file |
string |
sendspin-server.log |
-debug |
debug |
*bool |
false |
-no-mdns |
no_mdns |
*bool |
false |
-no-tui |
no_tui |
*bool |
false |
-audio |
audio |
string |
(empty → test tone) |
-discover-clients |
discover_clients |
*bool |
false |
-daemon |
daemon |
*bool |
false |
-config |
(not in YAML — circular) | string |
(empty) |
Precedence
For every key: CLI flag > SENDSPIN_SERVER_<UPPER_SNAKE> env > server.yaml > built-in default.
Matches the player exactly. Enforced by the shared ApplyEnvAndFile helper.
Error handling
- Invalid YAML →
log.Fatalf("config: %v", err)at startup. - Invalid env var (e.g.,
SENDSPIN_SERVER_PORT=abc) → fatal; error message includes the offending flag name. Behavior inherited from the shared helper. - Missing config file → silent no-op; all keys fall through to flag defaults. This is the documented contract.
-audiovalidation stays inserver.NewAudioSource; config loading only carries the string.-daemoncombined with TUI flags → daemon wins, no warning. Matches player behavior.
Testing
Existing player tests (pkg/sendspin/config_test.go) — each ApplyEnvAndFile(..., cfg) call-site must be rewritten to ApplyEnvAndFile(..., PlayerEnvPrefix, cfg.asStringMap()) to match the new signature. TestApplyEnvAndFile_NilConfigStillHonorsEnv passes a nil map instead of a nil struct (a nil map[string]string iterates as empty, so the env-only path behaves identically). No assertion changes; the precedence tests then exercise the shared code path for both binaries.
New server tests — structural coverage only:
TestLoadServerConfig_ExplicitPathWithAllKeys— round-trip everyServerConfigFilefield through YAML.TestLoadServerConfig_MissingFileIsNotAnError— contract symmetry.TestLoadServerConfig_EnvPathHonored—SENDSPIN_SERVER_CONFIGpicked up.TestApplyEnvAndFile_ServerEnvPrefix— confirms the generalizedenvPrefixparameter routesSENDSPIN_SERVER_*correctly.
No new WriteStringKey tests (server doesn't use write-back).
Manual verification (part of the plan's completion step)
sendspin-serverwith no config file → unchanged behavior.sendspin-server --config /tmp/s.yamlwithport: 9000→ binds 9000.SENDSPIN_SERVER_PORT=9001 sendspin-server --config /tmp/s.yaml→ binds 9001 (env beats file).sendspin-server --port 9002 --config /tmp/s.yaml→ binds 9002 (CLI beats env + file).sendspin-server --daemon→ no TUI, stdout logging with timestamps, nosendspin-server.logcreated.- Linux box:
make install-daemon && systemctl enable --now sendspin-server && journalctl -u sendspin-server -f→ clean startup.
Out-of-scope (future work)
server_id/ persistent identity for the server.- A unified
pkg/sendspin/configfilesub-package if a third binary ever joins (YAGNI until then). - README deep-dive on daemon operation — this work adds a one-line pointer only.