Skip to content

Headless Daemon

vicrodh edited this page Jul 20, 2026 · 5 revisions

Headless Daemon (qbzd)

qbzd is QBZ's headless playback daemon: a single native binary that plays music, exposes a Qobuz Connect endpoint, and configures itself over SSH — no window, no webview, no desktop required.

What It Is

  • One binary, ~25 MB, Slint-free. qbzd is the daemon, its own CLI client, and an interactive setup TUI, all in one executable. It never links qbz-ui, so it builds in about 90 seconds and runs comfortably on a Raspberry Pi.
  • The killer feature: a real Qobuz Connect endpoint. Once you log in and enable Connect, qbzd shows up inside the official Qobuz apps (phone, desktop, web) as a castable device — the same way a Sonos or a hardware streamer would. Cast to it from your phone; no QBZ desktop app needs to be open anywhere.
  • Bit-perfect. qbzd plays through the exact same protected audio core as desktop QBZ (qbz-audio / qbz-player) — ALSA direct hardware access, no forced resampling, no silently-inserted 48 kHz floor. If your DAC does 192 kHz/24-bit, so does the daemon.
  • Where it belongs: a box wired to your DAC that's always on — a Raspberry Pi, an LXC container, a living-room NUC, a headless server in a closet. Install it, log in, pick the audio device, and control playback from your phone via the Qobuz app, or from any machine on your LAN via the CLI/HTTP API.

Install

deb/rpm — the binary and a systemd user unit are bundled in the package; nothing further to copy.

Standalone tarball (no dependency on the desktop qbz app, no deb/rpm needed):

sudo install -Dm755 qbzd /usr/bin/qbzd
sudo install -Dm644 qbzd.service /usr/lib/systemd/user/qbzd.service
systemctl --user daemon-reload

Prefer a user-local install? Copy qbzd anywhere on your $PATH (e.g. ~/.local/bin/qbzd), edit the ExecStart= line in qbzd.service to match, then place it at ~/.config/systemd/user/qbzd.service and run systemctl --user daemon-reload. Shell completions for bash/zsh/fish ship alongside the binary in completions/.

Not systemd? qbzd service generates a ready-to-install service file for your init system — it auto-detects systemd, OpenRC, or runit (or name it explicitly), resolves the binary path and the target user's HOME/XDG_RUNTIME_DIR for you, and prints the file to stdout with install steps as comments:

qbzd service                 # auto-detect; prints the right file for this box
qbzd service openrc | sudo tee /etc/init.d/qbzd > /dev/null && sudo chmod +x /etc/init.d/qbzd
qbzd service runit           # prints an /etc/sv/qbzd/run script
qbzd service systemd --system  # a system unit (runs as your user) instead of the user unit
qbzd service systemd --user someuser --bin /usr/bin/qbzd  # override the resolved user/binary

The non-systemd templates run as a system service that drops to your user and sets HOME + XDG_RUNTIME_DIR so audio (PipeWire) and your login/token roots resolve — the piece a hand-written init script usually misses. On those boxes make sure your user is in the audio group (direct ALSA / bit-perfect) and that /run/user/<uid> exists for it (elogind + a login session or linger).

Self-build:

cargo build --release -p qbzd

qbzd is its own crate and never pulls in the Slint UI, so this is a ~90-second build, not the desktop's 20–30 minute one.

Raspberry Pi note: released binaries are built on Ubuntu 22.04 (glibc 2.35) specifically so they run unmodified on Raspberry Pi OS Bookworm (glibc 2.36) and similarly-aged distros — no rebuild needed.

Quickstart (5 Minutes)

1. Log in.

qbzd login

Login is always browser-based OAuth — there are no email/password fields. Run this over SSH and the printed URL automatically uses your box's LAN address (detected from the SSH session itself), so you can paste it into any browser on your network with zero flags. Fallbacks if the browser path doesn't fit your setup:

qbzd login --callback-host 192.168.0.40   # force a specific LAN address
qbzd login --paste                        # print URL, paste back the redirect
qbzd login --token <user_auth_token>      # inject a token directly

A stalled login (nothing received in 300 s) prints an SSH port-forward one-liner and points you at --paste/--token.

2. Configure it.

qbzd setup

Land on Account first if you're not logged in yet. Head to Audio and pick your DAC; while you're there, visit QConnect to name the device the way you want it to show up in the Qobuz app (default is QBZ (hostname)).

3. Enable the service — and enable linger.

systemctl --user enable --now qbzd
sudo loginctl enable-linger $USER

Enabling linger is not optional on a headless box. Without it, the user service — and the daemon with it — stops the instant you log out of SSH, and the device silently vanishes from the Qobuz app. qbzd status will warn you if linger is off.

4. Cast to it. Open the Qobuz app on your phone or desktop, open the Connect device picker, and pick your daemon by name (e.g. QBZ (kitchen-pi)). Playback starts on the daemon's DAC.

The Setup TUI

qbzd setup is a raspi-config-style configurator, not a player — it edits the daemon's real settings stores directly (works whether the daemon is running or not) and never shows track browsing, search, or transport controls. It needs an interactive terminal; run it non-tty (e.g. from a script) and it exits immediately with the equivalent non-interactive commands (qbzd settings import, qbzd settings set, qbzd login --token).

Layout. A persistent left nav sidebar (14 columns on an 80×24 terminal, doubling to 28 columns with spelled-out labels and a one-line summary once the terminal is at least 100 columns wide) sits beside a right-hand content frame. A one-row header carries the title and daemon version; a breadcrumb below it shows at most two levels — Setup › Audio at the section level, Audio › Output device inside a field. Modals (confirms, the device picker, the dirty-save prompt) are a third layer that never changes the breadcrumb.

The wide layout (terminals ≥ 100 columns — on narrower ones the sidebar compacts to names-only):

┌ QBZ Daemon Setup ─────────────────────────────────────────── qbzd 2.0.1 ┐
│ Setup › Audio                                                           │
├─────────────────────────┬───────────────────────────────────────────────┤
│   Account               │ OUTPUT                                        │
│     user 1234567        │  Backend          ALSA               [select] │
│ ▸ Audio               * │  Output device    Topping D30 [BP]   [select] │
│     ALSA · D30          │                                               │
│   Playback              │ BIT-PERFECT                                   │
│     Hi-Res+ · gapless   │  Exclusive mode   on                 [toggle] │
│   Qobuz Connect         │  Reserve DAC      off                [toggle] │
│     on · "Living Room"  │  DAC passthrough  off                [toggle] │
│   Network               │    PipeWire only                              │
│     0.0.0.0:8182 · open │                                               │
│   Import / Export       │                                               │
│   Wizard                │                                               │
│   Scrobbler             │                                               │
├─────────────────────────┴───────────────────────────────────────────────┤
│ daemon: running · playing 192 kHz / 24 bit                              │
└ ↑↓ move · Enter edit · s save · Tab nav↔content · q quit ───────────────┘

Navigation:

Key Effect
Tab ( where content allows) toggle focus between the sidebar and the content frame
/ move selection (sidebar rows, or fields inside a screen)
Enter open the selected section, or edit/activate the focused field
18 jump straight to a section (Account…Scrobbler); inert while a field is being edited
Esc in content → back to the sidebar; in the sidebar → the quit flow
s save the current screen
r refresh (e.g. re-enumerate audio devices)
/ filter (the device picker)
? help overlay

Sections (1–8): Account · Audio · Playback · QConnect · Network · Import/Export · Wizard · Scrobbler.

The Playback screen also carries a Media controls group with a single toggle — System media controls (MPRIS), on by default — which decides whether the daemon publishes itself to KDE/GNOME media widgets and hardware media keys (see Desktop Media Controls below). The Scrobbler screen is covered in Scrobbling.

The dirty guard. Any staged edit marks the current screen dirty (a * appears in its title). Nothing writes to disk until you press s. Leaving a dirty screen — Esc, jumping to another section, or quitting — opens a Save / Discard / Stay modal; there is no silent loss and no silent save. A handful of actions are immediate and confirmed instead of staged: logging in/out, pasting a token, applying an import, writing an export.

A field screen (Audio, showing the aligned control column and a word-wrapped disabled-reason under a constrained field):

┌ Audio * ─────────────────────────────────────────────────────┐
│ BIT-PERFECT                                                  │
│  Exclusive mode   off                            [toggle]    │
│    ALSA only                                                 │
│  Reserve DAC      off                            [toggle]    │
│  DAC passthrough  off                            [toggle]    │
│    PipeWire only                                             │
└ s save · r refresh devices · / filter · Esc back ────────────┘

Every screen computes its own control column (label width + a fixed gap) so values and widget markers line up consistently within that screen; long values truncate with rather than wrapping, but descriptions and disabled-reasons word-wrap onto their own dim line beneath the field so nothing clips at the frame edge.

HiFi Wizard

