MPRIS for Zig: a media player's side of the D-Bus remote control interfaces, on mdbus and zig-dbus-service.
  • Zig 87.1%
  • Nix 10.2%
  • Shell 2.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 2d66f57394
All checks were successful
test / docs (push) Successful in 6m21s
test / test (push) Successful in 9m47s
test / vm (stable) (push) Successful in 1m30s
test / vm (unstable) (push) Successful in 1m49s
Fuzzing: refusals, the state after a call, and failed allocations
The seeds are split into calls, each of which must be answered, and
refusals, each of which must be refused: values the specification does not
allow, properties that cannot be written or not with that type, names and
signatures that are not there. The test checks every seed's answer rather
than that three in four succeed. New calls reach a seek past the end, a
stale or negative SetPosition, GetPlaylists reversed and out of range, a
negative volume, GetAll of everything, and calls naming no interface.

Two more bytes of input choose the state after the call, rather than the
input's length, and seeds change the track, jump the position and swap the
track list and playlists, which is what update makes signals of. The
built-in fuzzer is now given the seeds as its corpus, framed with the
length Smith reads first; it was starting from nothing.

Every seed is replayed with each of its allocations failing in turn, on an
allocator that never grows in place, so that the count of allocations is
the same each time. That found a double free in zig-dbus-service's queue,
fixed there in 0b127fb, which this now uses.

zig build coverage writes a kcov report of what the seeds reach of src,
now 99.7% of it, and the devshell has kcov.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFAKTW62t5J1qkcKDuZ8Gy
2026-10-06 00:40:29 -05:00
.forgejo/workflows CI: the private session bus with dbus's own configuration 2026-09-29 08:46:45 -05:00
dbus The server on bindings generated by dbus-codegen 2026-10-06 00:05:03 -05:00
LICENSES MPRIS for Zig: all four interfaces, from snapshots of the player 2026-09-24 15:40:46 -05:00
src The server on bindings generated by dbus-codegen 2026-10-06 00:05:03 -05:00
tests Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
tools Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
.gitignore Fuzzing: calls from any peer, to a real Server 2026-09-26 17:58:09 -05:00
build.zig Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
build.zig.zon Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
build.zig.zon.nix Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
flake.lock Zig 0.17 2026-10-05 23:12:00 -05:00
flake.nix Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
package.nix The server on bindings generated by dbus-codegen 2026-10-06 00:05:03 -05:00
README.md Fuzzing: refusals, the state after a call, and failed allocations 2026-10-06 00:40:29 -05:00
REUSE.toml MPRIS for Zig: all four interfaces, from snapshots of the player 2026-09-24 15:40:46 -05:00

zig-mpris

MPRIS for Zig: a media player's side of the Media Player Remote Interfacing Specification, the D-Bus interfaces through which desktop shells, media keys, playerctl and the like see and control players.

A program describes what it is doing as a State whenever it is asked, and says what it can do with the commands of a Server.Player. The server owns org.mpris.MediaPlayer2.<name> — or .instance<pid> after it, when another copy already does — and serves all four MPRIS interfaces at /org/mpris/MediaPlayer2:

  • org.mpris.MediaPlayer2 — identity, desktop entry, raise and quit;
  • .Player — playback status, metadata, position, loop, shuffle, volume, and the transport commands;
  • .TrackList — the tracks around the current one, their metadata, and going to one;
  • .Playlists — the player's playlists and activating one.

It is built on zig-dbus-service, which answers the calls, and mdbus, which carries them. It was written for Pipit.

The four interfaces are written out in dbus/ as introspection XML — names, types and annotations, taken from the specification without its prose — and zig-dbus-service's dbus-codegen turns them into typed bindings at build time. The server is built on those, and mpris.interfaces exports them, so each interface's generated Client can be used to call some other player.

Using it

const mpris = @import("mpris");

var connection = try mdbus.Connection.connectSession(io, gpa, environ);
const server = try mpris.Server.create(gpa, io, &connection, .{
    .name = "myplayer",
    .identity = "My Player",
    .desktop_entry = "myplayer",
}, .{
    .context = &app,
    .snapshot = snapshot,     // fill in a State
    .play_pause = playPause,  // and whichever commands the player has
    .next = next,
    .set_position = setPosition,
});
defer server.destroy();
try server.start();
// server.run() on a thread of its own, and server.update() whenever
// anything the snapshot describes changes.

