Skip to content

Headless Daemon API

vicrodh edited this page Jul 24, 2026 · 1 revision

Headless Daemon — API & Examples

This is the worked-example companion to Headless Daemon. The main page tells you what qbzd is and how to install it; this one is for people who want to build on top of it — a Plasma widget, a web dashboard, a bar applet, a home-automation rule, a shell script.

Everything here talks to the daemon's small HTTP control plane on :8182. Every JSON body below is an illustrative dummy — real field values will differ, but the shapes are accurate. Read verbs (anything that returns data) also work from the CLI with --json, which prints the exact same payloads.

Contract: the JSON shapes and the CLI exit codes are stable within an API version (api_version, currently 1). Parse --json / the HTTP JSON, never the human-readable text.


Connecting

  • Base URL: http://<host>:8182 (default bind 0.0.0.0, so any LAN client works). Point the CLI at another box with QBZD_HOST=<ip>:8182.
  • Origin shield (always on): any request carrying an Origin header is rejected 403. Native clients (curl, QML, scripts, reqwest) don't send one; a browser fetch() does — so a browser page can't drive the port (CSRF protection). An <img> tag sends no Origin, so image URLs still work.
  • Optional token: if [server] token is set in qbzd.toml, every route except /api/ping needs Authorization: Bearer <token>.
# Liveness — always answers, even in token mode.
curl -s http://192.168.0.40:8182/api/ping
{ "ok": true, "app": "qbzd", "api_version": 1 }

Status & now-playing

GET /api/status is the whole daemon in one document — the first call any dashboard makes.

curl -s http://192.168.0.40:8182/api/status | jq
{
  "version": "2.0.2",
  "api_version": 1,
  "uptime_secs": 48213,
  "data_root": "/home/pi/.local/share/qbzd",
  "driver_tick_age_ms": 412,
  "auth":    { "state": "logged_in", "user_id": 10385965, "subscription": "hi-res" },
  "audio":   { "backend": "alsa", "configured_device": "hw:CARD=D30,DEV=0",
               "device_present": true, "device_open": true,
               "bit_perfect": "exclusive", "sample_rate": 192000, "bit_depth": 24 },
  "playback":{ "state": "playing", "track_id": 52960027, "title": "Spain",
               "artist": "Chick Corea", "position": 74, "duration": 592 },
  "qconnect":{ "enabled": true, "connected": true, "device_name": "QBZ (kitchen-pi)" },
  "network": { "bind": "0.0.0.0:8182", "token": false, "reachable": true },
  "last_errors": { "audio": null, "network": null, "auth": null }
}

GET /api/now-playing is the lighter poll for a "now playing" widget: the live playback block plus the full current track.

curl -s http://192.168.0.40:8182/api/now-playing | jq
{
  "playback": { "state": "playing", "position": 74, "duration": 592,
                "volume": 0.8, "muted": false, "shuffle": false, "repeat": "off",
                "sample_rate": 192000, "bit_depth": 24, "queue_len": 14 },
  "track": { "id": 52960027, "title": "Spain", "artist": "Chick Corea",
             "album": "Light as a Feather", "duration_secs": 592, "hires": true,
             "bit_depth": 24, "sample_rate": 192000,
             "artwork_url": "https://static.qobuz.com/images/covers/xy/…_600.jpg" }
}

When nothing is loaded, "track": null.


Transport & queue

All transport is POST with a small (often empty) JSON body; each returns the resulting state.

curl -s -XPOST http://192.168.0.40:8182/api/playback/toggle          # play/pause
curl -s -XPOST http://192.168.0.40:8182/api/playback/next            # advance
curl -s -XPOST http://192.168.0.40:8182/api/playback/seek   -d '{"position": 90}'
curl -s -XPOST http://192.168.0.40:8182/api/playback/volume -d '{"volume": 0.5}'
curl -s -XPOST http://192.168.0.40:8182/api/playback/shuffle -d '{"mode": "toggle"}'
{ "state": "paused" }                       // toggle
{ "position": 90, "duration": 592 }         // seek (clamped target)
{ "volume": 0.5, "muted": false }           // volume
{ "shuffle": true }                         // shuffle

Queue inspection and editing:

curl -s http://192.168.0.40:8182/api/queue | jq
curl -s -XPOST http://192.168.0.40:8182/api/queue/add  -d '{"track_ids": [52960027, 274703980]}'
curl -s -XPOST http://192.168.0.40:8182/api/queue/jump -d '{"index": 3}'
{
  "current_index": 2, "total": 14,
  "tracks": [
    { "index": 0, "id": 274703980, "title": "500 Miles High", "artist": "Chick Corea", "duration_secs": 528 },
    { "index": 1, "id": 52960024,  "title": "Captain Marvel",  "artist": "Chick Corea", "duration_secs": 274 }
  ]
}