qbzd setup has a seventh section — Wizard — that ports the same DAC-configuration logic as the desktop HiFi Wizard into the TUI, so you can run it entirely over SSH. Six steps:

  1. Welcome — what the wizard does; Enter to start.
  2. Check — probes whether your audio stack (PipeWire/WirePlumber) is healthy, with distro and init-system overrides and copy-paste remediation commands if something's missing.
  3. Select DACs — auto-detects candidate devices in the background (never blocking the UI) and lets you multi-select with Space; press m to enter a node name manually if auto-detect misses your DAC.
  4. Review — one bordered block per selected DAC, showing the exact config text that would be written and the three target paths it would land at. This is where the copy keys matter (below).
  5. Test — cold-starts playback on the daemon itself and compares the requested sample rate/bit-depth against what actually negotiated on the wire, so you can confirm the DAC is really running bit-perfect before you trust it.
  6. Done — a summary of what was configured and the restart command for your init system.
┌ Wizard › Review ─────────────────────────────────────────────┐
│▸┌ Topping D30 ──────────────────────────────────────────────┐│
│ │ ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-d30.conf    ││
│ │ ~/.config/pipewire/client.conf.d/99-qbz-bitperfect.conf   ││
│ │ ~/.config/wireplumber/wireplumber.conf.d/99-qbz-d30.conf  ││
│ │                                                           ││
│ │ context.properties = {                                    ││
│ │   default.clock.allowed-rates = [ 44100 48000 96000 ]     ││
│ │ }                                                         ││
│ └───────────────────────────────────────────────────────────┘│
│                                                              │
│ c copy block · C copy all · w save block · never writes      │
│ system paths                                                 │
└ ↑↓ block · PgUp/PgDn scroll · → Test · Esc abandon ──────────┘

Copy keys — the wizard's whole point is that it never touches your system, so getting the generated text into your hands matters:

  • c — copies the focused block. Over SSH/tmux this goes out via OSC 52, which is a one-way terminal escape with no acknowledgment — so the wizard is honest about it: the flash reads "sent via OSC 52 — paste to confirm", not "copied." Blocks over 100 KB skip OSC 52 entirely (most terminals cap it) and fall straight to a file. Locally it tries wl-copy (Wayland) or xclip (X11) first, with OSC 52 and a file as fallbacks either way — a copy never simply fails.
  • C — copies every block at once, and always additionally writes ~/qbzd-wizard/all-blocks.conf regardless of which clipboard tier succeeded, so you have a file to fall back to even if the paste didn't take.
  • w — saves just the focused block to ~/qbzd-wizard/<name>.conf.

The daemon never writes system configs. Every path under ~/.config/pipewire/, ~/.config/wireplumber/, etc. is something you apply — the wizard only ever writes into ~/qbzd-wizard/. See HiFi Wizard for the full config semantics (sample-rate switching, per-app bit-perfect rules, targeting) — the daemon wizard generates byte-identical config blocks from the same underlying logic.

CLI Cheat Sheet

All read verbs accept --json for scripting — the raw, versioned API payload, stable field names, no color, no truncation; scripts should parse that, never the human-readable text.

The CLI is no longer just transport — it's a full remote control. As of QBZ 2.0.2 you can search, browse, build the queue, run radio and recommendations, manage favorites and playlists, read lyrics, scrobble, and stream live events, all without ever opening the desktop app.

Control & status

Verb What it does
qbzd status the diagnostic — auth, audio, playback, QConnect, network, last error, one line each
qbzd now one-line now-playing snapshot
qbzd ping liveness check (pong); always answers, even in opt-in token mode
qbzd play / pause / toggle / stop transport
qbzd next / prev advance the queue (runs the full skip → play → prefetch ritual, not a bare cursor move)
qbzd seek <POS|+N|-N|mm:ss> absolute seconds, relative, or mm:ss
qbzd volume [<0-100>|+N|-N] bare = read; with an argument = set
qbzd mute [on|off] bare = toggle
qbzd shuffle [on|off|toggle] queue shuffle; bare = toggle
qbzd repeat <off|all|one> queue repeat mode
qbzd watch [--raw] stream live daemon events (see Live Events)

Queue

Verb What it does
qbzd queue list|add|remove|clear inspect and edit the play queue
qbzd queue move <FROM> <TO> reorder a queue entry
qbzd queue jump <INDEX> jump playback to a queue position
qbzd queue stop-after [<INDEX>|off] stop after the given track (gapless-aware)

Find & queue music — every list verb takes --ids to print bare ids, which pipe straight into queue add -:

