Skip to content

Qobuz Connect renderer - #790

Draft
PhilipVinc wants to merge 38 commits into
moode-player:developfrom
PhilipVinc:feature/qobuz-connect-pairing
Draft

Qobuz Connect renderer#790
PhilipVinc wants to merge 38 commits into
moode-player:developfrom
PhilipVinc:feature/qobuz-connect-pairing

Conversation

@PhilipVinc

Copy link
Copy Markdown

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?

PhilipVinc and others added 30 commits August 20, 2026 19:04
- 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
PhilipVinc force-pushed the feature/qobuz-connect-pairing branch from 48d60f3 to 7043397 Compare August 20, 2026 17:05
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant