Skip to content

Commit d531dff

Browse files
authored
Merge pull request #11 from chrisuthe/chrisuthe/task/config-file-state-store-and
Config file, state store and SendspinPersistenceProvider (roadmap item 8)
2 parents 65bf4a4 + 3b6398f commit d531dff

26 files changed

Lines changed: 3156 additions & 672 deletions

‎CMakeLists.txt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,16 @@ add_library(sendspin-cli-core STATIC
215215
src/control_client.cpp
216216
src/control_common.cpp
217217
src/control_socket.cpp
218+
src/config_file.cpp
218219
src/daemon.cpp
219-
src/last_server.cpp
220+
src/key_value_file.cpp
220221
src/log.cpp
221222
src/mdns_common.cpp
222223
src/null_sink.cpp
223224
src/outbound.cpp
224225
src/pcm_volume.cpp
225226
src/player_listener.cpp
227+
src/state_store.cpp
226228
src/supported_formats.cpp
227229
)
228230

@@ -315,12 +317,13 @@ if(SENDSPIN_CLI_BUILD_TESTS)
315317

316318
add_executable(sendspin-cli-tests
317319
tests/cli_test.cpp
320+
tests/config_file_test.cpp
318321
tests/control_test.cpp
319322
tests/daemon_test.cpp
320323
tests/device_spec_test.cpp
321324
tests/discovery_test.cpp
322-
tests/last_server_test.cpp
323325
tests/pcm_volume_test.cpp
326+
tests/state_store_test.cpp
324327
tests/supported_formats_test.cpp
325328
)
326329
target_link_libraries(sendspin-cli-tests PRIVATE sendspin-cli-core GTest::gtest_main)

‎README.md‎

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -670,12 +670,15 @@ which is what a foreground run whose terminal has just closed should do.
670670
### Flags, and what they refuse
671671

672672
The flags follow squeezelite's: `-o` output device, `-l` list devices, `-n` name,
673-
`-s` server, `-z` daemonize, `-P` pidfile, `-d`/`-f` logging. Six are long-only
673+
`-s` server, `-z` daemonize, `-P` pidfile, `-d`/`-f` logging. All but `-l` and
674+
`-z` also have a long spelling — `--output --name --server --pidfile --logfile
675+
--log-level` — so that every config key is a flag name. Eight more are long-only
674676
because they are not squeezelite's: `--port`, the port this player serves on,
675-
`--buffer-ms`, the two mDNS flags `--no-mdns` and `--mdns-name`, and the two
676-
control-socket flags `--control-socket` and `--no-control`. Run `--help` for the
677-
current state of each — a few still point at
678-
[`docs/ROADMAP.md`](docs/ROADMAP.md) for behaviour that is not built yet.
677+
`--buffer-ms`, the two mDNS flags `--no-mdns` and `--mdns-name`, the two
678+
control-socket flags `--control-socket` and `--no-control`, and `--config` and
679+
`--state-dir` for the two files above. Run `--help` for the current state of each
680+
— a few still point at [`docs/ROADMAP.md`](docs/ROADMAP.md) for behaviour that is
681+
not built yet.
679682

680683
A **subcommand comes first**, before any flag: `sendspin-cli vol 50 --port 9000`,
681684
not `sendspin-cli --port 9000 vol 50`. That is not getopt permutation showing
@@ -713,6 +716,112 @@ or a full `ws://`/`wss://` URL, or `mdns:[<name>]` to discover one. An IPv6 lite
713716
must be bracketed (`[::1]:8927`), since an unbracketed one cannot be told from a
714717
host with a port.
715718