Verb What it does
qbzd search <query> [--type track|album|artist|playlist] search Qobuz; top hits with ids
qbzd play <TRACK|ALBUM|ARTIST|URL> resolve anything and play it now (Qobuz id, share URL, or Deezer link)
qbzd album <ID> / qbzd artist <ID> tracklist / artist page (top tracks, albums)
qbzd similar artist:<ID>|album:<ID> / qbzd suggest <TRACK_ID> more like this
qbzd discover [--section <name>] the Qobuz editorial feeds (new releases, press awards, playlists…)
qbzd radio <SEED> start an endless radio from a track/artist
qbzd reco playlist <PLAYLIST_ID> recommend tracks to extend one of your playlists
qbzd lyrics [<TRACK_ID>] synced or plain lyrics; bare = current track
qbzd art [--save PATH] current cover art URL (or download it)
qbzd resolve <URL> parse a share/Deezer URL into a Qobuz id, no network

Library

Verb What it does
qbzd fav list|add|remove Qobuz favorites (tracks, albums, artists)
qbzd playlist list|show|create|edit|rm|add|remove full playlist CRUD (create, rename, add/remove tracks, delete)
qbzd scrobble login|status|enable|disable Last.fm / ListenBrainz (see Scrobbling)

Housekeeping

Verb What it does
qbzd settings export|import|show|set settings bundle tools — see the hand-off section below
qbzd qconnect enable|disable|name Qobuz Connect on/off and device naming
qbzd login / logout see Quickstart
qbzd config path|show config/data roots, credential file location, effective qbzd.toml
qbzd service [systemd|openrc|runit] generate an init service file (auto-detects the init if omitted)
qbzd version CLI version + API version, no daemon or network needed

Remote control: point the CLI at another box with QBZD_HOST=<ip>:8182 (or --host <ip>:<port> per-invocation) — no mDNS, no discovery, just a plain host:port like MPD's MPD_HOST.

Exit codes are one frozen table across every verb — scripts can key off them without parsing output: 0 success · 1 runtime error · 2 usage error · 3 daemon unreachable · 4 needs auth · 5 audio/device error · 6 not found.

Scrobbling (Last.fm / ListenBrainz)

qbzd scrobbles what it plays to Last.fm and/or ListenBrainz — connected the same way you log in: over SSH, no passwords typed into the terminal.

From the CLI:

qbzd scrobble login lastfm                       # prints a Last.fm authorize URL; press Enter after you approve
qbzd scrobble login listenbrainz --token <TOKEN> # your ListenBrainz user token (listenbrainz.org/settings)
qbzd scrobble status                             # per-service connection + on/off state
qbzd scrobble disable lastfm                     # keep the credentials, stop scrobbling
qbzd scrobble enable  lastfm                     # ...and back on

…or from the TUI: qbzd setupScrobbler (section 8). Press L to connect Last.fm, B for ListenBrainz — the same flow, run right there in the setup terminal (Last.fm prints the authorize URL; ListenBrainz asks you to paste the token).

Once connected it's automatic: on each track the daemon sends "now playing", then scrobbles the track once it's played past Last.fm's half-or-four-minutes threshold. Credentials live in the daemon's own scrobbler store — the same format the desktop uses and the settings bundle carries — so a bundle import with --include-auth can bring them along.

Offline-safe. If ListenBrainz can't be reached when a scrobble is due, the listen is queued to a local database and retried automatically (on a timer, and once at startup), so a flaky connection never loses plays.

Desktop Media Controls (MPRIS)

On Linux qbzd publishes the standard MPRIS interface (org.mpris.MediaPlayer2), so — with nothing else to install — the daemon shows up in the KDE Plasma / GNOME Shell media widget, responds to hardware media keys, and is drivable by any MPRIS-aware applet or plasmoid. Now-playing metadata (title, artist, album, cover art) plus play/pause/next/previous/seek/volume all flow through it. For a Linux desktop widget this is the easiest path by far: Plasma and GNOME already speak MPRIS, so there is nothing to build.

It's on by default where a session bus exists, and degrades to a silent no-op on a truly headless box with no D-Bus. To turn it off:

  • In the TUI: qbzd setupPlaybackSystem media controls → off (applies on the next daemon restart).
  • From the CLI: qbzd settings set playback.mpris off.
  • Per-launch override: the QBZD_MPRIS environment variable (0/off/no to force it off, anything else on) wins over the stored setting — handy inside a service unit.

This is local desktop IPC, not an internet integration: it exposes playback control on the machine the daemon runs on, and nothing leaves the box.

Live Events (qbzd watch)

qbzd watch opens a live event stream and prints daemon events as they happen — track changes, play/pause, position, volume, queue edits, favorites, playlist changes, login state, errors. It's how you build a status bar, a notifier, or a custom panel without polling qbzd now in a loop.

