feat(qbzd): live event stream — GET /api/events (SSE) + qbzd watch

Add a Server-Sent Events feed so a client (a plasmoid, a bar applet, a script)
reacts to playback/queue/library changes without polling — the last CONSOLE
surface.

Server (api/sse.rs): `GET /api/events` streams one SSE frame per CoreEvent
(`event: <type>` + the tagged `data:` JSON). Because the control-plane serve
loop is single-threaded, an open stream would starve it, so the route is moved
onto its OWN thread (Request is Send) after the same origin/token gate every
route runs; the bus is drained with `blocking_recv()` there. Bulky/internal
events (search payloads, loading/download/navigation hints, diagnostics) are
filtered out; everything else — playback, queue, volume, auth, favorites,
playlists, errors, device changes — is emitted. ApiState gains the bus sender.

Client (`qbzd watch`): a bespoke reqwest client with NO read timeout (only the
connect is bounded); default output is newline-delimited JSON (one data payload
per line, `qbzd watch | jq`), `--raw` passes the SSE frames through verbatim.
P1_ROUTES → 26 with the route-table test updated (caller: `qbzd watch`).

283 tests green.

cc
This commit is contained in:
vicrodh committed 2026-07-19 22:58:22 -06:00
1 parent 102bbba9c7
commit 7f2898396d
7 files changed
+300 -3

No files matched your search

+1
View File
@@ -153,6 +153,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
crate::api::ApiState {
runtime: booted.runtime.clone(),
shared: booted.shared.clone(),
bus: booted.bus.clone(),
roots: roots.clone(),
token: cfg.server.token.filter(|t| !t.trim().is_empty()),
bind: bind_addr.to_string(),