719+
### The config file, and what the player remembers
720+
721+
Two files, and the split is deliberate: one you write and the daemon only ever
722+
reads, one the daemon writes and you never need to touch.
723+
724+
**The config file** holds anything you would otherwise type. Keys are the long
725+
flag names without their dashes, one per line, and a value is exactly the string
726+
the flag would have been given:
727+
728+
```
729+
# /etc/sendspin-cli.conf, or ~/.config/sendspin-cli/config
730+
name = kitchen
731+
output = hw:1,0
732+
buffer-ms = 250
733+
server = mdns:Living Room
734+
no-mdns = true
735+
```
736+
737+
That is why `-o -n -s -P -f -d` grew the long aliases `--output --name --server
738+
--pidfile --logfile --log-level`: one vocabulary, so `--help` is the config
739+
reference rather than a second document to keep in step. `#` starts a comment
740+
only at the **start** of a line — a name or a path is free to contain one, and a
741+
reader that ate everything after a `#` would silently truncate it. Booleans take
742+
`true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. Where a key appears twice, the
743+
last one wins.
744+
745+
The first of these that exists is read **whole**, and nothing below it is merged
746+
over the top:
747+
748+
1. `--config <path>` — and this one is **fatal if it cannot be read**, because
749+
you named it. Falling back would start a player on options nobody chose.
750+
2. `$XDG_CONFIG_HOME/sendspin-cli/config`
751+
3. `$HOME/.config/sendspin-cli/config`
752+
4. `/etc/sendspin-cli.conf`
753+
754+
Finding none is silent and normal. There is deliberately no `--no-config`: the
755+
asymmetry above already gives you `--config /dev/null`. A half-overridden config
756+
assembled from several layers is far harder to reason about than one file you can
757+
read top to bottom, which is also why there is no `$XDG_CONFIG_DIRS` traversal.
758+
759+
**Precedence is command line > config file > built-in default**, per option
760+
rather than per file — `-n bathroom` on a line whose config also sets
761+
`buffer-ms` overrides only the name. Everything can come from a file except `-l`,
762+
`-z`, `--config` itself, and `--help`/`--version`, which have no meaning in one.
763+
Run shape stays on the command line: excluding it is
764+
reversible, and debugging a `daemonize` that came out of a file under systemd is
765+
not. A config that names one is refused as an unknown key.
766+
767+
A configured value is validated by exactly the code that validates a typed one,
768+
with the same message and the line to go and fix:
769+
770+
```console
771+
$ sendspin-cli
772+
error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000
773+
```
774+
775+
An unknown key or a line that is not `key = value` is refused the same way. A
776+
silently ignored typo is the failure mode this whole surface exists to avoid — so
777+
is a config that is quietly skipped, which is why a file that exists and does not
778+
parse stops the run rather than falling through to `/etc`. `--help`, `--version`
779+
and `-l` short-circuit above all of it: a broken config must not stop `--help`
780+
from telling you how to fix it. Startup logs one line naming the file in use, or
781+
saying there is none.
782+
783+
`server` from a config file behaves exactly as `-s` does, and that includes
784+
suppressing the mDNS advertisement — the spec forbids advertising
785+
`_sendspin._tcp` while this end is the one dialling out.
786+
787+
**The state file** is the other half: what the daemon remembers for itself,
788+
across restarts.
789+
790+
```
791+
# Written by sendspin-cli. Edits are overwritten.
792+
last-server = 7f3a…
793+
last-server-hash = 3387423128
794+
static-delay-ms = 375
795+
volume = 42
796+
muted = true
797+
```
798+
799+
`static-delay-ms` is there because the spec requires it — *"Clients must persist
800+
`static_delay_ms` locally across reboots and server reconnections"* — and
801+
`volume`/`muted` because the spec marks those RECOMMENDED. `last-server` is the
802+
server id mDNS discovery uses to break a tie between candidates;
803+
`last-server-hash` is the opaque `uint32_t` the library asks us to keep so *it* can
804+
prefer the last-played server among inbound connections. They mean different things
805+
and are deliberately not reconciled with each other.
806+
807+
**A remembered `static-delay-ms` is reported, not yet applied.** The player hands
808+
the figure back to the server so the two agree across a restart, which is the part
809+
the spec asks for; actually shifting playout by it is
810+
[`docs/ROADMAP.md`](docs/ROADMAP.md) item 13's. Until then a delay you set is
811+
remembered faithfully and does nothing you can hear.
812+
813+
It lives at `$XDG_STATE_HOME/sendspin-cli/state`, then
814+
`$HOME/.local/state/sendspin-cli/state`, and `--state-dir <dir>` overrides both —
815+
a systemd **system** unit has neither variable and gets `/var/lib/sendspin-cli`
816+
from `StateDirectory=`. With none of the three the player still runs and simply
817+
remembers nothing. Writes go through a temporary, an `fsync` and a `rename` at
818+
mode `0600`, so a player that loses power mid-write leaves either the old file or
819+
the new one and never half of either.
820+
821+
Two players on one host share this file unless you give each its own
822+
`--state-dir`. They already need different `--port`s; give them different state
823+
directories too, or the second one to save its volume overwrites the first's.
824+
716825
## Tests
717826

718827
```bash
@@ -740,8 +849,10 @@ and exits `0` on `SIGTERM` — plus the whole of the control socket, which needs
740849
processes by definition: that it appears at the default path as `0600`, that
741850
`status` round-trips, that `--no-control` binds nothing, that a stale socket is
742851
taken over after a `SIGKILL`, that it is gone after `SIGTERM`, and that a second
743-
instance on the same socket is refused. CI runs it on every platform leg; run it
744-
yourself against any build.
852+
instance on the same socket is refused. The config file is in here for the same
853+
reason: a player configured entirely from a file has to be findable by a
854+
subcommand that repeats none of it, and that is two processes agreeing on one
855+
path. CI runs it on every platform leg; run it yourself against any build.
745856

746857
## CI
747858

0 commit comments

Comments
 (0)