- Zig 90.2%
- Nix 7.5%
- Python 2.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The dependency cache is keyed on the options, so the same tls asked for with a target and optimize mode here and with none in zig-sendspin was two modules over the same files, and a program with both in it -- Pipit -- fails to compile. Its module takes the target of whatever imports it, so the options never meant anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qFokELp2xiUWz2VHcdBcq |
||
| .forgejo/workflows | ||
| LICENSES | ||
| schema | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-musicassistant
A Music Assistant client for Zig 0.17: the server's WebSocket API, its data model as Zig types generated from the schema it publishes, and a connection that puts itself back together when the server goes away.
For Zig 0.16 there is the 0.1 release, v0.1.0, and the zig-0.16 branch.
It is the Music Assistant half of Pipit, a desktop player, and is shaped by zig-homeassistant, which does the same job for Home Assistant.
What it does
Music Assistant's own web interface does everything through one WebSocket, at
/ws on port 8095, and so does this:
- The handshake. The server speaks first with its version and schema;
a client too old or too new for it is refused with an error that says which.
A token is presented with
auth, or a username and password are traded for one withauth/loginfirst, and the token that login is issued is handed back to be saved. - Commands, answered in any order. Any task may send a command and wait
for its answer while one task reads. Answers are matched to commands by id,
and a long listing — which the server sends 500 items at a time, every
message but the last marked
partial— is put back together before the caller sees it. - Events. Once authenticated, the server sends every event unasked: players and queues changing, the elapsed time ticking, items added to the library. Each is handed to a callback.
- Reconnection.
Client.runconnects, reads until the connection ends, and does it again after a pause that doubles up to half a minute. It stops for what no retry can fix — a refused password, or a schema this client cannot speak — rather than hammering a server that has said no. - Typed models.
src/models.zigis generated from the schema the server serves at/api-docs/schemas.json: a struct per model, an enum per enum, and a JSON value wherever the schema says "one of several". An enum value from a newer server parses as.unrecognizedrather than failing, and an unknown field is ignored, so a server upgrade does not break the client. - Typed commands.
apihas the commands a player application uses — players, queues, transport, volume, the library, search, recommendations, favorites, and the waveform audio analysis leaves — with their arguments named once and their answers parsed. - Artwork URLs.
image.urlbuilds the image-proxy URL the server builds for its own clients, resized to what will actually be drawn.
Using it
const ma = @import("musicassistant");
var client: ma.Client = .init(io, gpa, .{ .hostname = "ma.local" }, .{ .token = token }, .{}, .{
.context = &state,
.onEvent = State.onEvent,
});
defer client.deinit();
var runner = try io.concurrent(ma.Client.run, .{&client});
defer runner.cancel(io) catch {};
const queues = try ma.api.queues(&client);
defer queues.deinit();
try ma.api.playPause(&client, queues.value[0].queue_id);
Client.run wants a task of its own, and every callback in the sink runs on
it — so a callback must not wait for a command's answer, which would arrive on
the task that is waiting. The doc comments on Client say the rest.
A token comes from the server's profile page, where a long-lived one lasts ten
years, or from Session.Credentials.login, whose token lasts thirty days from
its last use.
The API documentation is generated from the doc comments, which carry most of the explanation.
Trying it against a server
ma-integration is a small program that holds a real conversation:
$ nix develop -c zig build
$ MA_TOKEN=... ./zig-out/bin/ma-integration ma.local players
$ MA_USERNAME=me MA_PASSWORD=... ./zig-out/bin/ma-integration ma.local queues
It knows info, players, queues, albums [n], search <text>,
playlist <id>, watch [seconds], which prints every event, and raw <command> [json], which sends a command, with its arguments as a JSON object
if it takes any, and prints the answer as the server sent it. check runs every
typed command that needs no arguments and says which answers parse into their
models — the schema describes the full models and the server sends trimmed
copies of them inside others, and this is where the two are found to
disagree. Against Music Assistant 2.10.3 and a library of a few thousand
albums, all of them do.
Against every Music Assistant NixOS has
The library is also tested against a real Music Assistant, from first boot, in a NixOS virtual machine. That runs once for each NixOS release the flake names:
| Check | NixOS | Music Assistant |
|---|---|---|
music-assistant-stable |
26.05 | 2.8.7 |
music-assistant-unstable |
unstable | 2.10.3 |
The library is built once, from the flake's own nixpkgs, and the same build
talks to both servers. Each run goes through these steps with
ma-integration, so every step is the library holding a conversation:
- It makes the first user through
/setup, as the web interface does, and logs in as them with a password. - It onboards the server and adds a folder of music to scan. That is a setup flow from 2.10 on, and a plain save before then, and the test handles either.
- It watches, on a second connection, the events the scan sends.
- It runs
checkagainst that server version's answers. - It lists the library and searches it.
- It reads a playlist of all 520 tracks, which the server sends in parts of 500.
- It keeps a connection open through a restart of the server.
The music is generated: a second of tone per track, tagged as a real library
is (tests/nixos/music.nix).
nix build -L .#checks.x86_64-linux.music-assistant-stable
nix build -L .#checks.x86_64-linux.music-assistant-unstable
To test against another release, add it as an input in flake.nix.
Building
git clone https://git.jcollie.dev/jeff/zig-musicassistant.git
cd zig-musicassistant
nix develop -c zig build test --summary all
nix develop -c zig build check
nix build .#zig-musicassistant
Fuzzing
Everything a server sends reaches the client as JSON it did not write, so
that is what is fuzzed. Any JSON is parsed as each of the models, from text
and from a parsed value, and whatever parses has to read back the same once
written out. Scripts of messages are delivered to waiting commands, and each
answer has to reach the command it answers, with the chunks of a long one
adding up to the whole. And image URLs are built from whatever paths, providers
and proxy ids a server gives. The seeds are made at build time from the
schema snapshot the models come from — every model with every field filled
in, and with only the ones it requires — by tools/fuzz_corpus.zig, and
zig build test checks the properties on them.
$ zig build fuzz --fuzz # until interrupted
$ zig build fuzz --fuzz=1M -Dfuzz-filter=models # a bounded run, one target
$ zig build fuzz-run # a minute of each target
$ zig build fuzz-run -- --seconds 300 --target models
$ zig build fuzz-run -Dfuzz-optimize=debug -- --input fuzz-findings/x.bin --target models
zig build fuzz --fuzz is Zig's own fuzzer, steered by coverage; it saves a
finding to .zig-cache/f/crash. fuzz-run is a loop of this project's own
in tools/fuzz.zig, with no coverage but a seed to repeat a run exactly by.
A failing input is written to fuzz-findings/, and --input runs it again.
The models
The models are generated rather than written, and generated from a snapshot of the schema rather than from a live server, so that building needs neither:
$ curl -o schema/2.10.3/schemas.json http://ma.local:8095/api-docs/schemas.json
$ curl -o schema/2.10.3/commands.json http://ma.local:8095/api-docs/commands.json
$ nix develop -c zig build models -Dschema=2.10.3
schema/<version>/ holds what a server of that version published, and
src/models.zig is committed; CI regenerates it and fails if it differs.
commands.json is not read by the generator — it is the list of commands and
their arguments that api.zig is written from.
The models are generated from Music Assistant 2.10.3, schema 65; 2.8.7's
schema is kept beside it. The client accepts servers from schema 28, where
authentication moved into the WebSocket, and image.url builds either form of
image proxy URL: 2.10's /imageproxy/<proxy_id>, at one of the sizes it
allows, when the server gives an image a proxy_id, and the older query
string when it does not. 2.10 answers only the first.
Dependencies
- zig-websocket for the WebSocket, with its write lock, which is what lets one task read while others send.
- tls.zig for a server behind a proxy that adds TLS.
- zig-uri for every URL built: the server's base URL, with an IPv6 address bracketed, and the image proxy's, escaped the way the server unescapes them.
Where this lives
The repository has three homes, and they hold the same history.
| Where | How to get it |
|---|---|
| Forgejo | git clone https://git.jcollie.dev/jeff/zig-musicassistant.git |
| Tangled | git clone https://tangled.org/jcollie.dev/zig-musicassistant |
| Radicle | rad clone rad:zHkX2cvXMAdrcCSj9gW88AVsVzc6 |
A Radicle repository is findable only by its ID, so that one is written out in
full: rad:zHkX2cvXMAdrcCSj9gW88AVsVzc6.
Licensing
MIT, following the REUSE specification; reuse lint checks it. The schema snapshots under schema/ are Music Assistant's
own description of its API, and are Music Assistant's under its Apache 2.0
licence.
References cited
Kept in the zig-musicassistant Zotero collection.
- Music Assistant. Music Assistant Documentation. https://music-assistant.io/.
- Music Assistant. Music Assistant Server, version 2.8.7. Source code. https://github.com/music-assistant/server/tree/2.8.7.
- Music Assistant. Music Assistant Server, version 2.10.3. Source code. https://github.com/music-assistant/server/tree/2.10.3.
- Music Assistant. Music Assistant Models. Source code. https://github.com/music-assistant/models.
- Fette, I., and A. Melnikov. The WebSocket Protocol. RFC 6455. Internet Engineering Task Force, December 2011. https://www.rfc-editor.org/info/rfc6455.
- Wright, A., H. Andrews, B. Hutton, and G. Dennis. JSON Schema: A Media Type for Describing JSON Documents. Internet-Draft draft-bhutton-json-schema-01, June 2022. https://json-schema.org/draft/2020-12/json-schema-core.