Qobuz Connect renderer - #790
Draft
PhilipVinc wants to merge 38 commits into
Draft
Conversation
- New cfg_qobuz table (quality/gapless/normalize_volume) and cfg_system params rsmafterqbz, qbzactive, qobuzsvc, qobuzname (ids 176-179) - FEAT_QOBUZ = 262144, default feat_bitmask 228279 -> 490423 - qobuz-installer.sh: INSTALL button downloads the prebuilt qbzd release binary (github.com/vicrodh/qbz) for the Pi architecture - ALSA hints expose _audioout/btstream to qbzd's device picker by name - Log files registered in sysutil.sh truncate list and System Config help Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- startQobuz()/stopQobuz(): fork qbzd, configure it via its control API (output device, quality, gapless, normalization, Connect name), enable Qobuz Connect; stop mirrors the other renderers (vol restore, flag reset) - chkQbzActive() in the worker poll loop (qbzd has no event-hook facility): sets qbzactive and stops MPD on a play attempt, retries a start that failed because the audio device was still busy, yields to MPD, pushes metadata/cover art on track change, resumes MPD per rsmafterqbz - Worker jobs: qobuzsvc, install_qobuz, qobuz_login (one-shot OAuth listener, URL surfaced on the Qobuz Config screen), qobuz_logout - moodeutl/sysinfo/watchdog/restart-renderer/renderer-onoff coverage identical to the other renderers (--qobuz), audioinfo Qobuz stream info Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Renderer Config: INSTALL button (mirrors AirPlay/Spotify plugin flow, ~1 minute prebuilt binary download), service on/off, friendly name, resume-MPD-after toggle, restart modal, EDIT link - New Qobuz Config screen (qbz-config.php): audio quality (MP3/CD/Hi-Res 96/Hi-Res 192), gapless, volume normalization, and Account section with browser-based Qobuz login/logout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Qobuz Active input-source overlay with disconnect button, cover art and track metadata (update_qbzmeta), identical to Deezer/Spotify - CONTRIBS attribution for qbzd (vicrodh), relnotes entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qbzd now provides an event hook facility (hooks.script / QBZD_HOOK, vicrodh/qbz#700): the daemon forks a script once per event with the event described in QBZ_* environment variables, the same contract pleezer established. Rework the renderer integration onto it, mirroring the Deezer Connect event script architecture: - New commandw/qbzevent.sh modeled on deezevent.sh: handles PlaybackStateChanged (qbzactive flag, mpc stop, 0dB volume, CDSP, multiroom), TrackStarted (metadata cache + front-end update), QconnectSessionChanged (volume restore, resume MPD after session end), PlaybackError (retry a start that failed because the audio device was still busy). - worker.php chkQbzActive() becomes flag-based like the other event-scripted renderers (no more curl polling of the control API); it retains the MPD-started-while-Qobuz-playing yield check. - startQobuz() launches qbzd with QBZD_HOOK pointing at the script. Also fixes a latent issue in the polling version where a user pause from the Qobuz app could be misread as a failed start and trigger auto-resume: failed starts are now detected via PlaybackError instead of the playing-to-paused heuristic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qbzd's pairing branch lets any Qobuz app on the LAN cast to the player without the daemon being logged in (the app hands over its own session tokens). When /api/status reports the pairing listener up, the Account status says the login is optional instead of implying it is required. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New cfg_qobuz 'pairing' param (default Yes) with a Local pairing select in Qobuz Config, mapped to qbzd's qconnect.pairing key; the config screen self-heals a cfg_qobuz predating the row so the generic UPDATE in the save handler cannot silently no-op on upgraded systems. - startQobuz() applies all qbzd settings BEFORE launching the daemon: the pairing listener, its mDNS device name, and the qconnect startup mode are read once at boot, so configuring after launch left the player advertising a stale name (and pairing toggles inert) until the next service restart. The settings CLI writes the stores directly and creates them if missing, so pre-start configuration is safe on a fresh install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qobuz-installer.sh downloads qbzd 2.0.2-pairing.1 from PhilipVinc/qbz's standalone qbzd releases (tag qbzd-v<version>, published by the fork's fork-qbzd-release.yml) while the pairing work is not yet upstream. Same tarball name and layout as the upstream releases, so only the repo, version, and tag scheme change; revert to vicrodh/qbz + v<version> once the feature lands upstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local pairing and a fixed account login are mutually exclusive in the UI: the setting now lives in the Account section, the Login button is disabled with an explanatory hint while pairing is on (POST guarded server-side too), and flipping pairing No->Yes logs the player out synchronously (plus kills any in-flight browser login) before the qobuzsvc restart job — the single-slot job queue is taken by the restart, so the logout cannot be a second job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Picks up the handoff-join-claims-active-renderer fix (casting from the app left the session's active renderer on the previous output, so the first play sat at 0:00 loading). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qbzactive deactivated on paused, so any renderUI rebuild (page load, window resize) while playback was paused dropped the Qobuz overlay and exposed the underlying panel. AirPlay/Spotify scope their active flags to the session, not the play state; do the same — pause keeps the render, stop and session-end release it, and an exhausted failed-start retry now deactivates explicitly (paused no longer does it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes for the Renderer Active overlay vanishing mid-playback: - The standalone qbzd tarball ships a systemd unit for running the daemon on its own, and its ExecStartPre clears cfg_system.qbzactive. Under moOde the worker owns the daemon lifecycle, so an enabled unit is a second instance competing for the audio device whose restart attempts blank the overlay flag every few seconds (a crash-looping unit cleared it ~every 10s on the test Pi). The installer now disables it, leaving the unit file in place. - qbzevent.sh: deactivate() verifies against the control API before tearing the render down, so a transient stopped/error event (gapless transition, a superseded stream abandoned on track change) can no longer drop the overlay while qbzd is still playing. Fail-safe: an unreachable daemon still releases the UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…seek) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eanly stopQobuz() used killall -s9, so the daemon never got to leave the Qobuz Connect session. The cloud kept a zombie renderer registered mid-playback, and the next handoff rejoined that same session and replayed the stale 'playing <old track> at <old position>' state — the app then showed the newly chosen track at 0:00 with nothing playing until the user pressed play a second time. SIGTERM first (qbzd disconnects on it), with SIGKILL after ~3s as the fallback so a wedged daemon still frees the audio device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…echo) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…both shapes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orts) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…source The screen ran 'qbzd --version', which reports only the Cargo version (2.0.2) — so a moOde fork build carrying unmerged Qobuz Connect pairing work was indistinguishable from an upstream release. The installer now records what it installed in QBZD_BUILD_FILE and the screen prefers that, falling back to the binary for installs predating the marker. Build ids move to a '.moodeN' suffix (2.0.2.moode7) to say plainly that this is a moOde build of upstream 2.0.2, and a fork build additionally renders a link to the exact branch it came from, so installed bits stay traceable while the work is pre-release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uild id Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Renderer Config screen told the user 'Not logged in to Qobuz yet — use the Edit screen to log in' whenever the daemon reported needs_auth. With Local pairing on that IS the normal state — the casting app hands the player its own session — so the message was telling people to fix something that was not broken. It now appears only when pairing is off, where a fixed-account login really is required, and says so. Added a Clear button for the stored account credentials, mirroring Spotify's credential-cache Clear (same modal-confirm shape and job plumbing). It reaches the case the Qobuz Config screen cannot: Logout only shows while logged IN, so a stale or undecryptable token file — which reports as needs_auth — left no way to clear it from the UI. The job also kills a stuck browser-login listener and truncates its log, so the screen stops offering a dead login URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…defaults Two settings users can reasonably want on a Pi are now in Qobuz Config: - Buffer (audio.stream_buffer_seconds): trades start latency against ride-through on a slow or wireless link, which is the tension a Pi over wifi actually hits. - Volume control (qconnect.volume_mode): Software lets the Qobuz app's slider attenuate the stream; Locked keeps it at full scale for bit-perfect output, with volume coming from the amp or DAC. Three settings are forced rather than exposed, because only one value is correct for a headless renderer: - audio.quality_fallback_behavior was ask, which cannot work with nobody to ask — a track the DAC cannot do at full rate had no defined outcome. Now always_fallback, with allow_quality_fallback on. - playback.persist_session / resume_playback_position: the queue belongs to the controlling app, so a restored local queue is invisible to it and surfaced as the daemon streaming a track nobody asked for after a restart. Both off. Also bumps the qbzd build to 2.0.2.moode13. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setup_renderers.txt had no mention of Qobuz Connect at all: added to the renderer/client table (cover art yes, CamillaDSP yes), to the list of renderers that can be ON simultaneously, and to the Session-based metadata paragraph. Also a short section on how its accounts differ from every other renderer here — no stored login by default, the casting app supplies the session, most recent client wins — since that is the part users will not expect. install_renderers.txt described a build-from-source process that does not apply to qbzd, so the process section, the build-time table and the log list now say which renderers they cover and note that Qobuz installs a prebuilt binary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PhilipVinc
force-pushed
the
feature/qobuz-connect-pairing
branch
from
August 20, 2026 17:05
48d60f3 to
7043397
Compare
Qobuz playback was routed through the named ALSA chain (_audioout) with qbzd left on its default CPAL backend, which opens the device at whatever rate it negotiates and resamples in software. On a hi-res track that meant a 96 kHz stream arriving at a DAC clocked at 44.1 kHz, and setting Audio Config > ALSA Options > Output mode to Direct did not help: that only repoints _audioout from plughw to hw, while the rate is still chosen by CPAL, so the conversion just moved from ALSA's plug layer into rodio. Reaching the bit-perfect path needs backend=alsa AND a device id that qbzd's is_hw_device() accepts. Those are coupled - backend=alsa with a chain name silently falls back to CPAL while still reporting "alsa" - so startQobuz() now writes them together, driven by one new setting rather than exposing the pair. Output routing offers ALSA output mode (Auto), Software and Direct. Auto takes the DAC exactly when moOde's own chain has nothing in it: Output mode Direct, Local output, no Multiroom sender, and none of alsaequal / CamillaDSP / crossfeed / eqfa12p / invert-polarity / PeppyALSA. That is the same condition under which updAudioOutAndBtOutConfs() makes _audioout a bare hw copy, so nothing is bypassed - there is nothing there to bypass. The Software branch is written explicitly, not merely left alone, or a box that once went direct would stay direct after DSP is switched on and the DSP would silently stop applying. qobuzDirectRouting() is shared by startQobuz(), which applies it, and the config page and audioinfo.php, which display it; a second copy of the predicate would drift and describe something the daemon is not doing. It reads $_SESSION through a defaulting accessor because several DSP flags do not exist as cfg_system rows on every release - 10.3.2 has no crossfeed, eqfa12p, invert_polarity or enable_peppyalsa - and an undefined key compared against 'Off' reads as ON, which would block a direct handoff for a feature that is not even installed. Volume control becomes Auto / DAC hardware volume / Software / Locked, resolved the same way: hardware volume is meaningless without a direct handoff and a DAC that has a mixer element, so the option renders disabled with the reason rather than silently doing nothing, and Auto only picks it when it is really available. Audio Information described MPD's routing for Qobuz playback, listing DSP stages that a direct handoff does not pass through. It now reports qbzd's own chain and ALSA mode when qbzd holds the DAC. Finally, the metadata hook read the output format from /api/now-playing, which is login-gated - the pairing flow never logs in, since the casting app hands over its own session - so it answered needs_auth every time and the fallback published the SOURCE container as the output. "Decoded to" claimed FLAC for a PCM stream. It now reads /api/status, which is not gated and also carries the rate the device actually runs at.
…us, ALSA-direct software volume)
Track cache shipped defaulting to stream-only, which was a silent regression: the graft never set audio.streaming_only before, so qbzd's own default applied - false, "cache tracks for instant replay" - and gapless worked. Defaulting to stream-only turned the gapless prefetch into a no-op and took gapless away from anyone upgrading, while their gapless=Yes row sat there reading as on. Sparing the SD card is a real reason to want stream-only, but it is a reason to offer the option, not to change what everyone already has. Keep it as the opt-in and let the default preserve existing behaviour.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hello,
With the help of Claude I spent the last 2 days trying to get qbzd working as a renderer inside of Moode.
To make it work in the same way as Spotify connect (no login on the moode side), and to fix several issues in how qbzd reports it's state (Buffering, loading, etc) I had to do a lot of changes to qbzd.
I will eventually try to upstream those, but as that would require a bit more time which I won't have over the next few weeks, so it's more of a longer term plan.
Moreover, there's still a few issues I'd like to address, so the code for now lives at https://github.com/PhilipVinc/qbz/tree/feature/external/qbzd-connect-pairing and I'm tagging releases at https://github.com/PhilipVinc/qbz/releases .
This PR is the moode side of things. Also claude-generated largely, with a lot of steering and manual testing to identify issues.
The implementation basically follows the same hook system as the other renderers. There's nothing fancy there (note that I did not try the multi receiver implementation).
Would you be willing to accept this PR, assuming I clean it up to standards, and assuming that I will be venturing my own fork of qbzd until I manage to upstream the changes, or that would not be acceptable to you?