The system_capabilities profiler (Normal vs LowMemory, built for #331)
lost all its consumers in the Tauri removal; 2.0.2 shipped it as dead
code and qbzd got OOM-killed (exit 137) on 1 GB Pi hosts at track
transitions. Phase A wiring:
- Frontends read memory_profile() at startup and push it down via the
new player memory_tuning (initial-buffer cap, L1 budget, low-memory
gate) before the Player is created.
- L1 audio cache sized from the profile (400 MB Normal / 50 MB
LowMemory; was hardcoded 400 MB).
- Oversized tracks (>L1/4 = 12.5 MB on LowMemory) skip the promotion
clone and the parallel CMAF RAM accumulator entirely; the finished
track is streamed to the L2 disk cache in 1 MB chunks straight from
the playback buffer — no second in-RAM copy. Gapless degrades to a
small gap for those tracks (a gap beats an OOM kill).
- Stream buffer preallocates total_size up front (try_reserve_exact,
1 GiB cap, dynamic fallback) — no more ~8 doubling reallocs + memcpy
per track (weak-CPU relief).
- New memory-pressure watchdog (15 s): critical pressure evicts the
L1 (L2 disk kept) and halts prefetch; low pressure pauses HiRes
prefetch (downgrades to Lossless). Spawned by both frontends; qbzd
aborts+joins it for the #521 shutdown ordering.
- Prefetch honors the profile (depth cap, HiRes->Lossless downgrade).
Normal-profile behavior is byte-identical (every behavioral change is
gated on the LowMemory class; profile values equal the old hardcodes).
Phase B (follow-ups): tempfile spill of the live stream buffer for
giant tracks, POLLERR flood rate-limit, desktop kick_prefetch halt
check, L2-read on the play_track cache-hit path.
km
playerctl scripts/widgets can now identify the exact song:
https://open.qobuz.com/track/<id> on every Qobuz catalog row (offline
qobuz_download keeps the real id, so it qualifies too). Local/Plex rows
omit the field — QueueTrack carries no file path to map to file:// and
a synthetic local id would mint a bogus Qobuz link. Wired in both the
app (refresh_now_playing_meta) and qbzd (track_meta).
km
The rustls crypto provider was not installed for `qbzd`, which caused the
process to panic:
> thread 'tokio-rt-worker' (15737) panicked at [...]/rustls-0.23.37/src/crypto/mod.rs:249:14:
>
> Could not automatically determine the process-level CryptoProvider from
> Rustls crate features.
> Call CryptoProvider::install_default() before this point to select a
> provider manually, or make sure exactly one of the 'aws-lc-rs' and 'ring'
> features is enabled.
> See the documentation of the CryptoProvider type for more information.
The function `ensure_crypto_provider()` was defined in the `qbz-cast` crate
which is obviously not a dependency of `qbzd`.
This commit moves `ensure_crypto_provider()` to `qbz-app`, calls it from `qbzd`
and updates `qbz` accordingly.
parse_login_response now reads user.country_code and user.language_code
(snake_case wire names, verified against Qobuz's own embedded login
fixture) into two new serde-defaulted Option fields on UserSession, so
pre-v10 persisted sessions still load with both as None (translation
stays Auto-off). qbzd's test construction updated for the new fields.
km
The API serve loop is single-threaded; an inline block_on of the
load leg (next/previous/jump/play/cold-start) starved every route for
the whole fetch — clients read 'daemon not reachable' while the action
DID execute. The resolve+play leg now runs on the tokio runtime and
the routes ACK immediately; cheap gates (auth, 404, empty queue) stay
synchronous with their documented errors, and load failures latch into
last_errors.stream (visible via status). next/previous answer
{"queued":true,"direction":...} (CLI renders '-> queued (next)');
play/toggle cold-start report {"state":"loading"}; jump/play keep
their response shape. Live-verified: next returns in ~20 ms and status
answers during a load.
km
CPAL enumerates descriptions, never hw:CARD=... ids, so a playing
direct-hw stream read as 'not present'. device_present is now
device_open || enumeration-match.
km
The daemon was queue receive-only: a CLI/TUI/MPRIS/restored queue never
reached the cloud, so controllers rendered a different queue than the
one playing. New qconnect/publish.rs ports the desktop's
sync_local_queue_if_changed with its full gate set (active-renderer,
offline-only, echo-suppress vs last-applied, per-session pushed-ids
latch, all-or-nothing admission), triggered by a debounced
CoreEvent::QueueUpdated subscriber — which also covers queue edits
while paused/stopped, unlike a transition-only hook. Live-verified: a
CLI queue move produced exactly one QueueLoadTracks push (233 tracks)
and the cloud echo was suppressed.
km
Akamai answers small raw-url objects with ~106 headers, over hyper's
hard-coded 100-header h1 cap, so every reqwest fetch of those tracks
failed identically (streaming probe AND full download) and the track
never played — with the cause hidden by reqwest's Display. Now:
describe_reqwest_error() walks the source chain for logs; on the
header-flood signature the engine skips the doomed reqwest re-download
and falls back to play_track_resolved (CMAF, unaffected) plus an
explicit sync_current_to_id so the queue cursor follows the audio.
Any other failure keeps the previous behavior. Live-verified on the Pi
(DSOTM track 1 plays; gapless continues).
km
With a custom name set, the controller bootstrap announced it but the
deferred renderer join and resolve_local_identity used the DEFAULT name —
one device_uuid, two friendly names. When the server's ADD_RENDERER omits
the uuid (observed on the wire), the name-fingerprint self-match failed,
local_renderer_id stayed None and the is_local_renderer_active gate
dropped every renderer report (mute renderer, frozen controller seekbar).
default_qconnect_device_info() now resolves the persisted name on both
frontends, so all announce paths agree by construction. Wire-verified:
ADD_RENDERER carries the custom name and reports flow.
km
The token encryption key mixed in the XDG portal secret (session-bus
dependent): a login run in a graphical session wrote a token an
init-started/headless qbzd could never decrypt, reported as a bare
"not logged in". PortalKey policy: desktop root keeps Session, daemon
root uses Never; one-shot migration on read (decrypt with Session,
rewrite portal-free); oauth_token_file_present_at() +
latch_undecryptable_token() so status distinguishes "no token" from
"token I cannot decrypt". Live-verified 3x (OpenRC, headless Pi,
migration path).
km
Add `qbzd service [INIT] [--user U] [--bin PATH] [--system]`, a pure-local
generator (no daemon, like `completions`) that prints a ready-to-install service
definition for the host init — auto-detecting systemd/openrc/runit when INIT is
omitted. systemd is the standard (and ships a user unit); this also covers the
inits the packaging can't: OpenRC (supervise-daemon + command_user) and runit
(chpst), plus a systemd --system unit.
The thing those non-user services get wrong by default is the AUDIO ENVIRONMENT:
dropping to a user loses that user's session env, so PipeWire (XDG_RUNTIME_DIR)
and the config/token roots (HOME) go missing. Every system-level template sets
both explicitly, resolved for the target user at generation time (getent/id;
the live XDG_RUNTIME_DIR for the current user), and notes the elogind/linger +
`audio` group requirements. Binary path defaults to the running executable.
Verified on a live OpenRC box (auto-detect + resolution correct). 287 tests.
cc
Add a stable current-cover redirect so a daemon-only client (a plasmoid, a bar
applet, an `<img src>` in a dashboard) can point at
`http://<host>/api/artwork/current` and always get the live cover — no need to
know the Qobuz CDN URL. It reads queue state and 302s to the art URL already
stamped on the current track (unauthenticated CDN link, no Qobuz session), or
404s when nothing is playing.
`qbzd art` becomes the route's shipped caller (§3.1.4): it now issues a
redirect-none request and reads the 302 Location (bare `art` prints it; `--save`
follows to download), instead of parsing `/api/now-playing`. It no longer
depends on auth state, so its exit set drops the needs-auth 4 (now 0 · 3 · 6).
P1_ROUTES → 27 with the route-table test updated.
283 tests green. This completes the CONSOLE surface (search/play/browse/discover/
radio/fav/playlist/queue/shuffle/repeat/art/lyrics/resolve/reco + scrobbling +
MPRIS + watch/SSE + artwork).
cc
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
MPRIS enablement moves from env-only to a persisted daemon pref, exposed as the
canonical `playback.mpris` setting key so it round-trips through `qbzd settings
show|set`, the settings bundle, AND a toggle in the setup-TUI:
- daemon_prefs gains `mpris_enabled: bool` (default true; unknown-field guard
updated).
- `playback.mpris` added to the settings KEY_TABLE (ApplyClass::None), with the
read (`settings show`) and write (`settings set`) arms; the per-canonical-key
round-trip test now covers it.
- setup-TUI Playback screen: a new "MEDIA CONTROLS" group with a System media
controls toggle, carrying a description line ("MPRIS: KDE/GNOME media widget +
media keys · restart to apply") since it applies at next daemon start.
- `mpris::enabled(roots)`: QBZD_MPRIS env still wins when set (deploy override);
otherwise the persisted toggle decides. Default stays ON.
280 tests green.
cc
Migrate `qbzd scrobble login|status|enable|disable` off the interim custom
`crate::scrobbler` (scrobbler.json) onto the canonical
`qbz_app::settings::scrobblers::ScrobblerSettingsStore` (SQLite at the daemon
data root) — the SAME store the scrobble-on-play engine reads and the settings
bundle export/import already carries, so credentials are consistent across the
daemon, the desktop store format, and bundle transport. Session keys / tokens are
registered with `qbz_log::register_secret` before they touch the store.
enable/disable now refuses a not-connected provider up front (`not_connected`)
and status reads `lastfm_is_authed()/active()` from the store snapshot.
This was the orphaned tail of the store migration the scrobble-on-play engine
and TUI screen already build on; committing it closes the gap.
cc
Add a daemon MPRIS service (CONSOLE ext) via qbz-media-controls (mpris-server on
Linux, incl. the DesktopEntry that lets GNOME/KDE resolve the app icon). A KDE
Plasma media widget, a plasmoid, or hardware media keys now drive the daemon
with NO custom client code — the marquee third-party-integration surface.
Two halves in crate::mpris:
- OUTBOUND: a CoreEvent-bus subscriber pushes now-playing metadata, play/pause,
live position, and volume into the OS controls (seeded once from live state).
- INBOUND: the qbz-media-controls callback maps MediaEvent (Play/Pause/Toggle/
Next/Previous/Stop/Seek/SetPosition/SetVolume) onto core transport commands;
seek/volume honor the DSD-direct guard; Next/Previous fire the full advance
ritual off the D-Bus thread; Raise/Quit are ignored (headless).
The inbound callback holds only a Weak<AppRuntime> (upgraded per event) and the
updater task upgrades a Weak once to seed then drops it, so the integration never
pins the runtime — the #521 audio-release-before-drop(booted) ordering is
unaffected. Spawned next to the scrobbler in run(); torn down (updater abort +
handle drop) in shutdown. On by default where a session bus exists; QBZD_MPRIS in
{0,false,off,no} disables it, and spawn returns None gracefully on a headless
box with no D-Bus. 280 tests green.
cc
A failed `submit_listen` now writes the listen to the SHARED
`ListenBrainzCache.listen_queue` (daemon-root `cache/listenbrainz_v2.db`, the
same schema the desktop uses) instead of being lost. The scrobble task's loop
becomes a biased `select!` between the CoreEvent bus and a 120s drain interval
(the interval also fires once at task start), so a queue built up while offline
is retried oldest-first and cleared on reconnect, stopping at the first failure
and resuming on the next tick. Mirrors `qbz::scrobble::flush_listenbrainz_queue`:
the rusqlite Connection is opened inside a `spawn_blocking` per op and never held
across an await. The daemon uses its own `qbzd` cache root — it never touches the
desktop's dirs.
cc
Add an eighth setup-TUI section (`qbzd setup`) for scrobbler auth, mirroring
the Account browser-login methodology: the App suspends the alt-screen and runs
the CLI connect flow on the plain terminal, then reloads the screen's settings
snapshot on resume. Last.fm prints an authorize URL and waits for Enter (the CLI
verb owns that); ListenBrainz prompts for a pasted user token, then hands it to
the CLI verb. Both write the canonical ScrobblerSettingsStore, so enable/disable
stays a CLI verb and the screen only shows connection state.
Scrobbler is appended LAST in SCREENS so no existing section index or number-key
jump shifts; the number-key jump range widens 1-7 -> 1-8. The D7 six-screen cap
was already lifted for FB4's Wizard; this is the second owner-sanctioned break.
cc
The daemon now scrobbles what it plays. A CoreEvent-bus subscriber
(scrobble_engine, spawned in run() next to queue-persist):
- on TrackStarted -> "now playing" to each active provider (LastFm
update_now_playing / ListenBrainz submit_playing_now);
- scrobbles ONCE when the position crosses the threshold from
qbz_app::scrobble_timing::scrobble_delay_secs (Last.fm's half-or-4-min rule)
-> LastFm scrobble / ListenBrainz submit_listen, best-effort + logged;
- re-reads the canonical ScrobblerSettingsStore on each track, so `qbzd scrobble
login/enable/disable` take effect on the next track with no reload signal;
- holds no Arc<AppRuntime>; aborted on shutdown alongside queue-persist.
Album is dropped when empty / "Unknown Album". Pure helpers (due, album_opt)
are unit-tested. The ListenBrainz offline queue (ListenBrainzCache) is a
follow-up — this slice submits best-effort.
Verified: cargo test -p qbzd --bin qbzd -> 278 passed, 0 failed.
cc
The connect flows, same methodology as `qbzd login`:
- `scrobble login lastfm`: LastFmClient::get_token prints an authorize URL, the
user approves on last.fm, get_session exchanges it for a session key.
- `scrobble login listenbrainz --token <T>`: ListenBrainzClient::set_token
validates a pasted user token (like `login --token`).
- `scrobble status` · `scrobble enable|disable <provider>`.
Credentials land in the CANONICAL scrobbler store
(qbz_app::settings::scrobblers::ScrobblerSettingsStore — SQLite at the daemon
data root), the SAME store the desktop uses and the one the settings bundle
export/import already carries, so scrobbler creds travel with `settings export`.
Secrets registered with qbz_log; a running daemon is nudged to reload.
- Cargo.toml: qbz-integrations path dep (already in-tree via qbz-core).
- cli/scrobble.rs: the verbs. main.rs: Scrobble subcommand group.
Verified: cargo test -p qbzd --bin qbzd -> 276 passed, 0 failed.
NOTE: lands CREDENTIALS + auth only. Scrobble-on-play (a daemon task on the
CoreEvent bus: update_now_playing on TrackStarted, scrobble past
scrobble_delay_secs, ListenBrainz submit + offline queue) is the next slice —
it wires the playback driver. The setup-TUI Scrobbler screen is also pending.
cc
Enables QBZ's Suggested-Songs engine headlessly and Slint-free. The
artist-vector store is a CACHE the engine builds on demand from MusicBrainz +
Qobuz — no listening history needed, so the personalized (history-based)
recommendations stay out of scope (they would need play-recording plumbing).
- Cargo.toml: qbz-reco path dep (already in-tree via qbz-core; direct dep only
exposes the ArtistVectorStore name).
- daemon.rs: open ArtistVectorStore::open_at(daemon-data-root) after core init
and set_artist_vectors — mirrors qbz/src/auth.rs, session-independent,
best-effort (suggestions still work un-cached if the open fails).
- api/reco.rs: POST /api/reco/playlist {playlist_id} (server seeds the engine
with the playlist's distinct artists + excludes its tracks) | {artists,
exclude} | {limit}. generate_playlist_suggestions; auth-gated. Documents the
single-thread cost (MusicBrainz-resolve is rate-limited).
- cli/reco.rs: `qbzd reco playlist <ID> [--limit --ids --json]`; --ids emits
suggested track_ids (pipe into `queue add -` / `playlist add`).
- api/mod.rs: P1_ROUTES 23->24; route + test. main.rs: Reco subcommand group.
Verified: cargo test -p qbzd --bin qbzd -> 274 passed, 0 failed.
cc
- api/playlist.rs: POST /api/playlist/create, /update, /delete, /tracks/add,
/tracks/remove. tracks/remove takes PLAIN track ids and the daemon resolves
them to per-playlist playlist_track_id ROW ids (the row-id trap) via
get_playlist before remove_tracks_from_playlist — clients never touch row ids.
- cli/playlist.rs: `playlist create/edit/rm/add/remove` (rm requires --yes; add
reads `-` from stdin; edit --public/--private mutually exclusive).
- api/mod.rs: P1_ROUTES 17->22; routes + test. main.rs: PlaylistCmd variants.
Pending: `cargo check -p qbzd` — box at 7GB with a qbz_ui build running;
compile deferred to avoid an OOM freeze (verified next in a memory window).
cc
`qbzd art` prints the now-playing track's artwork_url (already carried on
GET /api/now-playing — an unauthenticated Qobuz CDN URL); `qbzd art --save
PATH` downloads it (notification icons, terminal viewers). A pure-ish client
verb: no new route (reads the shipped now-playing). A daemon-served
GET /api/artwork/current (302) is deferred until a daemon-only client needs it.
Verified: cargo test -p qbzd --bin qbzd -> 269 passed, 0 failed.
cc
- api/fav.rs: GET /api/favorites (paged read), POST /api/favorites/add|remove.
The contract is SINGULAR (track|album|artist) everywhere; the server
pluralizes for get_favorites, hiding the plural-read/singular-write trap.
`add {current:true}` favorites the now-playing track.
- api/playlist.rs: GET /api/playlists (collection), GET /api/playlist?id=
(one playlist, full track list). Reads only in this slice; CRUD deferred.
- cli/fav.rs + cli/playlist.rs reuse browse's generic render/collect_ids
(made pub(crate)); playlist list has its own id/name/count renderer.
- api/mod.rs: P1_ROUTES 12->17 (+favorites x3, playlists, playlist); routes +
test. main.rs: Fav/Playlist subcommand groups.
Verified: cargo test -p qbzd --bin qbzd -> 269 passed, 0 failed.
cc
Completes transport + queue parity from the CLI.
- api/playback.rs: POST /api/playback/shuffle {on|off|toggle} and
/api/playback/repeat {off|all|one} (set_shuffle / toggle_shuffle /
set_repeat_mode; no auth — queue-mode toggles, state surfaces in now/status).
- api/queue.rs: POST /api/queue/move {from,to} (move_track); /api/queue/jump
{index} (play_index + the shipped start ritual, auth-gated — a click-to-play
row, never a bare cursor move); /api/queue/stop-after {track_id|current|off}
(set_stop_after / clear_stop_after).
- cli/mode.rs: `shuffle`, `repeat`. cli/queue.rs: `queue move/jump/stop-after`
(1-based positions translated to 0-based at the boundary, like `queue remove`).
- api/mod.rs: P1_ROUTES 7->12; routes + test. main.rs: subcommands.
Verified: cargo test -p qbzd --bin qbzd -> 264 passed, 0 failed.
cc
qbzd can now BROWSE the catalog from the CLI: open an album, an artist page /
top-tracks / releases grid, find similar artists or albums, and pull For-You
suggestions — reads that feed the composition pipeline (`--ids | queue add -`).
- api/browse.rs: GET /api/album (get_album + optional get_album_suggest);
GET /api/artist?view=page|top|albums (get_artist_page / get_artist_tracks /
get_releases_grid); GET /api/similar?artist=|album= (get_similar_artists /
get_album_suggest); GET /api/suggest?seed= (get_dynamic_suggest — seeds from
the current queue when omitted). Typed serde shapes verbatim; auth-gated;
blacklist fail-open.
- cli/browse.rs: `album`, `artist`, `similar`, `suggest`; a generic items/tracks
walk drives the human list and --ids; `--seed -` reads ids from stdin.
- api/mod.rs: P1_ROUTES 2->6 (+album/artist/similar/suggest); routes + test.
main.rs: four subcommands.
Verified: cargo test -p qbzd --bin qbzd -> 262 passed, 0 failed.
cc
qbzd can now START playback of a track / album / playlist / artist / Qobuz
URL from the CLI — the receiver becomes a source. Server-side materialization
and the shipped cold-start ritual; the protected audio path is untouched.
- api/play.rs: resolve selector (a URL via link_resolver wins over id fields),
fetch tracks (get_album / get_playlist / get_artist_tracks / get_tracks_batch),
map via the now-shared queue::track_to_queue_track, stamp album/artist/
playlist provenance, then set_queue -> play_track_resolved -> save_session_now
(never a bare cursor move). Auth-gated; 404 on unknown id / empty resolution;
503 on start failure.
- cli/play.rs: `qbzd play [album:ID|track:ID|artist:ID|playlist:ID|URL|N]`;
bare `play` delegates to transport::play (one resume implementation). Local
exit 2 on a malformed selector.
- api/queue.rs: track_to_queue_track -> pub(crate); api/playback.rs:
resolve_quality -> pub(crate) (both now shared with play).
- api/mod.rs: P1_ROUTES += POST /api/play; route + test. main.rs: Play gains
an optional content arg (bare = resume, unchanged).
Verified: cargo test -p qbzd --bin qbzd -> 256 passed, 0 failed.
cc
First slice of Proposal A (CONSOLE): qbzd can originate discovery — search
Qobuz by name from the CLI, not only receive via QConnect or take track
ids by hand.
- api/search.rs: GET /api/search?q=&type=&limit=&offset=. Typed searches
return SearchResultsPage<T> verbatim under a per-category key; type=all
fans out to the four typed searches. Auth-gated (needs_auth); blacklist
fail-open by design (documented GUI-parity delta).
- api/mod.rs: new P1_ROUTES table + p1_route_table test — the shipped-caller
HARD RULE (§3.1.4) applied to the content-verb door; route registered.
- cli/search.rs: `qbzd search <q> [--type all|albums|tracks|artists|
playlists] [--limit N] [--offset N] [--ids] [--json]`. --ids emits ids
one-per-line (pipes into `queue add -`); frozen exit-code table.
Verified: `cargo check -p qbzd` clean (no warnings); `cargo test -p qbzd
--bin qbzd` → 248 passed, 0 failed (10 new + 238 existing). New files are
standard-rustfmt; existing files carry only the minimal logical edits.
cc
Issue #637: a Qobuz outage + forced re-login left a user's account
transiently 403'd, and the no-backoff prefetch retry storm escalated it
into a per-IP edge/WAF block (the "error decoding response body" lines are
that non-JSON WAF body hitting a bare .json()). The bundle bump b019->b034
is exonerated: extraction yields byte-identical app_id/secret/private_key.
- ForbiddenBreaker: after repeated 403s the hot streaming/favorites paths
short-circuit without touching the network, with an exponential cooldown
and a half-open probe; success resets it.
- get_stream_url: typed 401/403 + body preview; abort the quality fallback
loop on 403/401/breaker-open instead of firing all five qualities.
- CMAF session/start + file/url: feed the breaker, map 403.
- get_favorites + qws/createToken: check status before .json() so a 403
surfaces as a 403, not a misleading "error decoding response body".
- Prefetch: per-track failure cooldown (negative cache) so a failed track
is not re-hammered every queue tick.
- Audible path: a 403/back-off no longer counts as an "unavailable" skip;
stop cleanly with a toast.
- QBZ_FORCE_403 test hook to smoke the back-off path on a healthy account.
- Header action-button tooltips for AlbumView/ArtistView/PlaylistView,
translated across all 8 locales.
cc
Implements owner requirement 2 (03-setup-tui.md): `qbzd setup`, a six-screen
raspi-config-grade configurator over the daemon's real stores — zero new
persistence. ratatui 0.30 (IV5: unifies on the lock's unicode-width 0.2.2;
0.29 forbidden). A synchronous event loop on a blocking thread; disk/HTTP only
at screen entry, `r`, save and the immediate actions, on workers with a spinner
(no I/O on keystrokes). Non-tty exits 2 with the verbatim copy; alt-screen +
panic-hook restore.
Screens: Account (T5 login engine; inline masked token paste, suspend-and-run
browser login, logout confirm), Audio (the §3.2.3 constraint matrix + 7
cascades verified 1:1 vs the desktop code, device picker re-deriving the
§3.2.2 grouping/BP-badge rules, DSD guard), Playback (ask->fallback rule,
infinite-radio read-only), QConnect (startup/name/volume_mode, live preview),
Network (whole-file qbzd.toml rewrite preserving every key), Import/Export
(pure renderer of the T12 bundle plan; interactive device re-pick; default-off
auth gate). Saves reuse T11 write_one + reload/status classification (§4.3);
dirty marker + Save/Discard/Stay. All user-facing strings centralized.
cc