update works out what to say. It takes a new snapshot and compares it with the last, and emits what the specification asks for and nothing more: PropertiesChanged for the properties whose values differ; Seeked when the position jumped within the same track, the one position change the specification signals; and TrackListReplaced when the list of tracks is a different list. A program never tracks what it last told the bus.

Position is read live. The snapshot says where the track was when it was taken, and while playing the server moves it on by the time since — so a program need not update every second for playerctl position to be right.

Commands arrive on the server's thread, and should hand their work off rather than do it there. Seek, which is relative, arrives as an absolute set_position; a seek past the end of the track is next, as the specification says. A command left null is one the player does not have, and CanQuit and CanRaise say so.

tools/example.zig is a complete pretend player, and the documentation is generated from the doc comments: https://jeff.jcollie.page/zig-mpris/.

Building and testing

main builds with Zig 0.17. Zig 0.16 is served by the zig-0.16 branch, which starts at the v0.1.0 tag and takes only critical fixes. The Nix flake's devshell provides the right Zig, D-Bus and the REUSE tooling.

git clone https://git.jcollie.dev/jeff/zig-mpris.git
cd zig-mpris
nix develop -c zig build test --summary all
nix develop -c zig build && nix develop -c dbus-run-session -- bash tests/bus.sh

tests/bus.sh runs the example player on a private session bus and drives it with playerctl and gdbus: status, metadata, play, a position that moves by itself, seeking, next and previous, shuffle, loop, volume, the track list, playlists, and the signals a monitor hears — including that changing track sends no Seeked. Both it and the unit tests run in CI.

The same script also runs in a NixOS virtual machine. There the player is on a logged-in user's own session bus, which is dbus-broker as on a NixOS desktop rather than the dbus-daemon that dbus-run-session starts. It runs once for each NixOS release the flake names:

nix build -L .#checks.x86_64-linux.broker-stable     # NixOS 26.05
nix build -L .#checks.x86_64-linux.broker-unstable   # NixOS unstable

Fuzzing

Anything on the session bus can call a media player, so the server is fuzzed with calls nobody wrote: any path, interface, member and signature, with arguments of any values that fit the signature, dispatched to a real Server whose player is in a state the input also chooses, and which changes to another state the input chooses once the call is answered. Whatever arrives, the server has to answer with something that can be sent, free what it allocated, and pass on to the player only what the specification allows: a position inside the track, and a volume that is a finite number.

The seeds, made by tools/fuzz_corpus.zig, are calls to every method and property of the four interfaces, which must be answered, and calls that must be refused — a value the specification does not allow, a property that cannot be written, a name or signature that is not there. zig build test checks that each is answered as it should be, and then runs each again with every one of its allocations failing in turn, which is the only way the code that cleans up after a failed allocation is ever run. zig build coverage writes a kcov report to zig-out/coverage of how much of src the seeds reach.

There are two ways to fuzz it. Zig's own fuzzer steers by coverage, and reports a failing input as input saved to '.zig-cache/f/crash':

$ zig build fuzz --fuzz          # until interrupted, with a web interface
$ zig build fuzz --fuzz=1M       # a bounded run, then a report

fuzz-run is a loop of this project's own in tools/fuzz.zig, which mutates the seeds without coverage feedback, can be run for a fixed time or from a fixed seed, and writes a failing input to fuzz-findings/, where --input runs it again:

$ zig build fuzz-run                                # a minute
$ zig build fuzz-run -- --seconds 300
$ zig build fuzz-run -Dfuzz-optimize=debug -- --input fuzz-findings/x.bin

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-mpris.git
Tangled git clone https://tangled.org/jcollie.dev/zig-mpris
Radicle rad clone rad:z3ZWFny68kSWDo3VGj2V6rKZtmM2s

A Radicle repository is findable only by its ID, so that one is written out in full: rad:z3ZWFny68kSWDo3VGj2V6rKZtmM2s.

Licensing

MIT, following the REUSE specification; reuse lint checks it.

References cited

Kept in the zig-mpris Zotero collection.