Search, browse & play anything

curl -s 'http://192.168.0.40:8182/api/search?q=chick%20corea&type=album&limit=5' | jq
{
  "query": "chick corea", "type": "album", "limit": 5, "offset": 0,
  "albums": [
    { "id": "0060753762913", "title": "Light as a Feather", "artist": "Chick Corea",
      "tracks_count": 6, "hires": true, "release_date": "1973-01-01" }
  ],
  "tracks": [], "artists": [], "playlists": []
}

POST /api/play resolves anything — a track/album/artist id, a Qobuz share URL, or a Deezer link — and starts it:

curl -s -XPOST http://192.168.0.40:8182/api/play -d '{"content": "album:0060753762913"}'
curl -s -XPOST http://192.168.0.40:8182/api/play -d '{"content": "https://open.qobuz.com/album/0060753762913"}'
{ "state": "playing", "started": { "kind": "album", "id": "0060753762913", "tracks": 6 } }

Other reads follow the same pattern — GET /api/album?id=, /api/artist?id=, /api/similar?id=, /api/discover?section=new-releases, /api/lyrics?id=current, and POST /api/radio / POST /api/reco/playlist. Each returns a JSON envelope with the relevant list; add --ids on the CLI to get bare ids to pipe into queue add.


Cover art

GET /api/artwork/current is a 302 redirect to the current track's cover. Point an <img> straight at it — it always shows the live art, and because an <img> request sends no Origin, the shield doesn't block it:

<img src="http://192.168.0.40:8182/api/artwork/current" alt="cover">

404 when nothing is playing.


Live events (SSE) — the good part

GET /api/events is a Server-Sent Events stream: subscribe once and react to everything instead of polling. Each frame is event: <Type> + a data: line carrying the tagged CoreEvent JSON.

curl -sN http://192.168.0.40:8182/api/events
: qbzd event stream

event: TrackStarted
data: {"type":"TrackStarted","data":{"track":{"id":52960027,"title":"Spain","artist":"Chick Corea","album":"Light as a Feather","duration_secs":592,"artwork_url":"https://…_600.jpg"},"position_secs":0}}

event: PositionUpdated
data: {"type":"PositionUpdated","data":{"position_secs":1,"duration_secs":592}}

event: VolumeChanged
data: {"type":"VolumeChanged","data":{"volume":0.75}}

event: PlaybackStateChanged
data: {"type":"PlaybackStateChanged","data":{"state":"Paused"}}

Emitted types include TrackStarted, TrackEnded, PlaybackStateChanged, PositionUpdated, VolumeChanged, QueueUpdated, ShuffleChanged, RepeatModeChanged, FavoritesUpdated, PlaylistCreated/Updated/Deleted, LoggedIn/LoggedOut, and error events. Bulky/internal events (full search results, loading/download/navigation hints) are filtered out.

From the CLI, qbzd watch is the same stream as newline-delimited JSON (one data payload per line) — perfect for a status bar:

qbzd watch | jq -r 'select(.type=="TrackStarted") | "▶ " + .data.track.artist + " — " + .data.track.title'
▶ Chick Corea — Spain
▶ Chick Corea — 500 Miles High

Errors & exit codes

Every error is the same envelope; the CLI keys its exit code off error.code (never the HTTP status):

{ "error": { "code": "needs_auth", "message": "not logged in to Qobuz", "hint": "run: qbzd login" } }
Exit Meaning Example code
0 success
1 runtime error internal
2 usage error bad_request
3 daemon unreachable (transport)
4 needs auth needs_auth
5 audio/device error audio_unavailable
6 not found / bad id not_found
qbzd now; echo "exit=$?"     # exit=3 if the daemon isn't running — a monitor can key off this

Desktop integration without any of this: MPRIS

On Linux the daemon also publishes the standard MPRIS interface (org.mpris.MediaPlayer2), so for a Plasma/GNOME media widget you need zero of the above — the desktop already speaks MPRIS. Metadata, cover art and play/pause/next/seek/volume all flow through it. Use the HTTP API when you need something MPRIS doesn't cover (search, queue editing, browsing, favorites).

# Anything MPRIS-aware works out of the box, e.g.:
playerctl -p qbzd metadata title
playerctl -p qbzd play-pause

A tiny worked example: a 3-line "now playing" poll

#!/bin/sh
# Prints the current track once a second. Pipe to a bar, a notifier, anything.
while :; do
  curl -s "$QBZD_HOST"/api/now-playing \
    | jq -r '.track | if . == null then "— stopped —" else .artist + " — " + .title end'
  sleep 1
done

For anything event-driven (a widget that updates the instant a track changes), prefer qbzd watch / /api/events over polling.


See also: Headless Daemon (setup & the full CLI) · Casting and Remote Control · Build a Streaming Box

Clone this wiki locally