1279 lines
40 KiB
Markdown
1279 lines
40 KiB
Markdown
# sendspin-server YAML config + --daemon Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Give `sendspin-server` a YAML config file, `SENDSPIN_SERVER_*` env overlay, and a `--daemon` flag, plus the distribution artifacts needed to run it under systemd. Parity with `sendspin-player`.
|
|
|
|
**Architecture:** Refactor `pkg/sendspin/config.go` so the generic machinery (flag overlay, YAML load, config-dir lookup) takes `(envPrefix, map[string]string)` instead of a typed `*PlayerConfigFile`. Add a parallel `ServerConfigFile` / `LoadServerConfig` surface using the shared helpers. Wire the server CLI to load it. Ship `sendspin-server.service`, `sendspin-server.env`, and `server.example.yaml`; split `install-daemon` into per-binary leaf targets.
|
|
|
|
**Tech Stack:** Go 1.24+, `gopkg.in/yaml.v3`, Go stdlib `flag` package, systemd unit files, GNU Make.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-20-server-config-yaml-design.md`
|
|
|
|
**Environment note (from `memory/reference_cgo_toolchain.md`):** on Windows/MSYS2, prepend `/c/msys64/mingw64/bin` to `PATH` before any full-module build or test (`make`, `go build ./...`, `go test ./...`). The `pkg/sendspin` tests alone usually work without it — scope test runs narrowly unless told otherwise.
|
|
|
|
**Flaky test:** `TestServerStartStop` has been flaky on port 8929 on Chris's box (`memory/project_test_port_8929.md`) — not a regression. If it fails in the final verification step, skip it and move on.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
**Modify:**
|
|
- `pkg/sendspin/config.go` — refactor `ApplyEnvAndFile` signature; extract helpers; add server surface.
|
|
- `pkg/sendspin/config_test.go` — update existing call-sites for the new signature; add server tests.
|
|
- `main.go` (player, repo root) — single-line call-site update.
|
|
- `cmd/sendspin-server/main.go` — add `--config` and `--daemon` flags, wire config loading, daemon-mode logging branch.
|
|
- `Makefile` — split install/uninstall targets.
|
|
- `README.md` — add Server config-file section + daemon pointer.
|
|
|
|
**Create:**
|
|
- `dist/systemd/sendspin-server.service`
|
|
- `dist/systemd/sendspin-server.env`
|
|
- `dist/config/server.example.yaml`
|
|
|
|
---
|
|
|
|
### Task 1: Refactor `ApplyEnvAndFile` to take `(envPrefix, fileValues)` instead of a typed struct
|
|
|
|
**Files:**
|
|
- Modify: `pkg/sendspin/config.go:105-127`
|
|
- Modify: `pkg/sendspin/config_test.go:151, 186, 204, 219`
|
|
- Modify: `main.go:68` (player repo-root)
|
|
|
|
This is a pure signature refactor — no new behavior. All changes land in one commit because they have to compile together.
|
|
|
|
- [ ] **Step 1: Update the function signature and body**
|
|
|
|
Replace the existing `ApplyEnvAndFile` in `pkg/sendspin/config.go` with:
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update player call-site in `main.go`**
|
|
|
|
Find the line (around `main.go:68`):
|
|
|
|
```go
|
|
if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, cfg); err != nil {
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```go
|
|
if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap()); err != nil {
|
|
```
|
|
|
|
Wait — `asStringMap` is unexported and lives in package `sendspin`. From `main.go` (package `main`), we can't call it directly. We need either (a) export it, or (b) have the player pass the raw struct through a small exported wrapper.
|
|
|
|
Choose (a) — rename `asStringMap` to `AsStringMap` on `*PlayerConfigFile` and (in a later task) on `*ServerConfigFile`. Make this rename part of Step 1: update the method definition in `config.go:132` and every reference in `config_test.go`.
|
|
|
|
So replace the block above with two edits:
|
|
|
|
In `pkg/sendspin/config.go:132`:
|
|
|
|
```go
|
|
// Before
|
|
func (c *PlayerConfigFile) asStringMap() map[string]string {
|
|
|
|
// After
|
|
func (c *PlayerConfigFile) AsStringMap() map[string]string {
|
|
```
|
|
|
|
In `main.go` (player):
|
|
|
|
```go
|
|
if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
|
```
|
|
|
|
- [ ] **Step 3: Update the four existing test call-sites in `config_test.go`**
|
|
|
|
Line 151 (`TestApplyEnvAndFile_FileFillsUnsetFlags`):
|
|
|
|
```go
|
|
// Before
|
|
if err := ApplyEnvAndFile(fs, setByUser, cfg); err != nil {
|
|
|
|
// After
|
|
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
|
```
|
|
|
|
Line 186 (`TestApplyEnvAndFile_EnvBeatsFile`):
|
|
|
|
```go
|
|
// Before
|
|
if err := ApplyEnvAndFile(fs, map[string]bool{}, cfg); err != nil {
|
|
|
|
// After
|
|
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
|
```
|
|
|
|
Line 204 (`TestApplyEnvAndFile_NilConfigStillHonorsEnv`):
|
|
|
|
```go
|
|
// Before
|
|
if err := ApplyEnvAndFile(fs, map[string]bool{}, nil); err != nil {
|
|
|
|
// After
|
|
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil {
|
|
```
|
|
|
|
Line 219 (`TestApplyEnvAndFile_InvalidEnvReturnsError`):
|
|
|
|
```go
|
|
// Before
|
|
err := ApplyEnvAndFile(fs, map[string]bool{}, nil)
|
|
|
|
// After
|
|
err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil)
|
|
```
|
|
|
|
- [ ] **Step 4: Run the package tests and verify they still pass**
|
|
|
|
```bash
|
|
go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig|TestApplyEnvAndFile' -v
|
|
```
|
|
|
|
Expected: all 5 tests PASS (`TestLoadPlayerConfig_ExplicitPathWithAllKeys`, `..._MissingFileIsNotAnError`, `..._InvalidYAMLIsAnError`, `..._EnvPathHonored`, and the four `TestApplyEnvAndFile_*` plus `TestWriteStringKey_*`).
|
|
|
|
If cgo build errors appear (e.g., miniaudio), prepend `/c/msys64/mingw64/bin` to `PATH` per the memory note and retry.
|
|
|
|
- [ ] **Step 5: Build the player to catch any lingering call-site issues**
|
|
|
|
```bash
|
|
go build -o /dev/null .
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add pkg/sendspin/config.go pkg/sendspin/config_test.go main.go
|
|
git commit -m "refactor(sendspin): ApplyEnvAndFile takes envPrefix+map, not typed struct
|
|
|
|
Prepares the way for a second consumer (sendspin-server) to share the
|
|
same flag-overlay machinery. Player call-site passes cfg.AsStringMap()
|
|
and sendspin.PlayerEnvPrefix explicitly. asStringMap renamed to
|
|
AsStringMap so callers outside pkg/sendspin can use it.
|
|
|
|
No behavior change. All existing tests pass unchanged except for
|
|
signature updates at four call-sites."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Extract `loadYAMLConfig` helper
|
|
|
|
**Files:**
|
|
- Modify: `pkg/sendspin/config.go`
|
|
|
|
Pull the generic "search paths, open first existing, YAML-unmarshal into `out`" logic out of `LoadPlayerConfig` so the upcoming `LoadServerConfig` can call it too. Pure refactor.
|
|
|
|
- [ ] **Step 1: Add the helper function**
|
|
|
|
Add above `LoadPlayerConfig`:
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Refactor `LoadPlayerConfig` to use it**
|
|
|
|
Replace the body:
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the package tests**
|
|
|
|
```bash
|
|
go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig' -v
|
|
```
|
|
|
|
Expected: all `TestLoadPlayerConfig_*` tests PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add pkg/sendspin/config.go
|
|
git commit -m "refactor(sendspin): extract loadYAMLConfig helper
|
|
|
|
Prepares for a second consumer. No behavior change."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Extract `userConfigPath` helper
|
|
|
|
**Files:**
|
|
- Modify: `pkg/sendspin/config.go`
|
|
|
|
- [ ] **Step 1: Add the helper**
|
|
|
|
Add above `DefaultPlayerConfigPath`:
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Refactor `DefaultPlayerConfigPath` and `playerConfigSearchPaths`**
|
|
|
|
Replace `DefaultPlayerConfigPath`:
|
|
|
|
```go
|
|
func DefaultPlayerConfigPath() (string, error) {
|
|
return userConfigPath("player.yaml")
|
|
}
|
|
```
|
|
|
|
In `playerConfigSearchPaths`, replace the `os.UserConfigDir()` block with:
|
|
|
|
```go
|
|
if p, err := userConfigPath("player.yaml"); err == nil {
|
|
paths = append(paths, p)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the package tests**
|
|
|
|
```bash
|
|
go test ./pkg/sendspin/ -v
|
|
```
|
|
|
|
Expected: all tests in the package PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add pkg/sendspin/config.go
|
|
git commit -m "refactor(sendspin): extract userConfigPath helper
|
|
|
|
Shared between DefaultPlayerConfigPath and playerConfigSearchPaths;
|
|
ready for the server's analogous use. No behavior change."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add `ServerConfigFile` + `LoadServerConfig` with TDD tests
|
|
|
|
**Files:**
|
|
- Modify: `pkg/sendspin/config.go` — add server surface.
|
|
- Create: `pkg/sendspin/config_server_test.go` — new test file, keeps server tests adjacent to player tests but separately readable.
|
|
|
|
TDD: write the failing tests first, then the implementation.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `pkg/sendspin/config_server_test.go`:
|
|
|
|
```go
|
|
// ABOUTME: Tests for YAML config loading and env prefix routing for sendspin-server
|
|
package sendspin
|
|
|
|
import (
|
|
"flag"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadServerConfig_ExplicitPathWithAllKeys(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "server.yaml")
|
|
body := `# Test server
|
|
name: "Living Room Server"
|
|
port: 9000
|
|
log_file: "custom-server.log"
|
|
debug: true
|
|
no_mdns: true
|
|
no_tui: true
|
|
audio: "/srv/music/radio.m3u8"
|
|
discover_clients: true
|
|
daemon: true
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
cfg, used, err := LoadServerConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if used != path {
|
|
t.Errorf("used = %q, want %q", used, path)
|
|
}
|
|
if cfg == nil {
|
|
t.Fatal("cfg is nil")
|
|
}
|
|
if cfg.Name != "Living Room Server" {
|
|
t.Errorf("name = %q", cfg.Name)
|
|
}
|
|
if cfg.Port == nil || *cfg.Port != 9000 {
|
|
t.Errorf("port = %v, want 9000", cfg.Port)
|
|
}
|
|
if cfg.LogFile != "custom-server.log" {
|
|
t.Errorf("log_file = %q", cfg.LogFile)
|
|
}
|
|
if cfg.Debug == nil || !*cfg.Debug {
|
|
t.Errorf("debug = %v, want true", cfg.Debug)
|
|
}
|
|
if cfg.NoMDNS == nil || !*cfg.NoMDNS {
|
|
t.Errorf("no_mdns = %v, want true", cfg.NoMDNS)
|
|
}
|
|
if cfg.NoTUI == nil || !*cfg.NoTUI {
|
|
t.Errorf("no_tui = %v, want true", cfg.NoTUI)
|
|
}
|
|
if cfg.Audio != "/srv/music/radio.m3u8" {
|
|
t.Errorf("audio = %q", cfg.Audio)
|
|
}
|
|
if cfg.DiscoverClients == nil || !*cfg.DiscoverClients {
|
|
t.Errorf("discover_clients = %v, want true", cfg.DiscoverClients)
|
|
}
|
|
if cfg.Daemon == nil || !*cfg.Daemon {
|
|
t.Errorf("daemon = %v, want true", cfg.Daemon)
|
|
}
|
|
}
|
|
|
|
func TestLoadServerConfig_MissingFileIsNotAnError(t *testing.T) {
|
|
cfg, used, err := LoadServerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if cfg != nil || used != "" {
|
|
t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
|
|
}
|
|
}
|
|
|
|
func TestLoadServerConfig_EnvPathHonored(t *testing.T) {
|
|
dir := t.TempDir()
|
|
envPath := filepath.Join(dir, "from-env-server.yaml")
|
|
if err := os.WriteFile(envPath, []byte("name: EnvServer\n"), 0o600); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
t.Setenv("SENDSPIN_SERVER_CONFIG", envPath)
|
|
|
|
cfg, used, err := LoadServerConfig("")
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if used != envPath {
|
|
t.Errorf("used = %q, want %q", used, envPath)
|
|
}
|
|
if cfg.Name != "EnvServer" {
|
|
t.Errorf("name = %q", cfg.Name)
|
|
}
|
|
}
|
|
|
|
// TestApplyEnvAndFile_ServerEnvPrefix confirms the generalized envPrefix
|
|
// parameter routes SENDSPIN_SERVER_* correctly. Precedence rules themselves
|
|
// are already covered by the player tests; this is pure plumbing.
|
|
func TestApplyEnvAndFile_ServerEnvPrefix(t *testing.T) {
|
|
fs := flag.NewFlagSet("server", flag.ContinueOnError)
|
|
port := fs.Int("port", 8927, "port")
|
|
audio := fs.String("audio", "", "audio source")
|
|
if err := fs.Parse(nil); err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
t.Setenv("SENDSPIN_SERVER_PORT", "9999")
|
|
t.Setenv("SENDSPIN_SERVER_AUDIO", "/srv/env.flac")
|
|
|
|
if err := ApplyEnvAndFile(fs, map[string]bool{}, ServerEnvPrefix, nil); err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
if *port != 9999 {
|
|
t.Errorf("port = %d, want 9999", *port)
|
|
}
|
|
if *audio != "/srv/env.flac" {
|
|
t.Errorf("audio = %q", *audio)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests — confirm they fail to compile**
|
|
|
|
```bash
|
|
go test ./pkg/sendspin/ -run 'TestLoadServerConfig|TestApplyEnvAndFile_ServerEnvPrefix' -v
|
|
```
|
|
|
|
Expected: compile error — `undefined: LoadServerConfig`, `undefined: ServerEnvPrefix`, `undefined: ServerConfigFile` (fields).
|
|
|
|
- [ ] **Step 3: Add the server surface to `config.go`**
|
|
|
|
Append to `pkg/sendspin/config.go`:
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the server tests — confirm they pass**
|
|
|
|
```bash
|
|
go test ./pkg/sendspin/ -run 'TestLoadServerConfig|TestApplyEnvAndFile_ServerEnvPrefix' -v
|
|
```
|
|
|
|
Expected: all 4 tests PASS.
|
|
|
|
- [ ] **Step 5: Run the full package test suite to confirm no regressions**
|
|
|
|
```bash
|
|
go test ./pkg/sendspin/ -v
|
|
```
|
|
|
|
Expected: every test in the package PASSES.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add pkg/sendspin/config.go pkg/sendspin/config_server_test.go
|
|
git commit -m "feat(sendspin): ServerConfigFile + LoadServerConfig
|
|
|
|
Mirrors LoadPlayerConfig: CLI > env > ~/.config/sendspin/server.yaml >
|
|
/etc/sendspin/server.yaml. Pointer bools so absence in YAML is distinct
|
|
from explicit false. AsStringMap() feeds the shared ApplyEnvAndFile
|
|
helper with SENDSPIN_SERVER_ as the env prefix."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Wire `--config` and `--daemon` into `cmd/sendspin-server/main.go`
|
|
|
|
**Files:**
|
|
- Modify: `cmd/sendspin-server/main.go`
|
|
|
|
This is the single user-visible change to the server binary. Touches flag declarations, logging setup, and the config-overlay call site.
|
|
|
|
- [ ] **Step 1: Add the two new flag declarations**
|
|
|
|
In the `var (...)` block at the top, add (order matches the player's convention — `config` first, `daemon` near `no-tui`):
|
|
|
|
```go
|
|
var (
|
|
port = flag.Int("port", 8927, "WebSocket server port")
|
|
name = flag.String("name", "", "Server friendly name (default: hostname-sendspin-server)")
|
|
logFile = flag.String("log-file", "sendspin-server.log", "Log file path")
|
|
debug = flag.Bool("debug", false, "Enable debug logging")
|
|
noMDNS = flag.Bool("no-mdns", false, "Disable mDNS advertisement")
|
|
noTUI = flag.Bool("no-tui", false, "Disable TUI, use streaming logs instead")
|
|
audioFile = flag.String("audio", "", "Audio source to stream (MP3, FLAC, HTTP URL, HLS). Default: test tone")
|
|
discoverClients = flag.Bool("discover-clients", false, "Enable server-initiated discovery: browse _sendspin._tcp and dial out to clients")
|
|
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.")
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Wire config loading after `flag.Parse()`**
|
|
|
|
Replace the opening of `main()` (current lines 30-33) with:
|
|
|
|
```go
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
// Overlay YAML file and SENDSPIN_SERVER_* env vars onto flag vars for
|
|
// anything the user didn't set on the CLI. --config is excluded because
|
|
// putting it in the config file would be circular.
|
|
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)
|
|
}
|
|
|
|
useTUI := !(*noTUI || *daemon)
|
|
```
|
|
|
|
(`cfg.AsStringMap()` is safe on a nil `cfg` — returns an empty map.)
|
|
|
|
- [ ] **Step 3: Replace the logging-setup block with a daemon-aware branch**
|
|
|
|
Current block (around lines 35-46) opens the log file unconditionally. Replace with:
|
|
|
|
```go
|
|
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_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
|
|
if err != nil {
|
|
log.Fatalf("error opening log file: %v", err)
|
|
}
|
|
defer 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))
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add the daemon-mode banner line**
|
|
|
|
Find the existing non-TUI startup log (around line 57-59):
|
|
|
|
```go
|
|
if !useTUI {
|
|
log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port)
|
|
}
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```go
|
|
if !useTUI {
|
|
log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port)
|
|
if *daemon {
|
|
log.Printf("Daemon mode: logging to stdout only")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Build the server binary**
|
|
|
|
```bash
|
|
make server
|
|
```
|
|
|
|
Expected: builds cleanly. If cgo errors appear, prepend `/c/msys64/mingw64/bin` to `PATH` first.
|
|
|
|
- [ ] **Step 6: Smoke test 1 — default (no config, no daemon)**
|
|
|
|
```bash
|
|
./sendspin-server --no-tui
|
|
```
|
|
|
|
Expected: "Starting Sendspin Server: ... on port 8927" in stdout; `sendspin-server.log` appears in cwd; mDNS advertisement starts. Ctrl+C to exit.
|
|
|
|
- [ ] **Step 7: Smoke test 2 — config file overrides port**
|
|
|
|
Create `/tmp/s.yaml` (or equivalent) with:
|
|
```yaml
|
|
port: 9000
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
./sendspin-server --no-tui --config /tmp/s.yaml
|
|
```
|
|
|
|
Expected: banner says `port 9000`. Ctrl+C.
|
|
|
|
- [ ] **Step 8: Smoke test 3 — env beats file**
|
|
|
|
```bash
|
|
SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --config /tmp/s.yaml
|
|
```
|
|
|
|
Expected: banner says `port 9001`.
|
|
|
|
- [ ] **Step 9: Smoke test 4 — CLI beats env + file**
|
|
|
|
```bash
|
|
SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --port 9002 --config /tmp/s.yaml
|
|
```
|
|
|
|
Expected: banner says `port 9002`.
|
|
|
|
- [ ] **Step 10: Smoke test 5 — daemon mode**
|
|
|
|
Delete any stale `sendspin-server.log` in cwd first. Then:
|
|
|
|
```bash
|
|
./sendspin-server --daemon
|
|
```
|
|
|
|
Expected: logs to stdout (no TUI), banner includes "Daemon mode: logging to stdout only", no `sendspin-server.log` file created. Ctrl+C to exit, then:
|
|
|
|
```bash
|
|
ls sendspin-server.log 2>&1 || echo "confirmed: no log file created"
|
|
```
|
|
|
|
- [ ] **Step 11: Commit**
|
|
|
|
```bash
|
|
git add cmd/sendspin-server/main.go
|
|
git commit -m "feat(server): --config and --daemon flags
|
|
|
|
Matches the player's config surface: YAML search (CLI > env >
|
|
~/.config/sendspin/server.yaml > /etc/sendspin/server.yaml), env
|
|
overlay via SENDSPIN_SERVER_*, and --daemon (stdout-only logging,
|
|
no TUI, no log file) for journalctl-friendly operation under systemd."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Create `dist/systemd/sendspin-server.service`
|
|
|
|
**Files:**
|
|
- Create: `dist/systemd/sendspin-server.service`
|
|
|
|
- [ ] **Step 1: Write the unit file**
|
|
|
|
```ini
|
|
[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
|
|
# Pass CLI flags via SENDSPIN_SERVER_OPTS in the environment file.
|
|
# ExecStart is overridden below to append them.
|
|
ExecStart=
|
|
ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS
|
|
|
|
# Hardening
|
|
NoNewPrivileges=true
|
|
ProtectSystem=strict
|
|
ProtectHome=read-only
|
|
PrivateTmp=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add dist/systemd/sendspin-server.service
|
|
git commit -m "feat(dist): systemd unit for sendspin-server
|
|
|
|
Mirrors sendspin-player.service. ProtectHome=read-only (not =yes) so
|
|
operators can use --audio /home/user/Music/... under systemd without
|
|
fighting the sandbox."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Create `dist/systemd/sendspin-server.env`
|
|
|
|
**Files:**
|
|
- Create: `dist/systemd/sendspin-server.env`
|
|
|
|
- [ ] **Step 1: Write the env file**
|
|
|
|
```sh
|
|
# 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=""
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add dist/systemd/sendspin-server.env
|
|
git commit -m "feat(dist): /etc/default/sendspin-server starter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Create `dist/config/server.example.yaml`
|
|
|
|
**Files:**
|
|
- Create: `dist/config/server.example.yaml`
|
|
|
|
- [ ] **Step 1: Write the annotated example**
|
|
|
|
```yaml
|
|
# 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
|
|
#
|
|
# Every key is optional. Uncomment and adjust as needed.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Identity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Friendly name shown in Music Assistant and via mDNS.
|
|
# Default: <hostname>-sendspin-server
|
|
# name: "Living Room Server"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Network
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# WebSocket server port.
|
|
# port: 8927
|
|
|
|
# Disable mDNS advertisement. Clients must then connect manually.
|
|
# no_mdns: false
|
|
|
|
# Enable server-initiated discovery: browse _sendspin._tcp and dial out
|
|
# to clients. Useful for players that can't initiate connections.
|
|
# discover_clients: false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audio source
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone.
|
|
# Examples:
|
|
# audio: "/srv/music/ambient.flac"
|
|
# audio: "http://example.com/stream.mp3"
|
|
# audio: "https://stream.radiofrance.fr/fip/fip.m3u8"
|
|
# audio: ""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runtime / logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log
|
|
# file. Recommended when running under systemd.
|
|
# daemon: false
|
|
|
|
# Disable the interactive terminal UI, stream logs to stdout instead.
|
|
# no_tui: false
|
|
|
|
# Log file path. Ignored when daemon mode is on.
|
|
# log_file: "sendspin-server.log"
|
|
|
|
# Enable verbose debug logging.
|
|
# debug: false
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add dist/config/server.example.yaml
|
|
git commit -m "docs(server): annotated server.yaml starter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Split Makefile `install-daemon` / `uninstall-daemon` into leaves + aggregates
|
|
|
|
**Files:**
|
|
- Modify: `Makefile` (lines ~1-6 phony list, and the block at 85-117)
|
|
|
|
The existing `install-daemon` target (line 85) installs the player only. Rewrite so that `install-daemon` becomes an aggregate of per-binary leaf targets, and a matching `install-server-daemon` target installs the new artifacts.
|
|
|
|
- [ ] **Step 1: Update the `.PHONY` declaration**
|
|
|
|
At the top of the Makefile (around line 5), add the new target names:
|
|
|
|
```make
|
|
.PHONY: all clean test test-verbose test-coverage test-race lint help \
|
|
build-all build-linux build-darwin help conformance \
|
|
install-daemon uninstall-daemon \
|
|
install-player-daemon uninstall-player-daemon \
|
|
install-server-daemon uninstall-server-daemon
|
|
```
|
|
|
|
(Exact existing variable names may differ; preserve the existing list and append the new targets.)
|
|
|
|
- [ ] **Step 2: Rename the existing `install-daemon` body to `install-player-daemon`**
|
|
|
|
Replace the current `install-daemon: player` stanza (lines 85-107) with:
|
|
|
|
```make
|
|
# Install sendspin-player as a systemd daemon
|
|
install-player-daemon: player
|
|
@echo "Installing sendspin-player daemon..."
|
|
install -m 755 sendspin-player /usr/local/bin/sendspin-player
|
|
install -m 644 dist/systemd/sendspin-player.service /etc/systemd/system/sendspin-player.service
|
|
@if [ ! -f /etc/default/sendspin-player ]; then \
|
|
install -m 644 dist/systemd/sendspin-player.env /etc/default/sendspin-player; \
|
|
echo "Created /etc/default/sendspin-player — edit this file to configure."; \
|
|
else \
|
|
echo "/etc/default/sendspin-player already exists, not overwriting."; \
|
|
fi
|
|
@if [ ! -f /etc/sendspin/player.yaml ]; then \
|
|
install -d -m 755 /etc/sendspin; \
|
|
install -m 644 dist/config/player.example.yaml /etc/sendspin/player.yaml; \
|
|
echo "Created /etc/sendspin/player.yaml — edit this file to configure."; \
|
|
else \
|
|
echo "/etc/sendspin/player.yaml already exists, not overwriting."; \
|
|
fi
|
|
systemctl daemon-reload
|
|
@echo ""
|
|
@echo "Installed. To start:"
|
|
@echo " sudo systemctl enable --now sendspin-player"
|
|
@echo ""
|
|
@echo "Configure via /etc/sendspin/player.yaml (preferred) or /etc/default/sendspin-player"
|
|
```
|
|
|
|
- [ ] **Step 3: Add the new `install-server-daemon` target**
|
|
|
|
Append directly below:
|
|
|
|
```make
|
|
# Install sendspin-server as a systemd daemon
|
|
install-server-daemon: server
|
|
@echo "Installing sendspin-server daemon..."
|
|
install -m 755 sendspin-server /usr/local/bin/sendspin-server
|
|
install -m 644 dist/systemd/sendspin-server.service /etc/systemd/system/sendspin-server.service
|
|
@if [ ! -f /etc/default/sendspin-server ]; then \
|
|
install -m 644 dist/systemd/sendspin-server.env /etc/default/sendspin-server; \
|
|
echo "Created /etc/default/sendspin-server — edit this file to configure."; \
|
|
else \
|
|
echo "/etc/default/sendspin-server already exists, not overwriting."; \
|
|
fi
|
|
@if [ ! -f /etc/sendspin/server.yaml ]; then \
|
|
install -d -m 755 /etc/sendspin; \
|
|
install -m 644 dist/config/server.example.yaml /etc/sendspin/server.yaml; \
|
|
echo "Created /etc/sendspin/server.yaml — edit this file to configure."; \
|
|
else \
|
|
echo "/etc/sendspin/server.yaml already exists, not overwriting."; \
|
|
fi
|
|
systemctl daemon-reload
|
|
@echo ""
|
|
@echo "Installed. To start:"
|
|
@echo " sudo systemctl enable --now sendspin-server"
|
|
@echo ""
|
|
@echo "Configure via /etc/sendspin/server.yaml (preferred) or /etc/default/sendspin-server"
|
|
|
|
# Aggregate: install both binaries as systemd daemons
|
|
install-daemon: install-player-daemon install-server-daemon
|
|
```
|
|
|
|
- [ ] **Step 4: Rename the existing `uninstall-daemon` body to `uninstall-player-daemon` and add the server counterpart + aggregate**
|
|
|
|
Replace the current `uninstall-daemon` stanza (lines 110-117) with:
|
|
|
|
```make
|
|
# Uninstall the sendspin-player systemd daemon
|
|
uninstall-player-daemon:
|
|
@echo "Removing sendspin-player daemon..."
|
|
-systemctl stop sendspin-player 2>/dev/null
|
|
-systemctl disable sendspin-player 2>/dev/null
|
|
rm -f /etc/systemd/system/sendspin-player.service
|
|
rm -f /usr/local/bin/sendspin-player
|
|
systemctl daemon-reload
|
|
@echo "Removed. /etc/default/sendspin-player and /etc/sendspin/player.yaml left in place (manual cleanup if desired)."
|
|
|
|
# Uninstall the sendspin-server systemd daemon
|
|
uninstall-server-daemon:
|
|
@echo "Removing sendspin-server daemon..."
|
|
-systemctl stop sendspin-server 2>/dev/null
|
|
-systemctl disable sendspin-server 2>/dev/null
|
|
rm -f /etc/systemd/system/sendspin-server.service
|
|
rm -f /usr/local/bin/sendspin-server
|
|
systemctl daemon-reload
|
|
@echo "Removed. /etc/default/sendspin-server and /etc/sendspin/server.yaml left in place (manual cleanup if desired)."
|
|
|
|
# Aggregate: uninstall both daemons
|
|
uninstall-daemon: uninstall-player-daemon uninstall-server-daemon
|
|
```
|
|
|
|
- [ ] **Step 5: Update the `help` target**
|
|
|
|
Find the help-text block (around line 168). Replace the single daemon line:
|
|
|
|
```make
|
|
@echo " make install-daemon - Install as systemd service (Linux, requires root)"
|
|
@echo " make uninstall-daemon - Remove systemd service"
|
|
```
|
|
|
|
With:
|
|
|
|
```make
|
|
@echo " make install-daemon - Install both player and server as systemd daemons (Linux, requires root)"
|
|
@echo " make install-player-daemon - Install only the player daemon"
|
|
@echo " make install-server-daemon - Install only the server daemon"
|
|
@echo " make uninstall-daemon - Remove both daemons"
|
|
@echo " make uninstall-player-daemon - Remove only the player daemon"
|
|
@echo " make uninstall-server-daemon - Remove only the server daemon"
|
|
```
|
|
|
|
- [ ] **Step 6: Sanity check — `make help` still parses**
|
|
|
|
```bash
|
|
make help
|
|
```
|
|
|
|
Expected: help text lists the new targets; no Make parse errors.
|
|
|
|
- [ ] **Step 7: Dry-run the new targets to confirm they parse**
|
|
|
|
```bash
|
|
make -n install-server-daemon
|
|
make -n install-daemon
|
|
make -n uninstall-server-daemon
|
|
make -n uninstall-daemon
|
|
```
|
|
|
|
Expected: each prints the commands it *would* run (the `install`, `rm`, `systemctl` calls), without actually executing anything. If Make reports missing variables or syntax errors, fix them before committing.
|
|
|
|
**Not run here:** actual `make install-daemon` execution requires root on a Linux box. Document in the final verification step for a Linux smoke test.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add Makefile
|
|
git commit -m "build: split install-daemon into per-binary targets
|
|
|
|
install-daemon now aggregates install-player-daemon and
|
|
install-server-daemon (same for uninstall). Operators can install or
|
|
remove either side alone; running 'make install-daemon' still installs
|
|
both, preserving existing behavior."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: README — add Server config-file section and daemon pointer
|
|
|
|
**Files:**
|
|
- Modify: `README.md` (around line 232, right after the "Server Options" list)
|
|
|
|
- [ ] **Step 1: Add `--config` and `--daemon` to the Server Options list**
|
|
|
|
Find the Server Options bullet list (around lines 222-232). After the existing `--no-tui` bullet at line 232, append:
|
|
|
|
```markdown
|
|
- `--daemon` - Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file. Recommended under systemd.
|
|
- `--config` - Path to `server.yaml` config file. Default search: `$SENDSPIN_SERVER_CONFIG`, `~/.config/sendspin/server.yaml`, `/etc/sendspin/server.yaml`.
|
|
```
|
|
|
|
- [ ] **Step 2: Add a `Configuration File (server.yaml)` subsection after the Server TUI section**
|
|
|
|
After the Server TUI block (around line 243, before the `### Player` heading at line 244), insert:
|
|
|
|
```markdown
|
|
#### Configuration File (`server.yaml`)
|
|
|
|
Every CLI flag has a matching key in `server.yaml`. Keys use `snake_case` (`--no-mdns` ↔ `no_mdns`).
|
|
|
|
A fully-commented starter file lives at [`dist/config/server.example.yaml`](dist/config/server.example.yaml) — copy it to `~/.config/sendspin/server.yaml` (user install) or `/etc/sendspin/server.yaml` (daemon) and uncomment the keys you want to set.
|
|
|
|
**Search order** (first existing file wins; missing is not an error):
|
|
|
|
1. `--config <path>` flag
|
|
2. `$SENDSPIN_SERVER_CONFIG`
|
|
3. `~/.config/sendspin/server.yaml` (macOS: `~/Library/Application Support/sendspin/server.yaml`; Windows: `%AppData%\sendspin\server.yaml`)
|
|
4. `/etc/sendspin/server.yaml` (daemon/system-wide)
|
|
|
|
**Value precedence**, for every flag: CLI > `SENDSPIN_SERVER_<UPPER_SNAKE>` env > `server.yaml` > built-in default.
|
|
|
|
#### Running under systemd
|
|
|
|
`make install-daemon` installs both `sendspin-player` and `sendspin-server` as systemd units. To install only one side:
|
|
|
|
```bash
|
|
sudo make install-server-daemon # server only
|
|
sudo make install-player-daemon # player only
|
|
```
|
|
|
|
Then enable and start the server:
|
|
|
|
```bash
|
|
sudo systemctl enable --now sendspin-server
|
|
journalctl -u sendspin-server -f
|
|
```
|
|
|
|
Configure via `/etc/sendspin/server.yaml` (preferred) or `SENDSPIN_SERVER_OPTS` in `/etc/default/sendspin-server`.
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "docs: document server.yaml config and --daemon flag
|
|
|
|
Mirrors the existing player config docs. Adds a brief 'Running under
|
|
systemd' subsection covering install-daemon and the split per-binary
|
|
targets."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Final verification
|
|
|
|
No code changes — this is a verification pass. Run the full test suite and the full build to catch anything the per-task smoke tests missed.
|
|
|
|
- [ ] **Step 1: Run the full test suite**
|
|
|
|
```bash
|
|
go test ./... -short
|
|
```
|
|
|
|
Expected: all tests PASS.
|
|
|
|
**Known flake:** `TestServerStartStop` on port 8929 is a pre-existing environment issue on Chris's box (not introduced by this plan). If it's the only failure, record it and move on — do not chase it.
|
|
|
|
If cgo build errors appear, prepend `/c/msys64/mingw64/bin` to `PATH` and retry.
|
|
|
|
- [ ] **Step 2: Run the linter**
|
|
|
|
```bash
|
|
make lint
|
|
```
|
|
|
|
Expected: no lint failures.
|
|
|
|
- [ ] **Step 3: Build both binaries**
|
|
|
|
```bash
|
|
make
|
|
```
|
|
|
|
Expected: `sendspin-player` and `sendspin-server` built successfully.
|
|
|
|
- [ ] **Step 4: End-to-end server smoke — default**
|
|
|
|
```bash
|
|
./sendspin-server --no-tui
|
|
```
|
|
|
|
Expected: starts on port 8927, mDNS advertisement logs appear, Ctrl+C exits cleanly.
|
|
|
|
- [ ] **Step 5: End-to-end server smoke — config + env + CLI precedence**
|
|
|
|
Create `/tmp/s.yaml`:
|
|
```yaml
|
|
port: 9000
|
|
```
|
|
|
|
Verify the three-layer precedence:
|
|
|
|
```bash
|
|
./sendspin-server --no-tui --config /tmp/s.yaml
|
|
# Expected: port 9000
|
|
|
|
SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --config /tmp/s.yaml
|
|
# Expected: port 9001
|
|
|
|
SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --port 9002 --config /tmp/s.yaml
|
|
# Expected: port 9002
|
|
```
|
|
|
|
- [ ] **Step 6: End-to-end server smoke — daemon mode**
|
|
|
|
```bash
|
|
rm -f sendspin-server.log
|
|
./sendspin-server --daemon
|
|
# Ctrl+C
|
|
ls sendspin-server.log 2>&1 || echo "confirmed: no log file created"
|
|
```
|
|
|
|
Expected: no log file; stdout had the "Daemon mode: logging to stdout only" banner.
|
|
|
|
- [ ] **Step 7 (Linux-only, manual): systemd install smoke**
|
|
|
|
On a Linux box with the repo checked out:
|
|
|
|
```bash
|
|
sudo make install-server-daemon
|
|
sudo systemctl enable --now sendspin-server
|
|
journalctl -u sendspin-server -f
|
|
# Expected: "Starting Sendspin Server: ..." and "Daemon mode: ..." lines
|
|
sudo systemctl stop sendspin-server
|
|
sudo make uninstall-server-daemon
|
|
# Expected: /etc/sendspin/server.yaml and /etc/default/sendspin-server remain; binary + unit removed
|
|
```
|
|
|
|
If you're not on Linux, note this step as pending and flag it in the PR description.
|
|
|
|
- [ ] **Step 8: Player regression smoke**
|
|
|
|
Because Task 1 touched the player call-site, confirm the player still starts and connects:
|
|
|
|
```bash
|
|
./sendspin-player --no-tui --server ws://localhost:8927
|
|
```
|
|
|
|
Expected: mDNS-skipped direct connect, normal startup output. Ctrl+C exits cleanly.
|
|
|
|
- [ ] **Step 9: Final commit (only if anything was found and fixed)**
|
|
|
|
If steps 1-8 revealed issues that needed fixes, commit them with a descriptive message. If everything passed, nothing to commit here.
|
|
|
|
---
|
|
|
|
## Out-of-scope reminders
|
|
|
|
- No `server_id` persistent identity — explicitly deferred in the spec.
|
|
- No README deep-dive on daemon operation beyond the brief pointer above.
|
|
- No refactor of `pkg/sendspin` into a `configfile` sub-package — YAGNI until a third consumer arrives.
|