diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e548bc1..f5bf32d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,8 +90,9 @@ jobs: needs: [preflight, build] runs-on: ubuntu-24.04 timeout-minutes: 10 - # Scoped to this job alone, the workflow default above staying read-only. This is the only - # job in either workflow that can write to the repository. + # Scoped to this job alone, the workflow default above staying read-only. The only other + # job anywhere here that can write is wiki.yml's, and it writes to the wiki repository + # rather than to this one. permissions: contents: write env: diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml new file mode 100644 index 0000000..d94d996 --- /dev/null +++ b/.github/workflows/wiki.yml @@ -0,0 +1,190 @@ +name: Wiki + +# Publishes docs/wiki/ to this repository's GitHub wiki. +# +# The pages are authored in the tree and mirrored *out* to the wiki, rather than edited in the +# wiki tab, because a wiki push bypasses everything: no pull request, no review, no required +# check, and — for a repository whose wiki is open to collaborators — no gate at all. Authoring +# them here makes a documentation change the same kind of change as a code change. The wiki tab +# is a rendering of `docs/wiki/`; it is not where anything is written. +# +# That direction is what makes the mirror one-way and destructive: a page edited in the wiki tab +# is overwritten on the next push to main, and a page deleted from docs/wiki/ is deleted from +# the wiki. wiki/Home.md says so, because a reader who edits a page and watches it revert +# deserves to have been told where to send the fix instead. +# +# `contents: write` is scoped to the one job that needs it, the workflow default above it being +# read-only — the same shape release.yml's publishing job uses. The wiki is a separate git +# repository (`.wiki.git`) but the same token governs both, so this cannot be given less. +on: + push: + branches: + - main + paths: + # The workflow itself included: a change to how the mirror runs should be exercised by + # the push that makes it, not left until the next page edit. + - 'docs/wiki/**' + - '.github/workflows/wiki.yml' + # A hand-run, for the first sync after the wiki is enabled — that is a repository setting + # rather than a commit, so nothing about flipping it triggers a push. + workflow_dispatch: + +# Queued rather than cancelled, which is the difference between this and ci.yml. Two pushes to +# main both clone, commit and push to one remote; cancelling the first would be fine, but +# `cancel-in-progress: true` cancels the *older* run, and the survivor may be the one carrying +# the older tree. Serialising them instead means the second run clones what the first pushed. +concurrency: + group: wiki + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + # Named rather than left to default, because the default is `bash -e` with no pipefail. + shell: bash + +jobs: + sync: + name: sync + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + # Pinned to a commit rather than a tag, because a tag can be repointed at any time and + # this job runs with write access. The trailing comment is the version that commit was. + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Asked before anything is cloned, so the two ways there is nowhere to publish to are + # told apart from each other and from a clone that failed for some other reason. Both are + # repository settings only an owner can change, and neither is this push's fault: they + # skip with a notice. Everything else fails. + - name: Is there a wiki to publish to + id: wiki + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + has_wiki="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.has_wiki')" + echo "has_wiki=$has_wiki" >>"$GITHUB_OUTPUT" + + # Only `false` is a reason to skip. An empty answer, a `null`, or anything else this + # field might become is a question that was not answered -- and reporting that as + # "wikis are turned off" would stop publishing indefinitely without ever failing a + # run. The notice below is honest for exactly one value, so only that value gets it. + case "$has_wiki" in + true) ;; + false) + echo "::notice::Wikis are turned off for $GITHUB_REPOSITORY, so docs/wiki was not published. Enable it under Settings -> General -> Features -> Wikis, then re-run this workflow." + ;; + *) + echo "::error::The GitHub API answered '$has_wiki' for .has_wiki, which is neither true nor false. Refusing to guess whether there is a wiki to publish to." + exit 1 + ;; + esac + + # A wiki that is enabled but has never had a page created has no git repository behind it + # yet, and GitHub offers no API to create one — the first page has to be made in the wiki + # tab, once, by hand. That is the second setting-shaped skip, and it is recognised by + # git's own message rather than by "the clone failed", so a network failure or a rate + # limit still fails this job. The captured stderr is printed either way: a skip nobody + # can see the evidence for is a skip that hides a real error. + # + # One case this cannot separate, stated rather than glossed: GitHub answers a token that + # is not allowed to see the repository with the same "repository not found" it uses for + # one that does not exist, so a permission failure would classify as "no wiki yet" and + # skip green. It is checked above as far as it can be -- `has_wiki` came back from the + # same token one step earlier, so a token that reached the API and then cannot reach the + # wiki is a narrow case -- but the pattern below cannot tell the two apart, and the + # printed stderr is what a reader would have to judge it on. + - name: Clone the wiki + id: clone + if: steps.wiki.outputs.has_wiki == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + git clone --depth 1 \ + "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.wiki.git" \ + wiki 2>clone.err + status=$? + set -e + + # Printed before it is judged, and with the token pattern that cannot appear in it + # anyway left alone: git reports the URL without credentials. + cat clone.err + + if [ "$status" -eq 0 ]; then + echo 'cloned=true' >>"$GITHUB_OUTPUT" + exit 0 + fi + + if grep -qiE 'repository .* not found|not found: .*\.wiki' clone.err; then + echo 'cloned=false' >>"$GITHUB_OUTPUT" + echo "::notice::$GITHUB_REPOSITORY has wikis enabled but no wiki repository yet, so docs/wiki was not published. Create any page once in the wiki tab — that is what GitHub creates the repository on — then re-run this workflow." + exit 0 + fi + + echo '::error::Could not clone the wiki, and not because it is missing. See the git output above.' + exit 1 + + # Emptied and refilled rather than copied over, because the tree is the source of truth in + # both directions: a page removed from docs/wiki/ has to leave the wiki, and one edited in + # the wiki tab has to go back to what the tree says. A plain `cp` would only ever add. + # + # `find`/`cp` rather than `rsync --delete`, which would do this in one line: rsync is not + # something this workflow should have to assume is on the runner image, and the two + # commands below say what is happening more plainly than a flag does. + # + # `! -name .git` is load-bearing — deleting the clone's own history mid-sync would be a + # novel way to fail. + # + # The whole directory rather than just `*.md`, so that a page needing an image can keep it + # in docs/wiki/ beside itself and have it published too. The other half of the same rule: + # this removes anything the wiki holds that docs/wiki/ does not, an image uploaded through + # the wiki tab included. That follows from the model rather than being an oversight — a + # file that is not in the tree is a file this wiki does not have. + # + # A GitHub wiki page is one file at the wiki root, so docs/wiki/ is kept flat; `-R` is + # here to carry a directory of assets if one is ever added, not as an invitation to nest + # pages, which the wiki would not render as pages anyway. + - name: Mirror docs/wiki into it + if: steps.clone.outputs.cloned == 'true' + run: | + find wiki -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp -R docs/wiki/. wiki/ + echo 'What the wiki holds that differs from docs/wiki:' + git -C wiki status --porcelain + + # Nothing to say is the common case — this workflow also runs when only the workflow file + # changed — and an empty commit would put a row in the wiki's history for a push that + # changed no page. + # + # Never force. A rejected push means the wiki moved under this run, which is either the + # concurrent run above (impossible: the group serialises them) or somebody editing the + # wiki tab directly. That is precisely the bypass this whole arrangement exists to + # prevent, so it fails and names the cause instead of overwriting the evidence. + - name: Commit and push what changed + if: steps.clone.outputs.cloned == 'true' + run: | + cd wiki + if [ -z "$(git status --porcelain)" ]; then + echo "::notice::The wiki already matches docs/wiki at $GITHUB_SHA; nothing to push." + exit 0 + fi + + # An identity is required to commit at all, and github-actions[bot]'s is the one that + # attributes this to the workflow rather than to whoever pushed to main. + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m "Sync docs/wiki from $GITHUB_SHA" + + git push 2>push.err || { + cat push.err + echo '::error::Pushing to the wiki was rejected. The usual cause is a page edited in the wiki tab, which this mirror overwrites rather than merges — copy the edit into docs/wiki/ and open a pull request for it.' + exit 1 + } + echo "::notice::Published docs/wiki to the wiki at $GITHUB_SHA." diff --git a/README.md b/README.md index 72cbc2b..1a68a1c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ audio-streaming library, taking its command-line and control ergonomics from > The initial task brings up the build and boots a sendspin client; feature work > is tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md). +**New here?** The [wiki](https://github.com/chrisuthe/sendspin-cpp-cli/wiki) is the +task-shaped version of this file — installing, a Raspberry Pi walkthrough, troubleshooting — +and on Linux [`scripts/get_started_linux.sh`](scripts/get_started_linux.sh) does the install +in one command. Those pages are authored in [`docs/wiki/`](docs/wiki) and mirrored to the +wiki tab on every push to `main`. + ## What it is Like squeezelite is a headless endpoint for Lyrion/Logitech Media Server, diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..4ff59ac --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,190 @@ +# Configuration + +Two files. **One you write and the player only ever reads**, and **one the player writes and +you never need to touch.** The split is deliberate: a daemon that rewrote its own config +would destroy the comments and the ordering you put there, and a config the daemon could not +write would have nowhere to record a volume. + +## The config file + +Anything you would otherwise type on the command line. **Keys are the long flag names +without their dashes**, one `key = value` per line, and a value is exactly the string that +flag would have been given — so `sendspin-cli --help` is this file's reference rather than a +second document to keep in step with it. + +```ini +# /etc/sendspin-cli.conf +name = kitchen +output = hw:1,0 +buffer-ms = 250 +static-delay = 40 +control-socket = /run/sendspin-cli/control.sock +``` + +An annotated example is installed at +`/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example`, with every line commented +out. Copying it is the intended way to start: + +```bash +sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf +``` + +### Where it is looked for + +The **first of these that exists is read whole**, and nothing below it is merged over the +top: + +1. `--config ` — **fatal if it cannot be read**, because you named it. Falling back + would start a player on options nobody chose. +2. `$XDG_CONFIG_HOME/sendspin-cli/config` +3. `$HOME/.config/sendspin-cli/config` +4. `/etc/sendspin-cli.conf` + +Finding none is silent and normal. There is no `--no-config` flag — `--config /dev/null` +already does that. + +### Every key + +| Key | Same as | Value | Default | +|---|---|---|---| +| `output` | `-o`, `--output` | a device: `hw:1,0`, `default`, `portaudio:2`, `null`, `stdout` | `default` where ALSA is built in, else `portaudio`, else `null` | +| `name` | `-n`, `--name` | the friendly name a controller shows | this host's name | +| `server` | `-s`, `--server` | `[:]`, a `ws://` URL, or `mdns:[]` | none — wait to be discovered | +| `port` | `--port` | the port this player's own WebSocket server listens on | `8928` | +| `buffer-ms` | `--buffer-ms` | audio the output backend keeps queued, 10–2000 | `100` | +| `static-delay` | `--static-delay` | latency this endpoint's hardware adds after the audio port, 0–5000 | `0` | +| `no-mdns` | `--no-mdns` | `true`/`false` — do not advertise `_sendspin._tcp` | `false` | +| `mdns-name` | `--mdns-name` | the instance label to advertise, when it should differ from `name` | `name` | +| `control-socket` | `--control-socket` | the Unix socket the subcommands talk to | `$XDG_RUNTIME_DIR/sendspin-cli-.sock` | +| `no-control` | `--no-control` | `true`/`false` — bind no control socket at all | `false` | +| `state-dir` | `--state-dir` | where the player keeps what it remembers | `$XDG_STATE_HOME/sendspin-cli` | +| `log-level` | `-d`, `--log-level` | `none`, `error`, `warn`, `info`, `debug`, `verbose` | `info` | +| `logfile` | `-f`, `--logfile` | write the log here instead of to stderr | stderr | +| `pidfile` | `-P`, `--pidfile` | hold this path as a locked pidfile | none | + +The middle column is the point rather than a convenience: the key **is** the long flag name +minus its dashes, which is what makes `sendspin-cli --help` this file's reference. The six +that had a short flag first — `-o -n -s -P -f -d` — grew long spellings for exactly this +reason, so that one vocabulary covers both. + +Booleans take `true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. A line whose first non-blank +character is `#` is a comment; a `#` anywhere else is not, so a name or a path is free to +contain one. Where a key appears twice, the last one wins. + +**Five things cannot come from a file**: `-l`, `-z`, `--config`, `--help` and `--version`. +Run shape stays on the command line, and a config naming one is refused as an unknown key. +Excluding them is reversible; debugging a `daemonize` that came out of a file under systemd +is not. + +### Precedence + +**Command line > config file > built-in default**, per option rather than per file. `-n +bathroom` on the command line of a player whose config also sets `buffer-ms` overrides only +the name. + +That is why the system unit's own two flags win: `ExecStart` passes `--control-socket` and +`--state-dir`, so those two keys in a config file are **silently ignored** by the service. +Setting `control-socket` to the unit's path is still worth doing — it is what lets a +*subcommand* find the socket with no flags. + +### It refuses rather than guessing + +A configured value is validated by exactly the code that validates a typed one, with the +same message and the line to go and fix: + +```console +$ sendspin-cli +error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000 +``` + +An unknown key, or a line that is not `key = value`, is refused the same way — and a file +that exists and does not parse stops the run rather than falling through to `/etc`. A +silently ignored typo is the failure mode this whole surface exists to avoid. `--help`, +`--version` and `-l` short-circuit above all of it, so a broken config cannot stop `--help` +from telling you how to fix it. + +Under systemd that means a bad config is a unit that fails and is retried every five +seconds, naming the file and the line in the journal each time. See +[Troubleshooting](Troubleshooting). + +**Two keys parse and then fail under the shipped unit.** `logfile` and `pidfile` pointing +anywhere but `/run/sendspin-cli` or `/var/lib/sendspin-cli` are refused by +`ProtectSystem=strict` — `Read-only file system`, loudly, on every restart. Neither is the +shape for a unit whose stderr journald already has. See +[Running as a Service](Running-as-a-Service#what-is-hardened). + +The full argument, including why the search does not merge layers, is in +[The config file, and what the player remembers](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-config-file-and-what-the-player-remembers). + +## The state file + +The other half: what the player remembers **for itself**, across restarts. You do not write +this one. + +```ini +# Written by sendspin-cli. Edits are overwritten. +last-server = 7f3a… +last-server-hash = 3387423128 +muted = true +static-delay-ms = 375 +volume = 42 +``` + +| Key | What it is | +|---|---| +| `static-delay-ms` | this endpoint's static delay. The spec **requires** a client to persist it | +| `volume`, `muted` | the gain and mute the output was last told to apply. RECOMMENDED by the spec | +| `last-server` | the server id, which mDNS discovery uses to break a tie between candidates | +| `last-server-hash` | an opaque `uint32_t` the library asks us to keep so *it* can prefer the last-played server among inbound connections | + +The two server keys mean different things and are deliberately not reconciled with each +other. + +It lives at `$XDG_STATE_HOME/sendspin-cli/state`, then +`$HOME/.local/state/sendspin-cli/state`, and **`--state-dir ` overrides both** — a +systemd *system* unit has neither variable and is handed `/var/lib/sendspin-cli` by +`StateDirectory=`. With none of the three the player still runs and simply remembers +nothing. + +Under the unit the directory and the file belong to the unprivileged `sendspin-cli` account, +and a `/var/lib/sendspin-cli` left root-owned by an earlier root-run version needs nothing +done to it — `StateDirectory=` chowns a directory it finds as well as one it creates, so what +was remembered carries over. See +[Running as a Service](Running-as-a-Service#upgrading-from-a-version-that-ran-as-root). + +Writes go through a temporary, an `fsync` and a `rename` at mode `0600`, so a player that +loses power mid-write leaves either the old file or the new one and never half of either. + +**Two players on one host share this file** unless you give each its own `--state-dir`. They +already need different `--port`s; give them different state directories too, or the second +one to save its volume overwrites the first's. + +### `static-delay` versus a remembered `static-delay-ms` + +`static-delay` in the config is a **first-run default, not an override**. The library prefers +whatever the state store remembers and reads the config value only when there is nothing +remembered — exactly as a restored volume beats the sink's default. So once a server or +`sendspin-cli delay` has set one, the remembered value wins every run after and the config +key is inert. Nothing in the log names which of the two won, so `sendspin-cli status` is +where you read the value actually in force. + +Three things can set it: a server's `set_static_delay`, the `delay` subcommand, and this key +on a first run with nothing yet remembered. To make the config value take again, remove the +state file. + +## Applying a change + +The player reads its config once, at startup. There is no reload signal: + +```bash +sudo systemctl restart sendspin-cli +``` + +`SIGHUP` reopens the `-f` logfile and nothing else — that is for `logrotate`, not for +configuration. + +## Next + +- [Controlling the Player](Controlling-the-Player) — what you can change without a restart +- [Running as a Service](Running-as-a-Service) — the two flags the unit passes, and why +- [Troubleshooting](Troubleshooting) diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md new file mode 100644 index 0000000..a548740 --- /dev/null +++ b/docs/wiki/Controlling-the-Player.md @@ -0,0 +1,192 @@ +# Controlling the Player + +The player listens on a **Unix socket**, and the same binary is its own client. This is the +deliberate addition to the squeezelite model: `sendspin-cli pause` on the player's own host +drives it, with no server and no controller app in the loop. + +```console +$ sendspin-cli status +name: living-room +server: Music Assistant (connected) +state: playing +stream: receiving +track: Nils Frahm - Says +position: 2:05 / 9:03 (estimated) +group volume: 55 +repeat: off +shuffle: off +player volume: 80 +static delay: 0 ms +note: state, position, repeat and shuffle are the server's last report; a server that does not resend them after a change will show stale values here +output: default (48000 Hz / 2 ch / 16-bit) + +$ sendspin-cli pause +$ sendspin-cli vol 40 +$ sendspin-cli seek-rel -30000 +$ sendspin-cli delay 250 +``` + +**The subcommand comes first, before any flag** — `sendspin-cli vol 50 --port 9000`, never +`sendspin-cli --port 9000 vol 50`. `argv[1]` is split off before the flag parser runs, which +is also what lets `seek-rel -5000` be an offset rather than a flag cluster. + +## Every subcommand + +| Command | Argument | What it does | +|---|---|---| +| `status` | | what this player and its group are doing — **answered locally** | +| `play` | | resume or start playback | +| `pause` | | pause playback | +| `stop` | | stop playback | +| `next` | | skip to the next track | +| `prev` | | skip to the previous track | +| `vol` | `<0-100>` | set the **group** volume | +| `mute` | `on\|off` | mute or unmute the group | +| `seek` | `` | seek to an absolute position | +| `seek-rel` | `<+/-ms>` | seek by an offset; negative goes backwards | +| `repeat` | `off\|one\|all` | set the repeat mode | +| `shuffle` | `on\|off` | turn shuffle on or off | +| `switch` | | move this player through the groups available to it | +| `delay` | `<0-5000>` | this endpoint's static delay — **answered locally** | + +Twelve of those go out to the server as `controller@v1` commands. Two never leave the host: +`status`, formatted from the daemon's own view, and `delay`, which drives this endpoint's +own player role. + +## Three that are easy to misread + +**`vol` is the *group* volume, not this box's output level.** It goes to the server, which +spreads it across every player in the group and clamps it per player. A squeezelite refugee +will expect `vol 50` to move *this* box, and it does not — which is why `status` prints +`group volume` and `player volume` as two named lines rather than one ambiguous `volume:`. + +**`switch` is not a source selector.** Per the spec's switch cycle it re-homes this client +between the groups available to it. It sits next to `play` and `pause` and means something +quite different. + +**`delay` really is this endpoint's own**, and its direction is the opposite of what the +name suggests. It is not "play this speaker later"; it is "my gear is *already* this far +behind". The sync task **subtracts** the figure from every chunk's timestamp, so the player +hands audio to the device that much **earlier** and the sound lands on the timestamp the +server meant. If this speaker sounds 250 ms late against the rest of the group: + +```console +$ sendspin-cli delay 250 # my amp adds 250 ms, so hand audio over 250 ms early +$ sendspin-cli status | grep 'static delay' +static delay: 250 ms +$ sendspin-cli delay 0 # off again +``` + +It works with **no server connected**, it is **remembered across restarts** (the spec +requires that of a client), and the player still tells the server, which needs it to work +out how far ahead to send audio. Out-of-range values are refused rather than clamped. +Changing it mid-stream re-times chunk scheduling, so expect a brief resync — set it while +stopped where you can. + +The long version of all three is in +[The local control channel](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-local-control-channel). + +## Reading `status` + +**Four fields are the server's word and can lag what is true**: `state`, `position`, +`repeat` and `shuffle` all come from the server's last report, and the spec does not oblige +it to resend them after acting. Observed against a real server, `shuffle` read `off` for +minutes while it was demonstrably shuffling. If you have just changed something and the +figure has not moved, that is the likely reason — not a failed command. The `note:` line +above `output:` says so, and appears whenever a server is connected. + +- **`position` says `(estimated)` while playing**, because the library interpolates forward + from the last progress the server sent. After a seek the server does not re-report, the + estimate drifts by however far you jumped. Paused, it is the server's own snapshot and + carries no marker. +- **`player volume` is the gain this box's output is applying**, and says + `(default; no server has set it)` until a server sends a volume command — which is how you + tell "nobody has set this" from a server that chose full output. +- **`state` and `stream` are different facts.** `state` is the *group's* transport state. + `stream` is whether audio is arriving at *this* endpoint — a player dropped from the group + loses it while the group plays on. A `stream: receiving` line with **no format after the + device name** means the device refused the stream's format and its audio is being + discarded; the log says so loudly at the same moment. + +## Finding the socket + +The default is `$XDG_RUNTIME_DIR/sendspin-cli-.sock`, mode `0600`, where `` is +`--port`. The port is in the name so two players on one host each get their own — **so a +subcommand needs the same `--port` as the player**, or an explicit `--control-socket`: + +```bash +sendspin-cli --port 9000 & # this player's socket carries 9000 +sendspin-cli status --port 9000 # ...so its subcommands need it too +sendspin-cli status --control-socket /run/user/1000/sendspin-cli-9000.sock # or name it +``` + +**Under the systemd system unit**, both change. A system unit has no `$XDG_RUNTIME_DIR`, so +the unit passes `--control-socket /run/sendspin-cli/control.sock`, and the socket belongs to +the unprivileged `sendspin-cli` account the unit runs as, at mode `0600`. Root is what +connects to it, not being subject to the mode: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +Put `control-socket = /run/sendspin-cli/control.sock` in `/etc/sendspin-cli.conf` and the +flag becomes unnecessary. The `sudo` is still needed. Why the daemon ignores that same key +while its subcommands read it is [Configuration](Configuration)'s precedence rule. + +**On macOS the default works with nothing set**, because there is no `$XDG_RUNTIME_DIR` +there either and the path comes from the per-user directory under `/var/folders` that +launchd already provides. There is deliberately **no `/tmp` fallback anywhere**: `/tmp` is +world-writable, and a socket there would let any local user pause your music. + +## Exit status is the interface for scripts + +| Status | Means | +|---|---| +| `0` | sent, or answered locally (`status`, `delay`) | +| `1` | the command line did not parse (`vol 500`, `delay 5001`) | +| `2` | the player refused the argument (a `seek` past the server's `seek_max_ms`) | +| `3` | **nothing is listening on that socket** — no player, or the wrong `--port` | +| `4` | the player is up but has **no server connection** | +| `5` | the server does not offer that command | +| `6` | the exchange broke down | + +`3`, `4` and `5` are kept apart because they call for three different actions: start the +daemon, connect it to a server, or stop asking for a command this server does not offer. `4` +and `5` especially — a dropped connection *empties* the server's advertised command list, so +collapsing them would answer "pause is not supported" when the truth is that nothing is +connected. + +`status` and `delay` are never refused by any of them: nothing about either is sent, so a +missing connection is no obstacle. A disconnected player is exactly when reading `status` is +worth doing. + +```bash +if ! sendspin-cli status >/dev/null 2>&1; then + case $? in + 3) echo 'no player running there' ;; + *) echo 'something else' ;; + esac +fi +``` + +## Driving it without this binary + +Connect, send one line, read until the player closes. The first line back is `ok` or +`error : `; a `status` payload follows the `ok`. One command per connection. + +```console +$ printf 'status\n' | socat - UNIX-CONNECT:/run/user/1000/sendspin-cli-8928.sock +ok +name: living-room +... +``` + +The socket is polled from the main loop rather than from a thread, so a request round-trips +in up to 10 ms. That is a deliberate trade — the library calls behind it are documented +main-thread-only, and a reader thread would be a data race. + +## Next + +- [Configuration](Configuration) — making `control-socket` stick +- [Running as a Service](Running-as-a-Service) +- [Troubleshooting](Troubleshooting) — what exit `3` usually means diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md new file mode 100644 index 0000000..8c1d59a --- /dev/null +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -0,0 +1,181 @@ +# Getting Started on Linux + +From nothing to a player your Sendspin server can find. On a Raspberry Pi, read +[Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) instead — it is this +page plus the handful of things a Pi does differently. + +**You need:** a 64-bit Linux host (`x86_64` or `arm64`), systemd, a sound card, and root. + +## The short way + +```bash +curl -fLO https://raw.githubusercontent.com/chrisuthe/sendspin-cpp-cli/main/scripts/get_started_linux.sh +less get_started_linux.sh # it is about to run things as root; read it +chmod +x get_started_linux.sh +./get_started_linux.sh +``` + +It installs the release for this machine's architecture and sets the service up. It does +not pipe into a shell, and it does not run anything as root without printing the exact +commands first and waiting for you to say yes: + +``` +==> These are the commands that need root + + sudo tar -xzf /tmp/tmp.XXXX/sendspin-cli-0.1.0-linux-arm64.tar.gz --strip-components=1 -C / sendspin-cli-0.1.0-linux-arm64/usr + sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf + sudo systemd-sysusers + sudo systemctl daemon-reload + sudo systemctl enable sendspin-cli + sudo /usr/local/bin/sendspin-cli -l + +Run them? [y/N] +``` + +`--yes` skips the prompt, and is required when stdin is not a terminal — a script that +cannot ask refuses rather than assuming. `--version v0.1.0` installs a specific release +instead of the newest. + +### What it actually does + +1. **Checks the architecture.** `x86_64` and `aarch64` have builds. 32-bit ARM does not, and + is refused with the reason and the fix rather than an "unsupported" shrug. +2. **Finds the newest release** and downloads that archive plus `SHA256SUMS`. +3. **Verifies the checksum**, and stops without installing anything if it does not match. +4. **Unpacks it into `/`** with the member-selected `tar` form, so `BUILD-INFO.txt` stays in + the archive. +5. **Copies the annotated example config** to `/etc/sendspin-cli.conf` if you have none. + Every line in it is commented out, so it chooses nothing. +6. **Runs `systemd-sysusers`**, which creates the unprivileged `sendspin-cli` account the + unit runs as out of the declaration the payload just installed. This is the one step a + tarball cannot do for itself, and without it the unit does not start at all — see + [Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). +7. **Enables the unit — and starts it only if an output is already configured.** More on + that below; it is the one surprising thing the script does. +8. **Lists this host's sound devices** and prints the two commands that finish the job. + +Re-running it is how you upgrade: it overwrites the same paths, and restarts the service if +step 7 finds an `output` configured — which after a first run it will. Both step 6 and step 7 +are idempotent, so nothing there minds being run twice. + +### Why it does not start the player + +A systemd **system** unit has no user session, so there is no PipeWire or PulseAudio for +ALSA's `default` PCM to follow — and the device that opens perfectly from your shell usually +will not open under `systemctl`. The unit is `Restart=on-failure` with `RestartSec=5`, so a +player started before you have named a card fails and is retried every five seconds forever, +while the script that started it prints congratulations. + +So it enables the unit, shows you the devices, and leaves starting it to you. Once +`/etc/sendspin-cli.conf` names an `output`, the script starts the player itself on every +subsequent run. + +## The long way + +If you would rather do it by hand, or the script refuses this host: + +```bash +# 1. Install — see the Installation page for the checksum step +sudo tar -xzf sendspin-cli-0.1.0-linux-arm64.tar.gz --strip-components=1 -C / \ + sendspin-cli-0.1.0-linux-arm64/usr + +# 2. Find a device +sendspin-cli -l + +# 3. Tell it which one +sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf +sudo nano /etc/sendspin-cli.conf # output = hw:1,0 + +# 4. Create the account the unit runs as, then start it +sudo systemd-sysusers +sudo systemctl daemon-reload +sudo systemctl enable --now sendspin-cli +``` + +`systemd-sysusers` is not optional and is not a `useradd` you have to compose — the payload +installs the declaration, and this reads it. Leave it out and `systemctl status` reports +`217/USER`. + +## Choosing an output + +`sendspin-cli -l` lists every device this host can play through, and for each one the rates, +formats and channel counts it really accepts: + +``` + hw:CARD=Headphones,DEV=0 + bcm2835 Headphones + rates: 8000 11025 16000 22050 32000 44100 48000 + formats: S16_LE + channels: 2 +``` + +Put the name in the config file as `output`, without the dashes of the flag it mirrors: + +```ini +output = hw:1,0 +``` + +Three forms are worth knowing, and there are more in +[Choosing an output](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#choosing-an-output): + +| Value | What it means | +|---|---| +| `hw:1,0` | that card directly, bypassing any sound server | +| `plughw:1,0` | the same card, letting ALSA convert rate and format for it | +| `default` | follow the host's own configuration — PipeWire, PulseAudio or bare hardware | + +`default` is the right answer from a login shell and usually the wrong one under a system +unit, for the reason above. Under a **user** unit (`systemctl --user`) it is right again, +because there the session and its sound server exist. + +## Check it worked + +```bash +systemctl status sendspin-cli +journalctl -u sendspin-cli -f +``` + +A healthy start looks like this: + +``` +I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0, mDNS: dns_sd (avahi-compat)) +I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) +I control: Listening on /run/sendspin-cli/control.sock +``` + +Then ask the player itself: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +`sudo`, and the flag, are both needed under the system unit: the socket is mode `0600` and +belongs to the `sendspin-cli` account, and root has no `$XDG_RUNTIME_DIR` for the default path +to come from. Root can read the socket regardless, not being subject to the mode. Adding +`control-socket = /run/sendspin-cli/control.sock` to the config saves repeating the flag — +see [Controlling the Player](Controlling-the-Player). + +## Now find it from your server + +The player advertises `_sendspin._tcp` and waits. Open your Sendspin controller and it +should appear under the name it logged — which is `-n`, falling back to this host's name. +Nothing needs configuring on the server side. + +To go the other way and have the player dial the server instead, set `server` in the config: + +```ini +server = 192.168.1.10 # a host, port 8927 assumed +server = mdns:Music Assistant # or discover one by its advertised name +``` + +Any `server` value turns the mDNS advertisement off. That is the spec's rule rather than a +preference here, and the two modes are mutually exclusive by design — see +[The two connection modes](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). + +## Next + +- [Configuration](Configuration) — every key, and what the player remembers by itself +- [Controlling the Player](Controlling-the-Player) — `pause`, `vol`, `delay` and the rest +- [Running as a Service](Running-as-a-Service) — the account it runs as, drop-ins, and what + the hardening block takes away +- [Troubleshooting](Troubleshooting) — when it starts and stays silent diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md new file mode 100644 index 0000000..443053f --- /dev/null +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -0,0 +1,161 @@ +# Getting Started on a Raspberry Pi + +A Pi makes an excellent Sendspin endpoint: it is quiet, it is cheap, and one per room is the +whole point of a synchronized multi-room protocol. Installing on one is +[Getting Started on Linux](Getting-Started-on-Linux) — a Pi is an arm64 Linux box and takes +the same `linux-arm64` archive an arm64 server does — plus the five things on this page. + +## 1. You need a 64-bit OS. This is not negotiable + +**The builds are `arm64` only. There is no 32-bit ARM build, and none is coming from CI.** +The matrix has no armv7 or 32-bit Pi leg, which is recorded in +[`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), +item 12. + +Check what you are running before anything else: + +```console +$ uname -m +aarch64 +``` + +| It says | Where you are | +|---|---| +| `aarch64` | Good. Carry on. | +| `armv7l`, `armv6l` | A 32-bit userland. No archive will install. | + +A 32-bit answer on 64-bit hardware is the common case — Raspberry Pi OS shipped a 32-bit +userland by default for years, and plenty of installed cards still run it. The fix is to +reimage with **Raspberry Pi OS (64-bit)**, or *Raspberry Pi OS Lite (64-bit)* for a headless +player, which is what a Sendspin endpoint wants anyway. In Raspberry Pi Imager the 64-bit +builds are under **Raspberry Pi OS (other)**. + +| Model | 64-bit capable | +|---|---| +| Pi 5, Pi 4, Pi 400, Pi 3, Pi Zero 2 W, CM3/CM4/CM5 | Yes | +| Pi 1, Pi Zero, Pi Zero W, and Pi 2 boards before v1.2 | **No** — build from source, or use other hardware | + +If your board is not on either row, do not go looking it up: install a 64-bit image and run +`uname -m`. That answer is the only one that decides anything here. + +The getting-started script refuses a 32-bit userland with this whole answer rather than an +"unsupported architecture", because it is the single most common way a Pi install goes +wrong. + +## 2. Install + +Exactly as on any Linux host: + +```bash +curl -fLO https://raw.githubusercontent.com/chrisuthe/sendspin-cpp-cli/main/scripts/get_started_linux.sh +less get_started_linux.sh # read it before it runs things as root +chmod +x get_started_linux.sh +./get_started_linux.sh +``` + +The script detects the Pi from `/proc/device-tree/model` and adds the notes below to what it +prints at the end. Everything on +[Getting Started on Linux](Getting-Started-on-Linux) — what it does, why it enables the unit +without starting it, and the by-hand equivalent — applies unchanged. + +## 3. The Pi has several sound cards, and you must pick one + +The headphone jack and each HDMI output are separate ALSA cards, and there is no useful +"just play" default under a system unit. Ask: + +```console +$ sendspin-cli -l + hw:CARD=Headphones,DEV=0 + bcm2835 Headphones + rates: 8000 11025 16000 22050 32000 44100 48000 + formats: S16_LE + channels: 2 + hw:CARD=vc4hdmi0,DEV=0 + vc4-hdmi-0 + ... +``` + +Then name it in `/etc/sendspin-cli.conf`: + +```ini +output = hw:1,0 +``` + +`output = default` is what usually leaves a Pi silent as a service, for the reason on the +Linux page: no user session, so nothing for ALSA's `default` to follow. + +**On a USB DAC or a HAT**, the card is in that same list — a HiFiBerry, an IQaudIO, a +Pi-DAC and a plain USB DAC all appear as ordinary ALSA cards once their overlay is enabled +in `/boot/firmware/config.txt`. Enable the overlay, reboot, and run `-l` again; this player +has nothing Pi-HAT-specific to configure. + +**The 3.5 mm jack is not a good listening output.** It is a PWM output on the SoC and it +sounds like one. It is fine for proving the chain works. For anything else, use HDMI to a +receiver, a USB DAC, or an I²S HAT. + +## 4. `/dev/snd` belongs to root and the `audio` group + +`/dev/snd/*` is `root:audio` mode `0660`, so `audio` membership is what decides whether a +player can open a card at all. Whoever runs it needs it. + +**As a service, this is already arranged.** The unit runs as an unprivileged `sendspin-cli` +account, and the declaration the payload installs puts that account in `audio` in the same +file that creates it — the two are owed together, and arrive together. What you do have to do +once is turn the declaration into an account: + +```bash +sudo systemd-sysusers +``` + +The getting-started script does that for you. Skipping it is a unit that does not start and a +`systemctl status` reading `217/USER`; it is not a player that starts and stays silent. See +[Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). + +**From your own shell, it is on you.** Put yourself in the `audio` group once, then log out +and back in — group membership is only picked up at login: + +```bash +sudo usermod -aG audio "$USER" +``` + +Without it the player starts and opens nothing. The `sendspin-cli` account's membership does +nothing for a player running as you. + +## 5. Things a Pi does that a server does not + +- **SD cards wear out**, and the state file is rewritten whole on every *distinct* volume a + server sends. A repeat of the current value is skipped, but a slider drag is one rewrite + per step; debouncing is a known gap, listed under + [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) + item 8. If that worries you, point the state at the runtime directory, which is already a + tmpfs — and accept that volume, mute and the static delay are then forgotten across + reboots. It has to be a **drop-in** rather than a config key, because the unit passes + `--state-dir` itself and the command line wins (`sudo systemctl edit sendspin-cli`): + + ```ini + [Service] + ExecStart= + ExecStart=/usr/local/bin/sendspin-cli --control-socket /run/sendspin-cli/control.sock --state-dir /run/sendspin-cli + ``` + + `ExecStart=` on its own line first, which is systemd's rule for clearing any list-valued + directive. +- **Wi-Fi power saving breaks mDNS.** A Pi that vanishes from your controller after a few + idle minutes and comes back when you ping it is the wireless NIC sleeping, not this + player. `sudo iw dev wlan0 set power_save off` is the usual fix; Ethernet avoids it + entirely, and a fixed endpoint deserves a cable. +- **Underruns on a busy Pi** show as clicks or dropouts. Raise the buffer: + `buffer-ms = 250` in the config. The default is 100 ms and the range is 10–2000; see + [Buffering, and what gets advertised](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#buffering-and-what-gets-advertised). +- **`avahi-daemon` is what provides mDNS on a Pi**, and Raspberry Pi OS ships it running. If + you have turned it off, the player warns and retries rather than failing — but nothing will + discover it until it is back. +- **One player per Pi.** Two on one host need different `--port`, different `--state-dir` + and different control sockets. It works; it is just not what a Pi is usually for. + +## Next + +- [Configuration](Configuration) — every key the config file takes +- [Controlling the Player](Controlling-the-Player) — including `delay`, for a Pi feeding an + amplifier that adds latency of its own +- [Troubleshooting](Troubleshooting) — silent player, no discovery, and the rest diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..0284c92 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,95 @@ +# sendspin-cli + +A headless **Sendspin audio player** for Linux and macOS — what squeezelite is to +Lyrion/Logitech Media Server, `sendspin-cli` is to the +[Sendspin](https://github.com/Sendspin/spec) protocol. It advertises itself over mDNS, +waits for a Sendspin server to find it, plays what it is sent in sync with every other +player in the group, and takes its flags and its ergonomics from squeezelite so that +muscle memory carries over. + +> **Status: early scaffold.** The player works; not everything on the roadmap is built. +> [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) +> is the honest list of what is and is not done. + +## Start here + +| | | +|---|---| +| [Getting Started on Linux](Getting-Started-on-Linux) | One script, from nothing to a player on the network | +| [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) | The same script, plus what a Pi does differently | +| [Installation](Installation) | Every way in: release archive, macOS `.pkg`, source | +| [Configuration](Configuration) | The config file, and what the player remembers by itself | +| [Controlling the Player](Controlling-the-Player) | `sendspin-cli pause` and the other thirteen subcommands | +| [Running as a Service](Running-as-a-Service) | The systemd unit, the account it runs as, drop-ins, and reading the log | +| [Troubleshooting](Troubleshooting) | It starts and makes no sound, and the rest | + +## What it does, in one screen + +```console +$ sendspin-cli -n living-room +I cli: sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default, mDNS: dns_sd (avahi-compat)) +I mdns: advertising _sendspin._tcp as "living-room" on port 8928 (path /sendspin) +``` + +That is the whole of the usual setup: nothing to configure on either end. A Sendspin +server discovers the advertisement and dials in. `-s ` inverts it and makes this +player the one dialling, which the protocol treats as the other of two mutually exclusive +modes — see +[The two connection modes](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). + +Audio goes out through ALSA (the Linux default) or PortAudio (the cross-platform one, and +the only way to make noise on macOS), with volume applied in software on a curve the spec +names. The player is also driven from its own host over a Unix socket: + +```console +$ sendspin-cli status +$ sendspin-cli pause +$ sendspin-cli vol 40 +``` + +## Supported platforms + +| Platform | Architecture | How | +|---|---|---| +| Linux | `x86_64`, `arm64` | Release tarball, or `scripts/get_started_linux.sh` | +| macOS | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | +| Raspberry Pi | `arm64` only — **a 64-bit OS is required** | The Linux tarball, same as any arm64 host | + +The macOS builds are made on the `macos-14` CI runner and declare no minimum OS version; +what the installer `.pkg` does check is the architecture, read off the binary with `lipo` at +build time, so it turns an Intel Mac away rather than reporting success. + +There is no 32-bit ARM build and no Intel-Mac build. The CI matrix has no armv7, 32-bit Pi +or macOS `x86_64` leg, which is recorded in +[`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), +item 12. +Anything else builds from source. + +## Where things live + +- **[`README.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md)** is + the reference, and ships inside every archive at + `usr/local/share/doc/sendspin-cli/README.md`. It explains *why* the player behaves as it + does — the two connection modes, how `-o` resolves its argument, why `vol` is the group's + volume and not this box's. These wiki pages link into it rather than restating it, so + there is one copy of each argument and it is the copy an offline tarball holder also has. +- **[`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md)** + is what is built, what is not, and what was actually tested rather than reasoned about. +- **`sendspin-cli --help`** is the flag reference, and the config file's reference too: + every config key is a long flag name minus its dashes. + +## Editing these pages + +**This wiki is generated. Do not edit it here — the edit will be overwritten.** + +The pages are authored in the repository at +[`docs/wiki/`](https://github.com/chrisuthe/sendspin-cpp-cli/tree/main/docs/wiki) and +mirrored here by +[`.github/workflows/wiki.yml`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/.github/workflows/wiki.yml) +on every push to `main`. A page removed there is removed here; a page changed here is put +back on the next push. + +That is deliberate rather than awkward. A wiki edit lands with no pull request, no review +and no CI, which for a document that tells people what commands to run as root is the wrong +default. Send a documentation fix as a pull request against `docs/wiki/`, the same way a +code fix goes, and it arrives here when it merges. diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md new file mode 100644 index 0000000..8e24b19 --- /dev/null +++ b/docs/wiki/Installation.md @@ -0,0 +1,188 @@ +# Installation + +Four ways in, depending on what you have. If you are on Linux and want the short version, +[Getting Started on Linux](Getting-Started-on-Linux) does all of this in one command. + +| You have | Take | +|---|---| +| A Linux box or a Raspberry Pi | The `linux-x86_64` or `linux-arm64` tarball | +| An Apple-silicon Mac | The `macos-arm64` installer `.pkg`, or the tarball | +| Anything else | [Build from source](#build-from-source) | + +Everything published is on the +[Releases page](https://github.com/chrisuthe/sendspin-cpp-cli/releases). Per-commit builds +of unreleased work are under the repository's Actions tab and expire after 14 days — see +[CI](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#ci). + +> **No release exists yet.** `v0.1.0` has not been tagged at the time of writing, so until +> it is, the only routes are building from source or staging a payload yourself. The +> getting-started script says the same thing rather than failing obscurely. + +## What is in the archive + +Every tarball is a staged `cmake --install` payload, which means **every path under `usr/` +is the path the file installs to**: + +``` +sendspin-cli-0.1.0-linux-arm64/ +├── BUILD-INFO.txt # what this build is, and what it needs +└── usr/local/ + ├── bin/sendspin-cli + ├── lib/systemd/system/sendspin-cli.service # Linux only + ├── lib/sysusers.d/sendspin-cli.conf # Linux only + └── share/doc/sendspin-cli/ + ├── README.md + ├── LICENSE + └── sendspin-cli.conf.example +``` + +The prefix is baked in at build time — the unit's `ExecStart` is an absolute +`/usr/local/bin/sendspin-cli` — so a binary moved somewhere else leaves the unit pointing at +nothing. Read `BUILD-INFO.txt` first; it names the runtime packages that build needs. + +## Linux + +```bash +# 1. Take the archive for this machine's architecture, and the checksums +VERSION=0.1.0 +ARCH=$(uname -m); [ "$ARCH" = aarch64 ] && LEG=linux-arm64 || LEG=linux-x86_64 +BASE=https://github.com/chrisuthe/sendspin-cpp-cli/releases/download/v$VERSION +curl -fLO "$BASE/sendspin-cli-$VERSION-$LEG.tar.gz" +curl -fLO "$BASE/SHA256SUMS" + +# 2. Verify before unpacking anything +sha256sum --ignore-missing -c SHA256SUMS + +# 3. Install +sudo tar -xzf "sendspin-cli-$VERSION-$LEG.tar.gz" --strip-components=1 -C / \ + "sendspin-cli-$VERSION-$LEG/usr" +sudo systemd-sysusers +sudo systemctl daemon-reload +``` + +**`systemd-sysusers` is the one thing unpacking cannot do for itself.** The unit runs as an +unprivileged `sendspin-cli` account, the archive carries the declaration for it at +`usr/local/lib/sysusers.d/sendspin-cli.conf`, and a tarball has no `postinst` to turn one into +the other. It is idempotent, and without it the unit reports `217/USER` rather than starting. +See [Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). + +`--ignore-missing` because `SHA256SUMS` covers every archive the release carries and you +have taken one of them; without it the other three are reported as failures. It is not a +way of passing with nothing checked — `sha256sum` still exits non-zero if the flag leaves +it with no file to verify. + +**Naming `/usr` as the member to extract is what leaves `BUILD-INFO.txt` in the +archive** instead of writing it to `/`. `--strip-components=1` drops the archive's own top +level so the rest lands where it belongs. + +Runtime packages, if the binary will not start: + +```bash +sudo apt install libasound2t64 libportaudio2 libavahi-compat-libdnssd1 # Debian / Ubuntu +sudo dnf install alsa-lib portaudio avahi-compat-libdns_sd # Fedora / RHEL +``` + +To run it without installing anywhere, unpack it and use it in place: + +```bash +tar -xzf sendspin-cli-0.1.0-linux-arm64.tar.gz +./sendspin-cli-0.1.0-linux-arm64/usr/local/bin/sendspin-cli --help +``` + +Then [Running as a Service](Running-as-a-Service) for the systemd half. + +## macOS + +The installer is the easier of the two: + +```bash +sudo installer -pkg sendspin-cli-0.1.0-macos-arm64.pkg -target / +sendspin-cli --version +``` + +It refuses a Mac it cannot run on — the architectures are read off the binary at build time +and declared in the package — so an Intel Mac is turned away rather than told the install +worked. To undo it: remove the four files and +`sudo pkgutil --forget io.github.chrisuthe.sendspin-cli`. + +Or take the tarball, and **unpack it from a terminal, not in Finder**: + +```bash +tar -xzf sendspin-cli-0.1.0-macos-arm64.tar.gz +./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli --version +``` + +**Neither the binary nor the `.pkg` is signed or notarized.** They are ad-hoc signed — the +minimum an arm64 Mach-O needs to execute at all — so Gatekeeper has no developer identity +to check. Whether you notice depends entirely on the quarantine flag, which `tar` and +`unzip` do not propagate and Finder's Archive Utility does. If macOS refuses it: + +```bash +xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli +``` + +The full picture — including why `sudo installer` is not gated at all, and why the `.pkg` +exists despite not fixing Gatekeeper — is in +[macOS, and Gatekeeper](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#macos-and-gatekeeper) +and +[The macOS installer `.pkg`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-macos-installer-pkg). +A Developer ID signature and notarization are owed and tracked as roadmap item 10. + +There is no launchd job in the payload. On macOS the player runs from a shell or under a +launch agent you write. + +## Raspberry Pi + +The Pi takes the `linux-arm64` archive like any other arm64 Linux host, with one hard +requirement: **a 64-bit OS**. See +[Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi). + +## Build from source + +For an architecture with no release — 32-bit ARM, an Intel Mac, anything not in the matrix +— or to build against a different version of the library. + +```bash +sudo apt install pkg-config libasound2-dev portaudio19-dev libavahi-compat-libdnssd-dev # Debian / Ubuntu +sudo dnf install pkgconf alsa-lib-devel portaudio-devel avahi-compat-libdns_sd-devel # Fedora / RHEL +brew install portaudio pkgconf # macOS + +git clone https://github.com/chrisuthe/sendspin-cpp-cli.git +cd sendspin-cpp-cli +cmake -B build +cmake --build build +./build/sendspin-cli --help +``` + +Needs CMake ≥ 3.16, a C++20 compiler, and network access on the first configure — +sendspin-cpp is pulled in with `FetchContent` at a pinned tag and fetches its own +dependencies in turn. + +**The audio backends and mDNS are optional and auto-detected**, so read the configure +output rather than assuming; a missing `-dev` package does not fail a configure, it just +produces a binary that cannot do that thing: + +``` +-- sendspin-cli audio backends: null, stdout, alsa, portaudio +-- sendspin-cli mDNS: dns_sd (/usr/lib/aarch64-linux-gnu/libdns_sd.so) +``` + +To install what you built: + +```bash +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr/local # the prefix is chosen HERE +cmake --build build +sudo cmake --install build --component sendspin-cli +``` + +`--component sendspin-cli` is not garnish — without it, `cmake --install` also stages 143 +files belonging to a fetched dependency. The prefix is fixed at *configure* time because +the unit's `ExecStart` names it absolutely, so reconfigure rather than passing +`--install --prefix`. Both points, at length, in +[Install](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#install). + +## Next + +- [Configuration](Configuration) — pick an output device and make it stick +- [Running as a Service](Running-as-a-Service) — the systemd unit +- [Controlling the Player](Controlling-the-Player) — drive it from its own host diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md new file mode 100644 index 0000000..dae5b21 --- /dev/null +++ b/docs/wiki/Running-as-a-Service.md @@ -0,0 +1,275 @@ +# Running as a Service + +One systemd unit ships in the Linux payload, with sane defaults and nothing to fill in. + +```bash +sudo systemd-sysusers +sudo systemctl daemon-reload +sudo systemctl enable --now sendspin-cli +systemctl status sendspin-cli +journalctl -u sendspin-cli -f +``` + +The unit is installed at `/usr/local/lib/systemd/system/sendspin-cli.service`, which is +already on systemd's search path — nothing needs copying by hand. `daemon-reload` after +installing is what makes systemd notice it. + +**`systemd-sysusers` is the line that is not optional.** It creates the unprivileged account +the unit runs as, out of a declaration installed beside the unit, and a tarball has no +`postinst` to run it for you. Skip it and the unit does not start at all — see +[It runs as its own account](#it-runs-as-its-own-account). + +> **Set an `output` before enabling it**, or this unit will fail and be retried every five +> seconds indefinitely — `Restart=on-failure` with `RestartSec=5`, and a system unit has no +> session for ALSA's `default` PCM to follow. Run `sendspin-cli -l`, pick a card, and put +> `output = hw:1,0` in `/etc/sendspin-cli.conf`. +> [Getting Started on Linux](Getting-Started-on-Linux) has the argument in full, and is why +> [`scripts/get_started_linux.sh`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/scripts/get_started_linux.sh) +> enables this unit without starting it. + +## What the payload installs + +| Path | What | +|---|---| +| `/usr/local/bin/sendspin-cli` | the binary | +| `/usr/local/lib/systemd/system/sendspin-cli.service` | the unit | +| `/usr/local/lib/sysusers.d/sendspin-cli.conf` | the account the unit runs as, declared | +| `/usr/local/share/doc/sendspin-cli/README.md` | the reference | +| `/usr/local/share/doc/sendspin-cli/LICENSE` | Apache 2.0 | +| `/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example` | an annotated config | + +The unit goes in `lib/systemd/system` and not a multiarch `libdir` because a unit file is +architecture-independent, and systemd reads `/usr/lib/systemd/system` and +`/usr/local/lib/systemd/system` — never `lib/x86_64-linux-gnu/systemd/system`. The account +declaration is in `lib/sysusers.d` for both of the same reasons: a list of users has no +architecture either, and `systemd-sysusers` searches `/usr/local/lib/sysusers.d` alongside +`/usr/lib/sysusers.d`. + +**`ExecStart` names the binary absolutely**, at the prefix the build was configured for, so +a binary moved out of `/usr/local` leaves the unit pointing at nothing. + +## The shape of it + +`Type=simple`, running the player in the **foreground**, so the log goes to the journal +rather than to a file something has to rotate. `-z` and `-f` would both be working around +the supervisor. Two other shapes exist and are not what ships: `Type=forking` with +`PIDFile=` pointing at `-P` is right for a supervisor with no journal, and `Type=notify` is +unavailable because `sd_notify` is not wired up. + +`After=network.target sound.target` and `After=avahi-daemon.service` — ordering only, and +deliberately no `Wants=`. The player retries its advertisement and its outbound dial on a +backoff, so it comes up perfectly well ahead of the network, and a host whose operator +turned `avahi-daemon` off should stay that way rather than have this unit pull it back in. + +## The two flags on the `ExecStart` line + +Both are there because a system unit has neither of the environment variables the default +path would come from: + +| Unit directive | Flag it pairs with | Without the pair | +|---|---|---| +| `RuntimeDirectory=sendspin-cli` | `--control-socket /run/sendspin-cli/control.sock` | no `$XDG_RUNTIME_DIR`, so no control socket — one warning, and the player carries on | +| `StateDirectory=sendspin-cli` | `--state-dir /var/lib/sendspin-cli` | no `$XDG_STATE_HOME`, so volume, mute and the static delay are forgotten every restart | + +systemd creates and owns both directories, and removes the runtime one when the unit stops +— which is why this unit never meets a stale socket. + +**One consequence**: `state-dir` and `control-socket` in `/etc/sendspin-cli.conf` are +*silently ignored* by the service, because the command line beats the file per option — see +[Configuration](Configuration). Setting `control-socket` to the same path is still worth +doing, since it is what lets a *subcommand* find the socket with no flags. + +## Configure it in the config file, not the unit + +Every config key is a long flag name, so there is nothing the `ExecStart` line can say that +`/etc/sendspin-cli.conf` cannot. Editing the unit means merging your changes by hand on +every upgrade; editing the config does not. See [Configuration](Configuration). + +```bash +sudo nano /etc/sendspin-cli.conf +sudo systemctl restart sendspin-cli +``` + +A config file that does not parse exits non-zero, and `Restart=on-failure` retries it every +five seconds indefinitely. That is the wanted end of it rather than an oversight: the parse +error names the file and its line in the journal on every attempt, and an operator who fixes +the file gets a player back without also having to `systemctl reset-failed` a unit that gave +up. + +## The subcommands need `sudo` here + +The control socket is mode `0600` and belongs to the service account, so an unprivileged +shell cannot connect to it — while root can, because root is not subject to the mode. And +root has no `$XDG_RUNTIME_DIR` for the default path to come from either: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +Adding `control-socket = /run/sendspin-cli/control.sock` to the config removes the flag. The +`sudo` stays. See [Controlling the Player](Controlling-the-Player). + +## It runs as its own account + +The unit names `User=sendspin-cli` — an unprivileged system account with no home and no +shell — so the player, its network-facing WebSocket server included, is not root. + +**Creating that account is the one step installing cannot do for you.** A tarball has no +`postinst`, so the declaration ships beside the unit as `lib/sysusers.d/sendspin-cli.conf` +and one idempotent command turns it into an account: + +```console +$ sudo systemd-sysusers +Creating group 'sendspin-cli' with GID 997. +Creating user 'sendspin-cli' (Sendspin audio player) with UID 997 and GID 997. +``` + +Skip it and the unit does not start at all, which `systemctl status` says in the words that +name the cause rather than hiding it: + +``` +sendspin-cli.service: Main process exited, code=exited, status=217/USER +``` + +The fragment carries two lines and they are owed **together**: the account, and its +membership of `audio`. A `sendspin-cli` in no `audio` group is a player that starts and +cannot open a device, because `/dev/snd` is `root:audio` mode `0660` — which is why this is a +shipped declaration rather than a `useradd` line in a document. If you manage accounts with +your own tooling, the equivalent is +`useradd --system --no-create-home -G audio sendspin-cli`. + +`DynamicUser=` looks like it would avoid all of this and does not: it hands the player a uid +in no supplementary group at all, which deafens the ALSA backend. + +### Upgrading from a version that ran as root + +**Nothing needs doing to `/var/lib/sendspin-cli`.** `StateDirectory=` chowns the directory it +finds as well as the one it creates, recursively, so a root-owned state file from an earlier +install becomes the new account's on the first start and the remembered volume, mute and +static delay carry over. + +Two things are worth checking before the upgrade, and both come from the hardening block: + +- **A `logfile` or `pidfile` in `/etc/sendspin-cli.conf`** pointing anywhere but + `/run/sendspin-cli` or `/var/lib/sendspin-cli` now fails under `ProtectSystem=strict` — + `cannot open logfile /var/log/sendspin-cli.log: Read-only file system`, loudly and on every + restart, rather than a player logging nowhere in silence. Neither key is the shape for this + unit anyway, since journald already has stderr. A drop-in with `ReadWritePaths=/var/log` is + the way back if you want one regardless. +- **A drop-in of your own that set `User=` and `SupplementaryGroups=audio`** — the recipe for + getting off root when the unit had no account of its own — is now overriding a unit that + already names one. Remove the drop-in and take the shipped account instead; `systemctl + revert sendspin-cli` drops every drop-in at once. + +### What is hardened + +The unit carries `ProtectSystem=strict`, `NoNewPrivileges=`, an empty +`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, +`SystemCallFilter=@system-service` and the `Protect*=` family, each commented where it sits. +Read the installed unit for the full block. Three operator-visible edges: + +- The whole block wants systemd **247**; the unit itself still starts on **236**. Below 247 + systemd warns `Unknown key name 'ProtectProc' … ignoring` and runs the unit with one + directive fewer. +- `RuntimeDirectory=` and `StateDirectory=` stay writable under `ProtectSystem=strict`, which + is what leaves the control socket and the state file somewhere to be. `/etc` is only ever + read. +- Four directives that would gate what the ALSA backend reaches are deliberately *absent* — + `PrivateDevices=`, `DeviceAllow=`, `ProcSubset=pid` and `RestrictRealtime=` — because they + pass every check a machine with no sound card can make, and tracked as + [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) + item 10. + +The full argument for every directive, and the `systemd-analyze security` figures, are in +[The systemd unit](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-systemd-unit). + +### Changing any of it + +Use a drop-in rather than editing the installed unit, which an upgrade overwrites: + +```bash +sudo systemctl edit sendspin-cli +``` + +An `ExecStart=` in a drop-in has to be cleared first — `ExecStart=` on its own line, then +the replacement — which is systemd's rule for every list-valued directive rather than +anything about this unit. + +## Reading the log + +Everything goes to the journal, and every line carries a level letter and a subsystem tag: + +```console +$ journalctl -u sendspin-cli -f +I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0, mDNS: dns_sd (avahi-compat)) +I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) +I sendspin.ws_server: Starting server on port: 8928 (max connections: 4) +``` + +The third line is the library's. That is the point of the format — it is the shape +sendspin-cpp's own logging already emits, so one `grep` reaches either half: + +```bash +journalctl -u sendspin-cli | grep ' mdns:' # this player's mDNS lines +journalctl -u sendspin-cli | grep ' sendspin\.' # the library's, all of them +journalctl -u sendspin-cli -p err # only failures +``` + +Ours are `cli`, `audio`, `mdns`, `discovery`, `outbound`, `player`, `metadata` and +`control`; the library's are all `sendspin.`. + +Turn it up with `log-level = debug` in the config. One level covers this player and the +library together — deliberately, so a single key turns up everything about one run. Fatal +startup errors are **not** gated by it: `none` means "do not narrate", not "exit without +saying why". + +Lines are not timestamped by the player under systemd, because journald already stamps them +and a second one would be noise. Only a `-f` logfile gets our own timestamp. See +[Logging](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#logging). + +## A user unit instead + +If the player should follow your desktop session's sound server, a user unit is the better +fit — `$XDG_RUNTIME_DIR` and `$XDG_STATE_HOME` both exist there, so neither flag is needed +and `output = default` works as it does from your shell: + +```bash +mkdir -p ~/.config/systemd/user +cp /usr/local/lib/systemd/system/sendspin-cli.service ~/.config/systemd/user/ +# edit out the --control-socket and --state-dir arguments, the two Directory= lines, +# and the User= line -- a user unit cannot set one, and would refuse to start with it +systemctl --user daemon-reload +systemctl --user enable --now sendspin-cli +loginctl enable-linger "$USER" # so it runs when you are not logged in +``` + +Your own account needs `audio` membership for `/dev/snd` here, since the `sendspin-cli` +account's membership does nothing for a unit that is not running as it: +`sudo usermod -aG audio "$USER"`, then log out and back in. + +The unit that ships is the system one; this is a recipe rather than something the project +installs or tests. + +## Uninstalling + +```bash +sudo systemctl disable --now sendspin-cli +sudo rm -f /usr/local/bin/sendspin-cli +sudo rm -f /usr/local/lib/systemd/system/sendspin-cli.service +sudo rm -f /usr/local/lib/sysusers.d/sendspin-cli.conf +sudo rm -rf /usr/local/share/doc/sendspin-cli +sudo rm -rf /var/lib/sendspin-cli # what it remembered +sudo rm -f /etc/sendspin-cli.conf # your config +sudo systemctl daemon-reload +sudo userdel sendspin-cli # the account, if you want it gone too +``` + +Removing the fragment does not remove the account — `systemd-sysusers` creates users and +never deletes them — so `userdel` is a separate line, and an optional one: a system account +with no home, no shell and nothing running as it costs a passwd entry. + +## Next + +- [Configuration](Configuration) +- [Controlling the Player](Controlling-the-Player) +- [Troubleshooting](Troubleshooting) diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md new file mode 100644 index 0000000..2877e2f --- /dev/null +++ b/docs/wiki/Troubleshooting.md @@ -0,0 +1,351 @@ +# Troubleshooting + +Every failure in this player is meant to say what it is in one line, in the log. Start +there: + +```bash +journalctl -u sendspin-cli -n 50 --no-pager # under systemd +sendspin-cli -d debug # or run it in the foreground, loudly +``` + +## It starts, and there is no sound + +The three common causes, in the order to check them. + +### The device refused the stream's format + +The loudest failure there is, and the one that most looks like health from the outside: the +player is connected, the server thinks it is playing, and the audio is being thrown away. + +``` +E audio: alsa: 'hw:1,0' rejected S24_3LE for 96000 Hz / 2 ch / 24-bit: Invalid argument +E audio: alsa: 'hw:1,0' is not open -- discarding audio until a stream reconfigures it +``` + +`status` shows the same thing more quietly — **a `stream: receiving` line with no format +after the device name**: + +```console +$ sendspin-cli status | grep -E 'stream|output' +stream: receiving +output: hw:1,0 +``` + +Fix it by giving ALSA permission to convert, which is what the `plug` layer is for: + +```ini +output = plughw:1,0 +``` + +`hw:` is the card exactly as it is; `plughw:` is the same card with rate and format +conversion in front of it. Use `sendspin-cli -l` to see what the bare device really takes — +only the four formats this player can emit are listed, since anything else is unreachable +anyway. + +### `output = default` under a system unit + +The single most common first-install problem, and it usually shows as the unit failing +rather than as silence: + +``` +E audio: cannot open ALSA device 'default': No such file or directory -- run with -l to list +this host's PCMs +``` + +Name a card instead of `default`, which under a system unit has no session to follow — +[Getting Started on Linux](Getting-Started-on-Linux) has the whole of why: + +```bash +sendspin-cli -l # find it +sudo nano /etc/sendspin-cli.conf # output = hw:1,0 +sudo systemctl restart sendspin-cli +``` + +Under a **user** unit, or from your own shell, `default` is right again. + +### The player has the wrong device, or none + +```console +$ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +output: null +``` + +`output: null` means audio is being discarded on purpose — either `output = null` in the +config, or a build with no audio backend at all. Check what the binary has: + +```console +$ sendspin-cli -l +Output devices (-o): + null discard audio; needs no sound card at all + ... +``` + +A build with no `alsa` in that list was configured without `libasound2-dev` present. See +[Installation](Installation). + +## The unit keeps restarting + +```console +$ systemctl status sendspin-cli + Active: activating (auto-restart) (Result: exit-code) +``` + +`Restart=on-failure` with `RestartSec=5` retries indefinitely, so the journal has the reason +repeated once every five seconds. The usual ones: + +**`status=217/USER` — the account the unit runs as does not exist.** The first thing to check +on a fresh install, and the only failure here where the player never runs at all: + +``` +sendspin-cli.service: Main process exited, code=exited, status=217/USER +``` + +The unit names `User=sendspin-cli`, and creating that account is the one step unpacking a +tarball cannot do for itself. One idempotent command fixes it: + +```bash +sudo systemd-sysusers +sudo systemctl restart sendspin-cli +``` + +The getting-started script runs it for you; a by-hand install has to. See +[Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). + +**A config file that does not parse.** It names the file and the line, every attempt: + +``` +error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000 +``` + +Fix the line and the player comes back on its own — there is no `systemctl reset-failed` to +do, which is deliberate. + +**`Read-only file system` on a `logfile` or `pidfile`.** The unit runs under +`ProtectSystem=strict`, so a path outside `/run/sendspin-cli` and `/var/lib/sendspin-cli` is +refused rather than silently unwritten: + +``` +error: cannot open logfile /var/log/sendspin-cli.log: Read-only file system +``` + +Neither key is the shape for this unit — journald already has stderr, and `-z`/`-f` are for a +supervisor without one — so removing the key is usually the answer. If you want a logfile +regardless, a drop-in with `ReadWritePaths=/var/log` is the way back. + +**A device that will not open.** See above. + +## Nothing discovers it + +### Check it is advertising + +```console +$ journalctl -u sendspin-cli | grep mdns +I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) +``` + +If that line is absent, one of three things is true. + +**`server` is set.** Any `-s` or `server =` makes this player the one dialling, which +suppresses the advertisement — the spec forbids advertising while this end initiates, and +the run says so: + +``` +Not advertising _sendspin._tcp: -s makes this player the one initiating the connection, +and the Sendspin spec forbids advertising while it is +``` + +There is deliberately no flag that turns both modes on together. See +[The two connection modes](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). + +**`no-mdns` is set.** Remove it. + +**There is no mDNS daemon.** On Linux that is `avahi-daemon`, reached through +`libavahi-compat-libdnssd`. The player warns and retries on a backoff rather than failing, +because a player nobody can find is still a player: + +```bash +systemctl status avahi-daemon +sudo apt install avahi-daemon libavahi-compat-libdnssd1 +``` + +**Or this build has no mDNS at all**, which it says at parse time rather than starting and +quietly finding nothing: + +``` +I mdns: This build has no mDNS support, so it cannot be discovered: point a server at +ws://:8928/sendspin, or dial one with -s. See docs/ROADMAP.md. +``` + +Rebuild with `libavahi-compat-libdnssd-dev` present, or point the server at the URL by hand. + +### It advertises and still nothing finds it + +- **mDNS does not cross subnets or most VLANs.** The server and the player must be on the + same broadcast domain, or you need an mDNS reflector on the router. +- **Wi-Fi power saving** puts the NIC to sleep and the advertisement with it. Common on a + Raspberry Pi: `sudo iw dev wlan0 set power_save off`, or use Ethernet. +- **Docker's default bridge network does not carry mDNS.** Use `--network host`. +- **A firewall blocking UDP 5353** blocks discovery, and TCP on `--port` (8928 by default) + blocks the connection that follows it. + +Verify the advertisement independently: + +```bash +avahi-browse -rt _sendspin._tcp # Linux +dns-sd -B _sendspin._tcp # macOS +``` + +## The subcommands cannot find the player + +```console +$ sendspin-cli status ; echo $? +error: no sendspin-cli is listening on /run/user/1000/sendspin-cli-8928.sock. Start one, or +point this at the right socket with --control-socket -- and note that a non-default --port +moves the default path, so the same --port has to be given here +3 +``` + +Exit `3` is "nothing is listening on that socket", which is almost always one of: + +**The player is on a different `--port`.** The socket path carries the port, so a subcommand +needs the same one: + +```bash +sendspin-cli status --port 9000 +``` + +**It is the systemd system unit**, whose socket is somewhere else and belongs to the +`sendspin-cli` account at mode `0600` — so root, which is not subject to the mode, is what +reads it: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +**There is no control socket at all.** A system unit with no `RuntimeDirectory=` pairing +warns once and carries on: + +``` +W control: No control socket: $XDG_RUNTIME_DIR is not set, so there is no user-private +directory to put a control socket in. Give --control-socket to choose one, or +--no-control to stop asking +``` + +**Or `no-control` is set**, which turns the channel off deliberately. + +The other statuses are on [Controlling the Player](Controlling-the-Player). The one worth +repeating here: **`4` is "no server connection" and `5` is "the server does not offer that +command"**, and they are kept apart because a dropped connection empties the server's +command list, so collapsing them would send you to read your server's capabilities when the +truth is that nothing is connected. + +## "another sendspin-cli is already running" + +```console +$ sendspin-cli +E control: another sendspin-cli is already running -- it holds the lock on +/run/user/1000/sendspin-cli-8928.sock.lock +``` + +Exactly what it says: a live player holds an exclusive lock. **Leftover files are not the +cause and need no cleanup** — the lock is a `flock()` held for the process's life, so a +player killed with `SIGKILL` has its descriptor closed by the kernel and its leftover socket +is simply taken over by the next start. Nothing ever parses a stale file. + +The same wording, from the same helper, covers `-P`: + +```console +$ sendspin-cli -z -P /run/sendspin-cli.pid +error: another sendspin-cli is already running -- it holds the lock on /run/sendspin-cli.pid +``` + +Find it and decide: + +```bash +systemctl status sendspin-cli +pgrep -a sendspin-cli +``` + +To run a second player on purpose, give it its own `--port`, its own `--control-socket` and +its own `--state-dir` — they share the state file otherwise, and the second to save its +volume overwrites the first's. + +## `status` is telling me something odd + +**`position` runs to the end of the track in seconds.** That is `output = null`. The null +sink consumes instantly and paces nothing, so the server sends the whole track as fast as it +can and `status` faithfully reports a server that believes it has finished. Behind a real +device it advances at 1×. Not a bug, and not visible on any sink with a clock. + +**`state`, `position`, `repeat` and `shuffle` are stale.** All four are the server's last +word, and the spec does not oblige it to resend them after acting — `shuffle` reading `off` +while it is demonstrably shuffling has been observed against a real server. If you have just +changed something and the figure has not moved, that is the likely reason rather than a +failed command. The block's own `note:` line says so. + +**`position` says `(estimated)`.** Expected while playing: the library interpolates forward +from the last progress the server sent, so after a seek the server does not re-report, the +estimate drifts by however far you jumped. + +**`state: unknown`.** The server has sent no progress yet. Nothing is wrong. + +## Audio drops out, or clicks + +Raise the buffer. The default is 100 ms, and the range is 10–2000: + +```ini +buffer-ms = 250 +``` + +That is one figure for every backend — ALSA divides it into periods, PortAudio makes it the +ring size, and a device-less sink ignores it. A figure smaller than one device buffer is +raised to the floor and says so at `debug`. See +[Buffering, and what gets advertised](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#buffering-and-what-gets-advertised). + +If it is one speaker out of sync with the others rather than dropping out, that is +`delay`, not `buffer-ms` — see [Controlling the Player](Controlling-the-Player). + +## macOS: "cannot be opened because the developer cannot be verified" + +Clear the quarantine flag: + +```bash +xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli +``` + +Unpacking from a terminal avoids it in the first place, and `sudo installer -pkg … -target /` +is not gated at all. Why — and which of the four ways you can come by these files you are in +— is on [Installation](Installation). + +## The daemon exits and says nothing + +`-z` with no `-f` is the one case where a failure can be genuinely invisible: the parent +returns `0` immediately, and everything after the fork — the output device, the WebSocket +server, mDNS — can only report into a log that is going to `/dev/null`. The player warns +about exactly this at startup. + +Give it a logfile, or do not daemonize: + +```bash +sendspin-cli -z -f /var/log/sendspin-cli.log -P /run/sendspin-cli.pid +``` + +Under systemd, neither flag belongs: `Type=simple` in the foreground puts everything in the +journal. See +[Running as a daemon](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#running-as-a-daemon). + +## Still stuck + +Collect this and open an issue at +[the repository](https://github.com/chrisuthe/sendspin-cpp-cli/issues): + +```bash +sendspin-cli --version +uname -srm +sendspin-cli -l +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +journalctl -u sendspin-cli -n 100 --no-pager +``` + +Say which of the two connection modes you are in — waiting to be discovered, or dialling +with `server` — because almost everything about the failure differs between them. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..af3efa1 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,26 @@ +### sendspin-cli + +- [Home](Home) + +**Get it running** + +- [Getting Started on Linux](Getting-Started-on-Linux) +- [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) +- [Installation](Installation) + +**Use it** + +- [Configuration](Configuration) +- [Controlling the Player](Controlling-the-Player) +- [Running as a Service](Running-as-a-Service) + +**When it misbehaves** + +- [Troubleshooting](Troubleshooting) + +--- + +- [Repository](https://github.com/chrisuthe/sendspin-cpp-cli) +- [README](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md) +- [Roadmap](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) +- [Releases](https://github.com/chrisuthe/sendspin-cpp-cli/releases) diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh new file mode 100755 index 0000000..1ecf460 --- /dev/null +++ b/scripts/get_started_linux.sh @@ -0,0 +1,650 @@ +#!/usr/bin/env bash +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Installs a released sendspin-cli on a Linux host and sets its systemd unit up. +# +# One script for every Linux host, a Raspberry Pi included, because a Pi *is* an arm64 +# Linux box here: it takes the same `linux-arm64` archive an arm64 server takes and +# installs it the same way. What is genuinely Pi-specific is advice -- the `audio` group, +# and that the headphone jack and HDMI are separate cards -- and that is printed at the end +# when a Pi is what this is running on. A second script would have been this one with two +# paragraphs changed, and the two would have drifted. +# +# The archive is GitHub's, verified against the release's own SHA256SUMS, and unpacked with +# the member-selected `tar` form README documents -- naming `/usr` is what leaves +# BUILD-INFO.txt in the archive instead of writing it to `/`. +# +# The unit runs the player as an unprivileged `sendspin-cli` account, which the payload +# *declares* in usr/local/lib/sysusers.d/sendspin-cli.conf and cannot *create* -- a tarball has +# no postinst. So `systemd-sysusers` is run here, which is the one command README, BUILD-INFO.txt +# and the release notes all tell an operator to run once, reading the fragment this script has +# just installed. It is idempotent, so a re-run costs nothing. Skipping it would leave a unit +# that does not start at all: `systemctl status` reports 217/USER, and a get-started script that +# enables a unit it has made unstartable is not one. +# +# WHAT IT DELIBERATELY DOES NOT DO IS START A PLAYER THAT CANNOT PLAY. A systemd *system* +# unit has no user session, so there is no PipeWire or PulseAudio for ALSA's `default` PCM +# to follow, and the device that opens fine from your shell usually will not open under +# `systemctl` -- README's "The systemd unit" says to expect exactly that. The unit's +# `Restart=on-failure`/`RestartSec=5` would then retry it every five seconds, forever, while +# this script printed congratulations. So the unit is *enabled* but only *started* once an +# `output` has been chosen: with one already in /etc/sendspin-cli.conf this installs and +# restarts, and without one it stops after listing the host's devices and prints the two +# commands that finish the job. That is this repo's refuse-rather-than-limp habit -- the same +# reason a bad `-s` port stops the player instead of dialling the default. +# +# Nothing here runs as root without saying so first: every privileged command is printed in +# full, and then either confirmed at a terminal or authorised up front with --yes. +# +# One asymmetry worth naming, in the spirit of the one .github/workflows/ci.yml names about +# itself: the `shellcheck` job there lints every script under scripts/, and the other two are +# also *run* on every build -- smoke_test.sh on each publishing leg, build_macos_pkg.sh on the +# macOS one. This is the first script CI lints but never executes. A CI leg for it would want +# a runner willing to take a payload into `/` and a sound card to then not find, so what it +# has instead is the container run recorded in the pull request that added it. +# +# Usage: scripts/get_started_linux.sh [--version ] [--yes] +# +# --version install this release instead of the latest, e.g. --version v0.1.0 +# --yes do not prompt before the commands that need root. Required when stdin +# is not a terminal, since there is nobody there to ask +# +# Environment: +# +# SENDSPIN_CLI_TARBALL install this payload instead of downloading a release. The escape +# hatch for a locally built archive -- `DESTDIR` staged and tarred, +# exactly as CI publishes -- and the only way to exercise this script +# before a release exists. It is NOT checksummed, and the script says +# so loudly rather than letting a skipped integrity check pass for a +# successful one. + +set -euo pipefail + +# The repository releases are taken from. A constant rather than an environment knob: a +# get-started script that can be pointed at any repository is a get-started script that can +# be pointed at somebody else's binary. +readonly REPO='chrisuthe/sendspin-cpp-cli' + +readonly UNIT='sendspin-cli' +readonly UNIT_FILE='/usr/local/lib/systemd/system/sendspin-cli.service' +readonly SYSUSERS_FILE='/usr/local/lib/sysusers.d/sendspin-cli.conf' +readonly SERVICE_USER='sendspin-cli' +readonly BINARY='/usr/local/bin/sendspin-cli' +readonly CONFIG='/etc/sendspin-cli.conf' +readonly CONFIG_EXAMPLE='/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example' +readonly CONTROL_SOCKET='/run/sendspin-cli/control.sock' + +fail() { + printf 'get_started_linux: FAIL: %s\n' "$*" >&2 + exit 1 +} + +say() { + printf '%s\n' "$*" +} + +step() { + printf '\n==> %s\n' "$*" +} + +# ============================================================================== +# What was asked for +# ============================================================================== + +VERSION_TAG='' +ASSUME_YES='no' + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || fail '--version needs a tag, e.g. --version v0.1.0' + VERSION_TAG=$2 + shift 2 + ;; + --yes | -y) + ASSUME_YES='yes' + shift + ;; + -h | --help) + # The usage block above, printed rather than restated -- a second copy is one to + # forget. Everything from the `# Usage:` line to the end of the header. + sed -n '/^# Usage:/,/^$/ s/^#\{1,2\} \{0,1\}//p' "$0" + exit 0 + ;; + *) + fail "unknown argument '$1' -- see --help" + ;; + esac +done + +readonly VERSION_TAG ASSUME_YES + +# ============================================================================== +# Is this a host this can work on at all +# ============================================================================== + +[ "$(uname -s)" = 'Linux' ] || + fail "Linux only -- this installs a systemd unit. On macOS take the installer .pkg from + https://github.com/$REPO/releases instead" + +for tool in tar sed grep; do + command -v "$tool" >/dev/null 2>&1 || + fail "'$tool' is not on \$PATH, and this cannot install anything without it" +done + +# The release archives are named for the CI leg that built them rather than for `uname -m`, +# so both spellings of 64-bit ARM map onto the one leg that exists. +MACHINE="$(uname -m)" +case "$MACHINE" in + x86_64 | amd64) + LEG='linux-x86_64' + ;; + aarch64 | arm64) + LEG='linux-arm64' + ;; + armv6l | armv7l | armhf | arm) + # The single most common way a Pi install goes wrong, so it gets the whole answer + # rather than "unsupported architecture". There is no 32-bit build to fall back to -- + # docs/ROADMAP.md item 12 records that the matrix has no armv7 or 32-bit Pi leg -- and + # the fix is a 64-bit OS on hardware that is almost certainly already 64-bit capable. + fail "this is a 32-bit ARM userland ($MACHINE), and the builds are arm64 only. + docs/ROADMAP.md item 12 records why: the CI matrix has no armv7 or 32-bit Pi leg, so no + such archive exists to install. A Raspberry Pi 3 or newer is 64-bit hardware, so the fix + is a 64-bit OS: install Raspberry Pi OS (64-bit), or 'Raspberry Pi OS Lite (64-bit)' for + a headless player, and check with 'uname -m' after -- it must say aarch64. A Pi Zero (the + original), a Pi 1 or a Pi 2 cannot run 64-bit at all, and needs a build from source" + ;; + *) + fail "no release is built for '$MACHINE' -- the archives are linux-x86_64 and + linux-arm64. Build from source instead: https://github.com/$REPO#build" + ;; +esac +readonly MACHINE LEG + +# systemd being *booted* rather than merely installed, which is what decides whether there is +# anything to enable. A container or a chroot without it still gets the binary. +HAVE_SYSTEMD='no' +if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + HAVE_SYSTEMD='yes' +fi +readonly HAVE_SYSTEMD + +# A Pi answers here and nothing else does. `/proc/device-tree/model` is a NUL-terminated +# string out of the device tree, so the NUL is stripped rather than carried into a message. +PI_MODEL='' +if [ -r /proc/device-tree/model ]; then + model="$(tr -d '\0' /dev/null 2>&1 || + fail 'this needs root to install into /usr/local and to drive systemctl, and there is + no sudo here -- re-run it as root' + SUDO='sudo' +fi +readonly SUDO + +# What a printed command line is prefixed with: `sudo ` where one is needed, and nothing at +# all where this is already root -- an empty $SUDO expanded inline leaves every printed +# command indented one space further than the last, and a sentence ending "that needs ." +readonly SUDO_P="${SUDO:+$SUDO }" + +as_root() { + if [ -n "$SUDO" ]; then + "$SUDO" "$@" + else + "$@" + fi +} + +WORK_DIR="$(mktemp -d)" +readonly WORK_DIR +trap 'rm -rf "$WORK_DIR"' EXIT + +say 'sendspin-cli getting started' +say " host: Linux $MACHINE${PI_MODEL:+ ($PI_MODEL)}" +say " release leg: $LEG" +if [ "$HAVE_SYSTEMD" = 'yes' ]; then + say ' systemd: yes' +else + say ' systemd: no -- the unit will be installed but nothing started' +fi + +# ============================================================================== +# The payload +# ============================================================================== + +# The tag of the newest release, on stdout, or a diagnostic naming which of the ways this can +# come back empty it was. +# +# Read off the `/releases/latest` redirect rather than out of the JSON API, which matters for +# three reasons. It needs no `jq`, absent on a fresh Raspberry Pi OS Lite. It is not subject +# to the API's 60-an-hour unauthenticated rate limit, which is per source address and so is +# shared by everything behind one NAT. And it is the only form that can tell "this repository +# has published no releases" -- which redirects to the releases index -- apart from "there is +# no such repository", which is a 404: the API answers 404 to both. +resolve_latest_tag() { + local headers status location + headers="$WORK_DIR/latest.headers" + + status="$(curl -sSI -o "$headers" -w '%{http_code}' \ + "https://github.com/$REPO/releases/latest")" || + fail "could not reach github.com -- check this host's network and try again" + + case "$status" in + 30[0-9]) ;; + 404) + fail "github.com has no repository at $REPO, or it is not public. Nothing has + been installed" + ;; + *) + fail "github.com answered $status asking for the latest release of $REPO" + ;; + esac + + # curl folds the header name to lower case for `-w`, but the raw header file keeps whatever + # the server sent, so the match is case-insensitive. + # + # `|| true` is load-bearing rather than sloppy: with no `location:` line at all, `grep` exits + # 1, `pipefail` carries that out of the pipeline, and `set -e` would kill the script *before* + # the guard below could say why. A redirect with no Location is exactly the case that guard + # exists for, so it must survive long enough to run. + location="$( (grep -i '^location:' "$headers" || true) | tail -n 1 | tr -d '\r' | + awk '{print $2}')" + [ -n "$location" ] || + fail "github.com answered $status with no Location header, which should not happen" + + case "$location" in + */releases/tag/*) + printf '%s\n' "${location##*/releases/tag/}" + ;; + *) + # The state this repository is actually in at the time of writing, so it gets a + # real answer rather than a shrug. + fail "$REPO has published no releases yet, so there is nothing to download. + Until it has, either build from source -- https://github.com/$REPO#build -- or stage a + payload of your own and point this script at it: + DESTDIR=/tmp/stage cmake --install build --component sendspin-cli + tar -czf /tmp/sendspin-cli.tar.gz -C /tmp stage + SENDSPIN_CLI_TARBALL=/tmp/sendspin-cli.tar.gz $0 --yes" + ;; + esac +} + +if [ -n "${SENDSPIN_CLI_TARBALL:-}" ]; then + step 'Using the payload you supplied' + [ -f "$SENDSPIN_CLI_TARBALL" ] || + fail "SENDSPIN_CLI_TARBALL names '$SENDSPIN_CLI_TARBALL', which is not a file" + # Made absolute before anything else, because `tar -C /` below would otherwise resolve a + # relative path against `/`. + TARBALL="$(cd "$(dirname "$SENDSPIN_CLI_TARBALL")" && pwd)/$(basename "$SENDSPIN_CLI_TARBALL")" + say " $TARBALL" + say '' + say ' !! NOT VERIFIED. This is your own archive, so there is no published SHA256SUMS to' + say ' !! check it against, and nothing here has established that it is what you think.' + say ' !! Take a release instead of passing SENDSPIN_CLI_TARBALL to get that check.' +else + step 'Finding the release to install' + for tool in curl sha256sum; do + command -v "$tool" >/dev/null 2>&1 || + fail "'$tool' is not on \$PATH -- install it (apt install curl coreutils) and + re-run, or pass a payload with SENDSPIN_CLI_TARBALL" + done + + if [ -n "$VERSION_TAG" ]; then + # Taken as given rather than looked up: a tag that does not exist fails at the + # download below, naming the file it could not find, which is the same answer one + # round trip earlier would have given. + TAG=$VERSION_TAG + else + TAG="$(resolve_latest_tag)" + fi + # The archives are named for the version, which is the tag without its `v`. + ARCHIVE="sendspin-cli-${TAG#v}-$LEG.tar.gz" + readonly TAG ARCHIVE + say " $TAG -> $ARCHIVE" + + step 'Downloading and verifying' + BASE="https://github.com/$REPO/releases/download/$TAG" + curl -fSL --progress-bar -o "$WORK_DIR/$ARCHIVE" "$BASE/$ARCHIVE" || + fail "no $ARCHIVE in release $TAG of $REPO. Check the tag exists and carries a build + for this architecture: https://github.com/$REPO/releases" + curl -fsSL -o "$WORK_DIR/SHA256SUMS" "$BASE/SHA256SUMS" || + fail "$TAG carries $ARCHIVE but no SHA256SUMS, so there is nothing to verify it + against. Refusing to install an unverified binary" + + # Asserted rather than assumed, in the way build.yml asserts the payload's own file list: + # `--ignore-missing` below skips a listed file that is absent, so a SHA256SUMS that never + # mentions this archive at all would leave `sha256sum` with nothing to check. It does exit + # non-zero when that leaves it with no file verified -- but "the checksum file does not + # cover this download" deserves to be said in those words rather than as `-c` failing. + awk -v want="$ARCHIVE" '$2 == want { found = 1 } END { exit !found }' \ + "$WORK_DIR/SHA256SUMS" || + fail "the SHA256SUMS published with $TAG does not list $ARCHIVE, so there is no + checksum to verify it against. Nothing has been installed" + + # --ignore-missing because SHA256SUMS covers every archive the release carries and this + # host has taken one of them. + (cd "$WORK_DIR" && sha256sum --ignore-missing -c SHA256SUMS) || + fail "$ARCHIVE does not match the checksum $TAG publishes for it. Nothing has been + installed. Download it again; if it fails a second time, say so on the issue tracker + rather than installing it anyway" + + TARBALL="$WORK_DIR/$ARCHIVE" +fi +readonly TARBALL + +# ============================================================================== +# What this is about to do as root +# ============================================================================== + +# Read off the archive rather than derived from its filename: a payload staged by hand is +# named whatever its author called it, and the member `tar` is asked to extract has to be one +# that is really in there. +# +# Listed once into a variable, and every question below asked of *that* rather than of a +# second `tar`. Not tidiness: `tar -tzf … | grep -q` is a pipeline whose reader exits on the +# first match, which leaves tar killed by SIGPIPE -- and under `set -o pipefail` that is a +# failed check on an archive that is perfectly fine. +ARCHIVE_LIST="$(tar -tzf "$TARBALL")" +readonly ARCHIVE_LIST + +mapfile -t ARCHIVE_ROOTS < <(cut -d/ -f1 <<<"$ARCHIVE_LIST" | sort -u) +[ "${#ARCHIVE_ROOTS[@]}" -eq 1 ] || + fail "'$TARBALL' has ${#ARCHIVE_ROOTS[@]} top-level entries, and a payload has one -- it + is a staged 'cmake --install' tree, not an archive of loose files" +PAYLOAD_ROOT="${ARCHIVE_ROOTS[0]}" +readonly PAYLOAD_ROOT + +grep -Fqx "$PAYLOAD_ROOT/usr/local/bin/sendspin-cli" <<<"$ARCHIVE_LIST" || + fail "'$TARBALL' holds no $PAYLOAD_ROOT/usr/local/bin/sendspin-cli. A payload is staged + with DESTDIR from a build configured for the /usr/local prefix: + DESTDIR=/tmp/stage cmake --install build --component sendspin-cli" + +# Read off the archive rather than assumed present, so the plan printed below is the plan that +# runs. Every Linux payload the CI publishes carries the fragment beside the unit, but a payload +# staged from an older tree does not, and a `systemd-sysusers` announced and then not needed +# would be the one command in that list an operator could not account for. +PAYLOAD_HAS_SYSUSERS='no' +if grep -Fqx "$PAYLOAD_ROOT/usr/local/lib/sysusers.d/sendspin-cli.conf" <<<"$ARCHIVE_LIST"; then + PAYLOAD_HAS_SYSUSERS='yes' +fi +readonly PAYLOAD_HAS_SYSUSERS + +# Only where there is a systemd to have an account for. The account is the unit's requirement +# and nothing else here needs it, so a container without systemd gets the binary and no user. +CREATE_USER='no' +if [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$PAYLOAD_HAS_SYSUSERS" = 'yes' ]; then + CREATE_USER='yes' +fi +readonly CREATE_USER + +# An `output` already chosen is what decides whether the player is started at the end, so it +# is settled here -- before anything is installed -- and reported in the plan below. +# +# /etc/sendspin-cli.conf alone, and not the two $HOME paths that also come first in the search +# order: this is about the *system* unit, whose process gets whatever HOME systemd hands root, +# and guessing at that would be worse than naming the one file the unit's own documentation +# tells an operator to edit. A line is a comment when its first non-blank character is `#`, so +# an indented `#output = …` is correctly not a match. +# Read through as_root because /etc/sendspin-cli.conf need not be world-readable: an +# unprivileged `grep` on an unreadable file exits 2, which would read here as "no output is +# configured" and quietly leave a properly configured player stopped. +CONFIG_HAS_OUTPUT='no' +if as_root test -f "$CONFIG" && + as_root grep -Eq '^[[:space:]]*output[[:space:]]*=' "$CONFIG"; then + CONFIG_HAS_OUTPUT='yes' +fi +readonly CONFIG_HAS_OUTPUT + +SEED_CONFIG='no' +if [ ! -e "$CONFIG" ]; then + SEED_CONFIG='yes' +fi +readonly SEED_CONFIG + +step 'These are the commands that need root' +say '' +say " ${SUDO_P}tar -xzf $TARBALL --strip-components=1 -C / $PAYLOAD_ROOT/usr" +if [ "$SEED_CONFIG" = 'yes' ]; then + say " ${SUDO_P}cp $CONFIG_EXAMPLE $CONFIG" +fi +if [ "$CREATE_USER" = 'yes' ]; then + say " ${SUDO_P}systemd-sysusers" +fi +if [ "$HAVE_SYSTEMD" = 'yes' ]; then + say " ${SUDO_P}systemctl daemon-reload" + say " ${SUDO_P}systemctl enable $UNIT" + if [ "$CONFIG_HAS_OUTPUT" = 'yes' ]; then + say " ${SUDO_P}systemctl restart $UNIT" + else + say " ${SUDO_P}$BINARY -l" + fi +fi +say '' +say "Naming '$PAYLOAD_ROOT/usr' is what keeps the archive's BUILD-INFO.txt out of /." +if [ "$CREATE_USER" = 'yes' ]; then + say "'systemd-sysusers' creates the unprivileged '$SERVICE_USER' account the unit runs as," + say "reading the declaration the line above it installs at $SYSUSERS_FILE." + say 'It adds nothing else and is idempotent. Without it the unit does not start at all.' +fi +if [ "$SEED_CONFIG" = 'yes' ]; then + say "There is no $CONFIG yet, so the installed example is copied there for you to edit." + say 'Every line in it is commented out, so it chooses nothing on its own.' +fi +if [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$CONFIG_HAS_OUTPUT" != 'yes' ]; then + say "No 'output' is set in $CONFIG, so the unit is enabled but NOT started: under" + say "systemd there is no PipeWire for ALSA's 'default' to follow, and starting it now" + say 'would usually mean a player failing and being retried every five seconds. The' + say 'device list is printed instead, and starting it is the last thing you do.' +fi +say 'Everything else this script does is reading.' + +if [ "$ASSUME_YES" != 'yes' ]; then + [ -t 0 ] || + fail 'stdin is not a terminal, so there is nobody to confirm those commands with. + Re-run with --yes if you have read them and want them run' + printf '\nRun them? [y/N] ' + # `|| fail` for SC1's reason: a closed stdin makes `read` exit non-zero, and `set -e` would + # otherwise end the run with no word about why nothing was installed. + read -r answer || fail 'stdin closed before an answer arrived; nothing was installed' + case "$answer" in + y | Y | yes | YES) ;; + *) fail 'nothing was installed' ;; + esac +fi + +# ============================================================================== +# Install +# ============================================================================== + +step 'Installing' +# Idempotent by construction: this overwrites whatever is at those paths, so re-running the +# script is how you upgrade. `--strip-components=1` drops the archive's own top level, so +# every remaining path is the path the file installs to. +as_root tar -xzf "$TARBALL" --strip-components=1 -C / "$PAYLOAD_ROOT/usr" + +# Checked before the binary is run rather than after: every Linux payload carries the unit, so +# its absence means a macOS archive was unpacked here -- and running the binary first would +# answer that with the dynamic loader's message instead of this one. +[ -f "$UNIT_FILE" ] || + fail "the payload installed no unit at $UNIT_FILE -- a macOS archive on a Linux host would + look exactly like this. Take the $LEG one" + +say " $BINARY" +"$BINARY" --version | sed 's/^/ /' + +if [ "$SEED_CONFIG" = 'yes' ]; then + as_root cp "$CONFIG_EXAMPLE" "$CONFIG" + say " $CONFIG (from the installed example; everything in it is commented out)" +fi + +if [ "$HAVE_SYSTEMD" != 'yes' ]; then + step 'Not touching a service' + say ' systemd is not running here, so there is nothing to enable. The binary is' + say ' installed and runs in the foreground:' + say '' + say " $BINARY -l # what this host can play through" + say " $BINARY -o hw:1,0 -n \"\$(hostname)\"" + exit 0 +fi + +step 'Setting the service up' + +# Before daemon-reload and enable, because this is what makes the unit startable at all: it +# names User=sendspin-cli, and 217/USER is what an operator gets instead of a player if the +# account is missing. `systemd-sysusers` with no argument reads every fragment on the search +# path, /usr/local/lib/sysusers.d included, so it needs no path to the file just installed. +if [ "$CREATE_USER" = 'yes' ]; then + command -v systemd-sysusers >/dev/null 2>&1 || + fail "the unit runs as '$SERVICE_USER' and 'systemd-sysusers' is not on \$PATH to + create the account from $SYSUSERS_FILE. Create it with your own tooling instead -- + '${SUDO_P}useradd --system --no-create-home -G audio $SERVICE_USER' is the equivalent + README documents -- then re-run this script" + + as_root systemd-sysusers + + # Asserted rather than assumed: sysusers exits 0 with nothing done if it read no fragment, + # and the failure that follows would be 217/USER at the end of an install that said it + # worked. `getent passwd` and not `id`, which on some hosts answers out of a cache. + getent passwd "$SERVICE_USER" >/dev/null || + fail "'systemd-sysusers' ran and there is still no '$SERVICE_USER' account, so the unit + would report 217/USER rather than starting. $SYSUSERS_FILE is what it should have read" + + say " user: $SERVICE_USER (unprivileged; the unit's User=)" +else + # The payload carried no fragment, which is an older tree -- and an older tree's unit runs + # as root and names no User=. Read the installed unit rather than trusting that pairing: a + # unit naming an account nothing here can create is 217/USER after an install that said it + # worked, and this is the one place left to catch it. + unit_user="$(sed -n 's/^[[:space:]]*User=[[:space:]]*//p' "$UNIT_FILE" | tail -n 1)" + if [ -n "$unit_user" ] && ! getent passwd "$unit_user" >/dev/null; then + fail "$UNIT_FILE runs as '$unit_user' and no such account exists, while this payload + carried no sysusers declaration to create one from. Create it -- '${SUDO_P}useradd --system + --no-create-home -G audio $unit_user' is the equivalent README documents -- then re-run + this script" + fi +fi + +as_root systemctl daemon-reload +as_root systemctl enable "$UNIT" +say " enabled: $UNIT starts on boot" + +# ============================================================================== +# Start it, or say what is still owed +# ============================================================================== + +if [ "$CONFIG_HAS_OUTPUT" = 'yes' ]; then + # `restart` and not `start`: this is also the upgrade path, and an already-running player + # would otherwise keep serving the binary that has just been replaced underneath it. + as_root systemctl restart "$UNIT" + + # Asked once rather than polled: the unit is Type=simple, so `restart` returns before + # systemd has decided anything, and a device that will not open takes about a second to + # say so. + sleep 2 + if systemctl is-active --quiet "$UNIT"; then + step "$UNIT is running" + say " output = $(sed -n 's/^[[:space:]]*output[[:space:]]*=[[:space:]]*//p' "$CONFIG" | tail -n 1)" + else + step "$UNIT was started and is NOT running" + say '' + say " $CONFIG names an output, so this is that device failing to open rather" + say " than the usual first-install case. What it said:" + say '' + # Through as_root like every other privileged read, and emptiness treated as failure: + # a user outside `systemd-journal` gets no lines and exit 0, which would print a blank + # block at the exact moment the operator most needs to be told something. + journal="$(as_root journalctl -u "$UNIT" --no-pager -n 15 2>/dev/null || true)" + if [ -n "$journal" ]; then + printf '%s\n' "$journal" | sed 's/^/ /' + else + say " (nothing readable in the journal; try: ${SUDO_P}journalctl -u $UNIT -n 50)" + fi + say '' + say " '${SUDO_P}$BINARY -l' lists what this host really has." + fi +else + step 'What this host can play through' + say '' + say ' (ALSA and PortAudio narrate their own enumeration on stderr -- a "jack server is' + say " not running\" here is those libraries talking, not this player failing.)" + say '' + as_root "$BINARY" -l 2>&1 | sed 's/^/ /' +fi + +# ============================================================================== +# What to do next +# ============================================================================== + +step 'Next' +if [ "$CONFIG_HAS_OUTPUT" != 'yes' ]; then + say '' + say " 1. Pick a device from that list and put it in $CONFIG. Keys there are the" + say ' long flag names without their dashes, and the file is annotated:' + say '' + say " ${SUDO_P}nano $CONFIG # output = hw:1,0" + say '' + say ' 2. Start it:' + say '' + say " ${SUDO_P}systemctl start $UNIT" + say " systemctl status $UNIT" + say '' + say ' 3. Then:' +else + say '' +fi +say '' +say ' Watch it:' +say '' +say " journalctl -u $UNIT -f" +say '' +say ' Ask it what it is doing:' +say '' +say " ${SUDO_P}$BINARY status --control-socket $CONTROL_SOCKET" +say '' +say " The socket is mode 0600 and belongs to the '$SERVICE_USER' account the service" +say ' runs as, so reading it means being root -- which is not subject to the mode.' +say " Put 'control-socket = $CONTROL_SOCKET' in the config to stop" +say ' repeating the flag.' +say '' +say ' Nothing else. The player advertises itself over mDNS and waits for a Sendspin' +say ' server to find it, so it should appear in your controller once it is playing' +say " through a device. To dial a server instead, set 'server' in the config." + +if [ -n "$PI_MODEL" ]; then + say '' + say " On this $PI_MODEL:" + say '' + say ' - The headphone jack and each HDMI output are separate cards. The list above' + say " names them; 'output = hw:X,Y' picks one, with X and Y the numbers it printed." + say " - 'output = default' is what usually leaves a system unit silent, because there" + say ' is no user session for it to follow. Name a card.' + say " - The service runs as the unprivileged '$SERVICE_USER' account, which the" + say ' declaration that created it also put in the audio group -- so it reaches /dev/snd' + say ' (root:audio 0660) with nothing for you to arrange.' + say ' - To run it from your own shell rather than as a service, put yourself in that' + say " group once and log back in: ${SUDO_P}usermod -aG audio \"\$USER\"" +fi + +say ''