A Music Assistant client for Zig 0.16: its WebSocket API, typed models generated from its schema, and reconnection.
  • Zig 90.2%
  • Nix 7.5%
  • Python 2.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie ed90733e2e
All checks were successful
test / test (push) Successful in 22m32s
test / docs (push) Successful in 7m1s
test / vm (stable) (push) Successful in 6m23s
test / vm (unstable) (push) Successful in 6m26s
tls asked for with no options, as zig-sendspin asks for it
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
2026-10-07 21:10:29 -05:00
.forgejo/workflows CI: jobs that boot virtual machines run on the m tier 2026-09-26 21:30:44 -05:00
LICENSES A Music Assistant client: handshake, commands, events, reconnection 2026-09-24 12:32:03 -05:00
schema Generate the models from Music Assistant 2.10.3 and use its image proxy 2026-09-24 22:52:06 -05:00
src Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
tests Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
tools Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
.gitignore Fuzzing: the models, answers to commands, and image URLs 2026-09-26 16:25:43 -05:00
build.zig tls asked for with no options, as zig-sendspin asks for it 2026-10-07 21:10:29 -05:00
build.zig.zon Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
build.zig.zon.nix Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
flake.lock Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
flake.nix Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
package.nix Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
README.md Build with Zig 0.17.0, and start 0.2.0 2026-10-07 00:29:57 -05:00
REUSE.toml A Music Assistant client: handshake, commands, events, reconnection 2026-09-24 12:32:03 -05:00

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 with auth/login first, 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.run connects, 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.zig is 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 .unrecognized rather than failing, and an unknown field is ignored, so a server upgrade does not break the client.
  • Typed commands. api has 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.url builds 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:

  1. It makes the first user through /setup, as the web interface does, and logs in as them with a password.
  2. 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.
  3. It watches, on a second connection, the events the scan sends.
  4. It runs check against that server version's answers.
  5. It lists the library and searches it.
  6. It reads a playlist of all 520 tracks, which the server sends in parts of 500.
  7. 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.