qbzd watch                 # newline-delimited JSON, one event per line — pipe into jq
qbzd watch | jq -r 'select(.type=="TrackStarted") | .data.track.title'
qbzd watch --raw           # the raw Server-Sent Events frames (event:/data:)

Under the hood it's a plain Server-Sent Events endpoint — GET /api/events — so a browser-free client (a plasmoid, a bar applet) can subscribe to the same stream directly.

HTTP API (build your own client)

Everything the CLI does goes through a small HTTP control plane on :8182, and a third-party client (a KDE plasmoid, a home-automation rule, a phone app) can speak to it directly. A few surfaces worth knowing:

  • GET /api/status, GET /api/now-playing — current state (never your credentials).
  • GET /api/events — the live SSE stream above.
  • GET /api/artwork/current — a 302 redirect to the current track's cover; point an <img src> straight at it and it always shows the live art (an <img> request sends no Origin, so it isn't blocked by the shield below).
  • POST /api/playback/*, /api/queue/* — transport and queue control.

All routes sit behind the same origin shield + optional token described under Network & Security Posture. Reach for MPRIS first on a Linux desktop; use the HTTP API when you need something MPRIS doesn't cover.

Settings Hand-off (Desktop → Daemon)

Already have your quality preferences, DAC settings and QConnect name dialed in on the desktop app? Bring them to the daemon instead of re-doing everything in the TUI:

  1. On the desktop: Settings > Developer > Export settings… — or, from a terminal on the same box, qbzd settings export --from desktop.
  2. Move the resulting .qbzb file to the daemon box (scp is fine — it's written 0600).
  3. On the daemon box: qbzd settings import qbz-settings-20260714.qbzb.

Import always ends with a three-bucket summary: fields that applied verbatim, fields that needed adapting (e.g. an audio device that only exists on the desktop box — you're walked through re-picking it), and fields that were skipped, each with a one-line reason.

Secrets never ride along by accident. Your Qobuz login only travels with the bundle if you pass --include-auth on both sides — export and import:

qbzd settings export --from desktop --include-auth
qbzd settings import qbz-settings-20260714.qbzb --include-auth

Without --include-auth on the import side, a credential present in the file is skipped with a reason — handing someone a bundle can never silently log them in as you.

Network & Security Posture

qbzd binds 0.0.0.0:8182 by default — deliberately LAN-first, the same posture as Sonos, Chromecast, or MPD: anyone on your local network can drive playback with no login prompt. This is a considered trade-off, not an oversight — the control plane exposes playback state, never credentials (/api/status never carries your Qobuz token).

Two things qualify that posture:

  • Origin shield, always on. Any request carrying an Origin header is rejected outright (403). This blocks browser-based CSRF/DNS-rebinding abuse of the port at zero cost to real clients — the CLI, curl, scripts, and home-automation tools never send an Origin header.
  • Opt-in shared secret. Set [server] token = "<secret>" in qbzd.toml (via the Network screen in qbzd setup, or by hand) to require Authorization: Bearer <token> on every route except /api/ping. The CLI reads it from QBZD_TOKEN (remote) or the local qbzd.toml (on the daemon box). Left unset (the default), there is no auth machinery at all.

To restrict access instead of opening it to the LAN, set [server] bind = "127.0.0.1" in qbzd.toml and restart the daemon.

There is no TLS. This is plain HTTP — fine on a trusted home LAN, never something to expose past it (no port-forwarding it to the internet).

Troubleshooting

"Why is it silent?" → qbzd status. This is the one command to run first for anything that looks wrong. It reports auth state, the audio device (present/open/bit-perfect mode), playback state, QConnect connection state, network reachability, and the last latched error for each subsystem — and its exit code alone tells a monitoring script whether things are healthy (0), unreachable (3), unauthenticated (4), or the audio device won't open (5).

Linger is off. qbzd status prints a warning naming the exact fix — sudo loginctl enable-linger <your-user>. Without it, the daemon dies the moment your SSH session ends.

Port 8182 already in use. The bind-failure message distinguishes a genuinely foreign process from a second qbzd already running (it probes the occupant over /api/ping first). If it's something else, change [server].port in qbzd.toml.

Token rotated or session expired. The daemon doesn't crash — it drops to NeedsAuth and stays up, still answering ping/status; playback routes answer 409 with a login hint. Just run qbzd login again.

The DAC vanished. Check qbzd status's audio block for device_present/device_open. Re-plug it and press r on the Audio screen of qbzd setup to re-enumerate, or turn on Reserve DAC so the daemon holds the device between tracks instead of releasing it.


See also: Casting and Remote Control · Audio Configuration · HiFi Wizard · Troubleshooting

Clone this wiki locally