From ac3fec21c2afe57e1fd017ffebc31f8ed20a180f Mon Sep 17 00:00:00 2001 From: Filippo Vicentini Date: Tue, 18 Aug 2026 01:22:29 +0200 Subject: [PATCH 01/38] Add Qobuz Connect renderer: schema, feature bit, installer, system files - 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 --- etc/alsa/conf.d/qbzd-devices.conf | 9 +++ var/local/www/db/moode-sqlite3.db.sql | 12 +++- www/inc/constants.php | 7 ++- www/install_renderers.txt | 9 +++ www/templates/sys-config.html | 2 + www/util/qobuz-installer.sh | 82 +++++++++++++++++++++++++++ www/util/sysutil.sh | 2 + 7 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 etc/alsa/conf.d/qbzd-devices.conf create mode 100644 www/util/qobuz-installer.sh diff --git a/etc/alsa/conf.d/qbzd-devices.conf b/etc/alsa/conf.d/qbzd-devices.conf new file mode 100644 index 000000000..b5d167daa --- /dev/null +++ b/etc/alsa/conf.d/qbzd-devices.conf @@ -0,0 +1,9 @@ +######################################### +# This file is managed by moOde +######################################### +# ALSA hints so the qbzd Qobuz Connect renderer can select +# moOde's output chains by name +pcm._audioout.hint.show on +pcm._audioout.hint.description "Moode Audio Output" +pcm.btstream.hint.show on +pcm.btstream.hint.description "Moode Bluetooth Stream" diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql index 365c63eb7..60c364b21 100644 --- a/var/local/www/db/moode-sqlite3.db.sql +++ b/var/local/www/db/moode-sqlite3.db.sql @@ -256,6 +256,12 @@ INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (3, 'render INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (4, 'renderer', 'spotify-connect', 'v8-librespot', '0.8.0-1moode1'); INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (5, 'system', 'nqptp', 'v1-nqptp', '1.2.6-1moode1'); +-- Table: cfg_qobuz +CREATE TABLE cfg_qobuz (id INTEGER PRIMARY KEY, param CHAR (32), value CHAR (32)); +INSERT INTO cfg_qobuz (id, param, value) VALUES (1, 'quality', 'hires_plus'); +INSERT INTO cfg_qobuz (id, param, value) VALUES (2, 'gapless', 'Yes'); +INSERT INTO cfg_qobuz (id, param, value) VALUES (3, 'normalize_volume', 'No'); + -- Table: cfg_radio CREATE TABLE cfg_radio (id INTEGER PRIMARY KEY, station CHAR (128), name CHAR (128), type CHAR (1), logo CHAR (128), genre CHAR (32), broadcaster CHAR (32), language CHAR (32), country CHAR (32), region CHAR (32), bitrate CHAR (32), format CHAR (32), geo_fenced CHAR (3), home_page CHAR (32), monitor CHAR (32)); INSERT INTO cfg_radio (id, station, name, type, logo, genre, broadcaster, language, country, region, bitrate, format, geo_fenced, home_page, monitor) VALUES (1, 'http://strm112.1.fm/blues_mobile_mp3', '1.FM - Blues Radio', 'r', 'local', 'Blues', '1.FM', 'English', 'Switzerland', 'Europe', '192', 'MP3', 'No', '', 'No'); @@ -907,7 +913,7 @@ INSERT INTO cfg_system (id, param, value) VALUES (77, 'cardnum', '0'); INSERT INTO cfg_system (id, param, value) VALUES (78, 'btsvc', '0'); INSERT INTO cfg_system (id, param, value) VALUES (79, 'btname', 'Moode Bluetooth'); INSERT INTO cfg_system (id, param, value) VALUES (80, 'camilladsp_volume_sync', 'off'); -INSERT INTO cfg_system (id, param, value) VALUES (81, 'feat_bitmask', '228279'); +INSERT INTO cfg_system (id, param, value) VALUES (81, 'feat_bitmask', '490423'); INSERT INTO cfg_system (id, param, value) VALUES (82, 'library_recently_added', '2592000000'); INSERT INTO cfg_system (id, param, value) VALUES (83, 'btactive', '0'); INSERT INTO cfg_system (id, param, value) VALUES (84, 'deezersvc', '0'); @@ -1002,6 +1008,10 @@ INSERT INTO cfg_system (id, param, value) VALUES (172, 'library_onetouch_pl', 'S INSERT INTO cfg_system (id, param, value) VALUES (173, 'scnsaver_mode', 'Cover art'); INSERT INTO cfg_system (id, param, value) VALUES (174, 'scnsaver_layout', 'Default'); INSERT INTO cfg_system (id, param, value) VALUES (175, 'scnsaver_xmeta', 'Yes'); +INSERT INTO cfg_system (id, param, value) VALUES (176, 'rsmafterqbz', 'No'); +INSERT INTO cfg_system (id, param, value) VALUES (177, 'qbzactive', '0'); +INSERT INTO cfg_system (id, param, value) VALUES (178, 'qobuzsvc', '0'); +INSERT INTO cfg_system (id, param, value) VALUES (179, 'qobuzname', 'Moode Qobuz'); -- Table: cfg_theme CREATE TABLE cfg_theme (id INTEGER PRIMARY KEY, theme_name CHAR (32), tx_color CHAR (32), bg_color CHAR (32), mbg_color CHAR (32)); diff --git a/www/inc/constants.php b/www/inc/constants.php index fb0bb609a..4da2214eb 100755 --- a/www/inc/constants.php +++ b/www/inc/constants.php @@ -19,6 +19,8 @@ const SHAIRPORT_SYNC_LOG = '/var/log/moode_shairport-sync.log'; const LIBRESPOT_LOG = '/var/log/moode_librespot.log'; const PLEEZER_LOG = '/var/log/moode_pleezer.log'; +const QBZD_LOG = '/var/log/moode_qbzd.log'; +const QOBUZ_LOGIN_LOG = '/var/log/moode_qobuz_login.log'; const SPOTEVENT_LOG = '/var/log/moode_spotevent.log'; const DEEZEVENT_LOG = '/var/log/moode_deezevent.log'; const SPSEVENT_LOG = '/var/log/moode_spsevent.log'; @@ -54,6 +56,7 @@ const APLMETA_CACHE_FILE = '/var/local/www/aplmeta.json'; const DEEZMETA_CACHE_FILE = '/var/local/www/deezmeta.json'; const DEEZ_CREDENTIALS_FILE = '/etc/deezer/deezer.toml'; +const QBZMETA_CACHE_FILE = '/var/local/www/qbzmeta.json'; const SPOTMETA_CACHE_FILE = '/var/local/www/spotmeta.json'; const ITUNES_API_BASE_URL = 'https://itunes.apple.com/search'; // SQLite @@ -162,6 +165,7 @@ const NAME_BLUETOOTH_PAIRING_AGENT = 'Pairing Agent'; const NAME_SPOTIFY = 'Spotify Connect'; const NAME_DEEZER = 'Deezer Connect'; +const NAME_QOBUZ = 'Qobuz Connect'; const NAME_SQUEEZELITE = 'Squeezelite'; const NAME_UPNP = 'UPnP'; const NAME_DLNA = 'DLNA'; @@ -242,8 +246,9 @@ const FEAT_DEVTWEAKS = 32768; // Developer tweaks const FEAT_MULTIROOM = 65536; // y Multiroom audio const FEAT_PEPPYDISPLAY = 131072; // y Peppy display +const FEAT_QOBUZ = 262144; // y Qobuz Connect renderer // ------- -// 228279 +// 490423 // Selective resampling bitmask const SOX_UPSAMPLE_ALL = 3; // Upsample if source < target rate diff --git a/www/install_renderers.txt b/www/install_renderers.txt index 91e398901..be1d08b76 100644 --- a/www/install_renderers.txt +++ b/www/install_renderers.txt @@ -18,6 +18,15 @@ AIRPLAY AND SPOTIFY CONNECT AirPlay and Spotify Connect renderers are not included in moOde but they can be built and installed via the INSTALL button in Renderer Config. +QOBUZ CONNECT + +The Qobuz Connect renderer (qbzd) is not included in moOde but it can be +installed via the INSTALL button in Renderer Config. Unlike AirPlay and Spotify +Connect which are built from source code, the INSTALL button downloads a +prebuilt qbzd binary and thus the process is fast, typically about 1 minute. + +The full log is in the Home Directory and is named "install_qobuz.log". + INSTALLATION PROCESS Clicking the INSTALL button initiates an automated process that first builds diff --git a/www/templates/sys-config.html b/www/templates/sys-config.html index ffe2c9c8f..7d4b5525c 100644 --- a/www/templates/sys-config.html +++ b/www/templates/sys-config.html @@ -643,6 +643,8 @@

System

Auto-config/var/log/moode_update.log Spotify/var/log/moode_librespot.log AirPlay/var/log/moode_shairport-sync.log + Qobuz/var/log/moode_qbzd.log + Qobuz login/var/log/moode_qobuz_login.log Squeezelite event/var/log/moode_slpower.log Spotify event/var/log/moode_spotevent.log AirPlay event/var/log/moode_spsevent.log diff --git a/www/util/qobuz-installer.sh b/www/util/qobuz-installer.sh new file mode 100644 index 000000000..619c710cf --- /dev/null +++ b/www/util/qobuz-installer.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright 2014 The moOde audio player project / Tim Curtis +# +# Install the qbzd Qobuz Connect renderer (https://github.com/vicrodh/qbz) +# +# Unlike AirPlay/Spotify which are built from source via the pkgbuild repo, +# qbzd ships prebuilt static binaries so this installer downloads the release +# for the current architecture, verifies it and installs binary + service. +# + +QBZD_VERSION="2.0.2" +QBZD_REPO="https://github.com/vicrodh/qbz" + +# Initialize the step counter +STEP=0 +TOTAL_STEPS=4 + +# Log files +MOODE_LOG="/var/log/moode.log" +PLUGIN_LOG="/var/log/moode_plugin.log" + +cancel_update () { + if [ $# -gt 0 ] ; then + message_log "$1" + fi + message_log "** Exiting install" + exit 1 +} + +message_log () { + echo "$1" + TIME=$(date +'%Y%m%d %H%M%S') + echo "$TIME updater: $1" >> $MOODE_LOG + echo "$TIME updater: $1" >> $PLUGIN_LOG +} + +WD=$(mktemp -d) +cd $WD || cancel_update "** Unable to create work directory" +truncate $PLUGIN_LOG --size 0 +message_log "Start install for Qobuz Connect (qbzd $QBZD_VERSION)" + +# 1 - Determine architecture +STEP=$((STEP + 1)) +message_log "** Step $STEP-$TOTAL_STEPS: Determine architecture" +case "$(uname -m)" in + aarch64) ARCH="aarch64";; + x86_64) ARCH="amd64";; + *) cancel_update "** Unsupported architecture $(uname -m) (qbzd requires 64-bit)";; +esac +TARBALL="qbzd-$QBZD_VERSION-linux-$ARCH.tar.gz" + +# 2 - Download release +STEP=$((STEP + 1)) +message_log "** Step $STEP-$TOTAL_STEPS: Download $TARBALL" +wget -q "$QBZD_REPO/releases/download/v$QBZD_VERSION/$TARBALL" -O "$TARBALL" +if [ $? -ne 0 ]; then + cancel_update "** Download failed" +fi +tar -zxf "$TARBALL" +if [ $? -ne 0 ]; then + cancel_update "** Unpack failed" +fi + +# 3 - Install binary +STEP=$((STEP + 1)) +message_log "** Step $STEP-$TOTAL_STEPS: Install qbzd binary" +install -Dm755 "qbzd-$QBZD_VERSION-linux-$ARCH/qbzd" /usr/local/bin/qbzd +if [ $? -ne 0 ]; then + cancel_update "** Install failed" +fi + +# 4 - Finish up +STEP=$((STEP + 1)) +message_log "** Step $STEP-$TOTAL_STEPS: Finish up" +cd / +rm -rf $WD +systemctl daemon-reload +message_log "** Installed qbzd $(/usr/local/bin/qbzd --version | awk '{print $2}')" +message_log "Install complete: turn the renderer on in Renderer Config" +exit 0 diff --git a/www/util/sysutil.sh b/www/util/sysutil.sh index 610e4a29e..c059187ba 100755 --- a/www/util/sysutil.sh +++ b/www/util/sysutil.sh @@ -148,6 +148,8 @@ if [[ $1 = "clear-syslogs" ]]; then truncate /var/log/samba/log.smbd --size 0 truncate /var/log/moode_shairport-sync.log --size 0 truncate /var/log/moode_librespot.log --size 0 + truncate /var/log/moode_qbzd.log --size 0 + truncate /var/log/moode_qobuz_login.log --size 0 truncate /var/log/moode_mountmon.log --size 0 truncate /var/log/moode_spotevent.log --size 0 truncate /var/log/moode_spsevent.log --size 0 From 369850e8829d4c318ed7479c1f3bfbadda62ec69 Mon Sep 17 00:00:00 2001 From: Filippo Vicentini Date: Tue, 18 Aug 2026 01:22:46 +0200 Subject: [PATCH 02/38] Add Qobuz Connect renderer: daemon control and worker integration - 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 --- usr/local/bin/moodeutl | 29 +++++-- www/audioinfo.php | 13 ++- www/command/renderer.php | 5 +- www/daemon/watchdog.sh | 20 +++++ www/daemon/worker.php | 157 +++++++++++++++++++++++++++++++++- www/inc/audio.php | 4 + www/inc/autocfg.php | 12 +++ www/inc/common.php | 3 +- www/inc/peripheral.php | 4 + www/inc/renderer.php | 60 +++++++++++++ www/util/renderer-onoff.php | 18 +++- www/util/restart-renderer.php | 15 +++- www/util/send-fecmd.php | 2 +- www/util/sysinfo.sh | 16 ++++ 14 files changed, 339 insertions(+), 19 deletions(-) diff --git a/usr/local/bin/moodeutl b/usr/local/bin/moodeutl index c9569cd7f..6dc387d56 100755 --- a/usr/local/bin/moodeutl +++ b/usr/local/bin/moodeutl @@ -26,6 +26,7 @@ $features = array( FEAT_SQUEEZELITE => 'Squeezelite renderer', FEAT_UPMPDCLI => 'UPnP client for MPD', FEAT_DEEZER => 'Deezer Connect renderer', + FEAT_QOBUZ => 'Qobuz Connect renderer', FEAT_ROONBRIDGE => 'RoonBridge renderer', FEAT_LOCALDISPLAY => 'Local display', FEAT_INPSOURCE => 'Input source select', @@ -163,16 +164,17 @@ switch ($option) { echo VERSION . "\n"; break; case '--help': - //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge] + //[--bluetooth | --airplay | --spotify | --deezer | --qobuz | --upnp | --squeezelite | --plexamp | --roonbridge] $btArg = $featBitmask & FEAT_BLUETOOTH ? '--bluetooth | ' : ''; $apArg = $featBitmask & FEAT_AIRPLAY ? '--airplay | ' : ''; $spArg = $featBitmask & FEAT_SPOTIFY ? '--spotify | ' : ''; $dzArg = $featBitmask & FEAT_DEEZER ? '--deezer | ' : ''; + $qbArg = $featBitmask & FEAT_QOBUZ ? '--qobuz | ' : ''; $upArg = $featBitmask & FEAT_UPMPDCLI ? '--upnp | ' : ''; $slArg = $featBitmask & FEAT_SQUEEZELITE ? '--squeezelite | ' : ''; $paArg = $featBitmask & FEAT_PLEXAMP ? '--plexamp | ' : ''; $rbArg = $featBitmask & FEAT_ROONBRIDGE ? '--roonbridge | ' : ''; - $rendererList = rtrim($btArg . $apArg . $spArg . $dzArg . $upArg . $slArg . $paArg . $rbArg, ' | '); + $rendererList = rtrim($btArg . $apArg . $spArg . $dzArg . $qbArg . $upArg . $slArg . $paArg . $rbArg, ' | '); echo "Usage: moodeutl [OPTION] Moode utility functions @@ -530,6 +532,17 @@ function stopAllRenderers($featBitmask) { } else { echo "- deezer connect\tfeature disabled\n"; } + if ($featBitmask & FEAT_QOBUZ) { + $qobuzSvc = trim(sqlQuery("SELECT value FROM cfg_system WHERE param='qobuzsvc'")); + if ($qobuzSvc == '1') { + sysCmd('/var/www/util/restart-renderer.php --qobuz --stop'); + echo "- qobuz connect\t\tstopped\n"; + } else { + echo "- qobuz connect\t\tnot on\n"; + } + } else { + echo "- qobuz connect\t\tfeature disabled\n"; + } if ($featBitmask & FEAT_UPMPDCLI) { $upSvc = trim(sqlQuery("SELECT value FROM cfg_system WHERE param='upnpsvc'")); if ($upSvc == '1') { @@ -578,8 +591,8 @@ function stopAllRenderers($featBitmask) { function restartRenderer($argv) { $renderers = array('--bluetooth' => 'btsvc', '--airplay' => 'airplaysvc', '--spotify' => 'spotifysvc', - '--deezer' => 'deezersvc', '--upnp' => 'upnpsvc', '--squeezelite' => 'slsvc', '--plexamp' => 'pasvc', - '--roonbridge' => 'rbsvc'); + '--deezer' => 'deezersvc', '--qobuz' => 'qobuzsvc', '--upnp' => 'upnpsvc', '--squeezelite' => 'slsvc', + '--plexamp' => 'pasvc', '--roonbridge' => 'rbsvc'); if (!isset($argv[2])) { echo 'Missing 2nd argument [renderer name]' . "\n"; @@ -593,7 +606,7 @@ function restartRenderer($argv) { } } else { echo 'Invalid renderer name' . "\n"; - echo 'Valid names are: --bluetooth, --airplay, --spotify, --deezer, --upnp, --squeezelite, --plexamp, --roonbridge' . "\n"; + echo 'Valid names are: --bluetooth, --airplay, --spotify, --deezer, --qobuz, --upnp, --squeezelite, --plexamp, --roonbridge' . "\n"; return; } @@ -603,15 +616,15 @@ function restartRenderer($argv) { function rendererOnoff($argv) { $renderers = array('--bluetooth' => 'btsvc', '--airplay' => 'airplaysvc', '--spotify' => 'spotifysvc', - '--deezer' => 'deezersvc', '--upnp' => 'upnpsvc', '--squeezelite' => 'slsvc', '--plexamp' => 'pasvc', - '--roonbridge' => 'rbsvc'); + '--deezer' => 'deezersvc', '--qobuz' => 'qobuzsvc', '--upnp' => 'upnpsvc', '--squeezelite' => 'slsvc', + '--plexamp' => 'pasvc', '--roonbridge' => 'rbsvc'); if (!isset($argv[2])) { echo 'Missing 2nd argument [renderer name]' . "\n"; return; } else if (!array_key_exists($argv[2], $renderers)) { echo 'Invalid renderer name' . "\n"; - echo 'Valid names are: --bluetooth, --airplay, --spotify, --deezer, --upnp, --squeezelite, --plexamp, --roonbridge' . "\n"; + echo 'Valid names are: --bluetooth, --airplay, --spotify, --deezer, --qobuz, --upnp, --squeezelite, --plexamp, --roonbridge' . "\n"; return; } else if (!isset($argv[3])) { echo 'Missing 3nd argument [on|off]' . "\n"; diff --git a/www/audioinfo.php b/www/audioinfo.php index 0247fdede..f8da722e2 100644 --- a/www/audioinfo.php +++ b/www/audioinfo.php @@ -33,6 +33,7 @@ $aplActive = sqlQuery("SELECT value FROM cfg_system WHERE param='aplactive'", $dbh)[0]['value']; $spotActive = sqlQuery("SELECT value FROM cfg_system WHERE param='spotactive'", $dbh)[0]['value']; $deezActive = sqlQuery("SELECT value FROM cfg_system WHERE param='deezactive'", $dbh)[0]['value']; +$qbzActive = sqlQuery("SELECT value FROM cfg_system WHERE param='qbzactive'", $dbh)[0]['value']; $slActive = sqlQuery("SELECT value FROM cfg_system WHERE param='slactive'", $dbh)[0]['value']; $paActive = sqlQuery("SELECT value FROM cfg_system WHERE param='paactive'", $dbh)[0]['value']; $rbActive = sqlQuery("SELECT value FROM cfg_system WHERE param='rbactive'", $dbh)[0]['value']; @@ -75,6 +76,12 @@ $_encoded_at = $metadata['sformat']; $_decoded_to = $metadata['oformat']; $_decode_rate = ''; +} else if ($qbzActive == '1') { + $_file = 'Qobuz stream'; + $metadata = json_decode(file_get_contents(QBZMETA_CACHE_FILE), true); + $_encoded_at = $metadata['sformat']; + $_decoded_to = $metadata['oformat']; + $_decode_rate = ''; } else if ($slActive == '1') { $_file = 'Squeezelite stream'; $_encoded_at = 'Unknown'; @@ -181,6 +188,8 @@ $renderer = 'Spotify Connect → '; } else if ($deezActive == '1') { $renderer = 'Deezer Connect → '; +} else if ($qbzActive == '1') { + $renderer = 'Qobuz Connect → '; } else if ($slActive == '1') { $renderer = 'Squeezelite → '; } else if ($paActive == '1') { @@ -268,7 +277,7 @@ $cdspVol = CamillaDSP::getCDSPVol() . 'dB'; $_volume_levels = 'Knob ' . $knobVol . ', ALSA ' . $alsaVol . ', CDSP ' . $cdspVol; -if ($aplActive == '1' || $spotActive == '1' || $deezActive == '1' || $slActive == '1' || $paActive == '1' || $rbActive == '1' || +if ($aplActive == '1' || $spotActive == '1' || $deezActive == '1' || $qbzActive == '1' || $slActive == '1' || $paActive == '1' || $rbActive == '1' || $btActive === true || $_SESSION['audioout'] == 'Bluetooth' || $_SESSION['inpactive'] == '1') { // Renderer active // NOTE: Class 'off' hides the item @@ -280,7 +289,7 @@ $_replaygain = 'off'; $_vol_normalize = 'off'; - if ($aplActive == '1' || $spotActive == '1' || $deezActive == '1') { + if ($aplActive == '1' || $spotActive == '1' || $deezActive == '1' || $qbzActive == '1') { $_peq = $_SESSION['eqfa12p'] == 'Off' ? 'off' : $_SESSION['eqfa12p']; $_geq = $_SESSION['alsaequal'] == 'Off' ? 'off' : $_SESSION['alsaequal']; $_camilladsp = getCamillaDspConfigName($_SESSION['camilladsp']); diff --git a/www/command/renderer.php b/www/command/renderer.php index 760489693..e5b172f1e 100644 --- a/www/command/renderer.php +++ b/www/command/renderer.php @@ -33,7 +33,7 @@ } phpSession('close'); - // AirPlay, Spotify Connect, Deezer Connect and RoonBridge are session based, they can be restarted to effect a disconnect + // AirPlay, Spotify Connect, Deezer Connect, Qobuz Connect and RoonBridge are session based, they can be restarted to effect a disconnect // NOTE: 'disconnect_renderer' is passed to worker so MPD play can be resumed if indicated if (submitJob($_POST['job'], $_GET['cmd'])) { echo json_encode('job submitted'); @@ -49,6 +49,9 @@ case 'get_deezmeta': echo trim(file_get_contents(DEEZMETA_CACHE_FILE)); break; + case 'get_qbzmeta': + echo trim(file_get_contents(QBZMETA_CACHE_FILE)); + break; case 'get_spotmeta': echo trim(file_get_contents(SPOTMETA_CACHE_FILE)); break; diff --git a/www/daemon/watchdog.sh b/www/daemon/watchdog.sh index 7eebb4e7e..a7113b60f 100755 --- a/www/daemon/watchdog.sh +++ b/www/daemon/watchdog.sh @@ -18,6 +18,7 @@ FPM_MIN_LIMIT=32 FPM_CNT=$(pgrep -c -f "php-fpm: pool www") MPD_RUNNING=$(pgrep -c -x "mpd") SPOTIFY_RUNNING=$(pgrep -c -x "librespot") +QOBUZ_RUNNING=$(pgrep -c -x "qbzd") AIRPLAY_RUNNING=$(pgrep -c -f "LC_ALL=C /usr/bin/shairport-sync") TRX_RX_RUNNING=$(pgrep -c -x "trx-rx") @@ -123,6 +124,24 @@ while true; do fi fi + # Qobuz Connect + QOBUZ_SVC=$(sqlite3 $SQLDB "SELECT value FROM cfg_system WHERE param='qobuzsvc'") + if [[ $QOBUZ_SVC = "1" ]]; then + if [[ $QOBUZ_RUNNING = 0 ]]; then + counter=0 + while [ $counter -lt 3 ]; do + sleep 1 + QOBUZ_RUNNING=$(pgrep -c -x qbzd) + if [[ $QOBUZ_RUNNING != 0 ]]; then break; fi + ((counter++)) + done + if [[ $QOBUZ_RUNNING = 0 ]]; then + message_log "CRITICAL ERROR: Restarted Qobuz Connect after crash detected" + moodeutl -R --qobuz + fi + fi + fi + # Multiroom receiver MULTIROOM_RX=$(sqlite3 $SQLDB "SELECT value FROM cfg_system WHERE param='multiroom_rx'") if [[ $MULTIROOM_RX = "On" ]]; then @@ -182,6 +201,7 @@ while true; do FPM_CNT=$(pgrep -c -f "php-fpm: pool www") MPD_RUNNING=$(pgrep -c -x "mpd") SPOTIFY_RUNNING=$(pgrep -c -x "librespot") + QOBUZ_RUNNING=$(pgrep -c -x "qbzd") AIRPLAY_RUNNING=$(pgrep -c -f "LC_ALL=C /usr/bin/shairport-sync") TRX_RX_RUNNING=$(pgrep -c -x "trx-rx") diff --git a/www/daemon/worker.php b/www/daemon/worker.php index 5d6a8318a..9c12c14c6 100755 --- a/www/daemon/worker.php +++ b/www/daemon/worker.php @@ -187,6 +187,7 @@ sysCmd('touch ' . SHAIRPORT_SYNC_LOG); sysCmd('touch ' . LIBRESPOT_LOG); sysCmd('touch ' . PLEEZER_LOG); +sysCmd('touch ' . QBZD_LOG); sysCmd('touch ' . SPOTEVENT_LOG); sysCmd('touch ' . DEEZEVENT_LOG); sysCmd('touch ' . SPSEVENT_LOG); @@ -212,6 +213,7 @@ sysCmd('chmod 0666 ' . SHAIRPORT_SYNC_LOG); sysCmd('chmod 0666 ' . LIBRESPOT_LOG); sysCmd('chmod 0666 ' . PLEEZER_LOG); +sysCmd('chmod 0666 ' . QBZD_LOG); sysCmd('chmod 0666 ' . SPOTEVENT_LOG); sysCmd('chmod 0666 ' . DEEZEVENT_LOG); sysCmd('chmod 0666 ' . SPSEVENT_LOG); @@ -277,6 +279,7 @@ airplayname Moode AirPlay spotifyname Moode Spotify deezername Moode Deezer + qobuzname Moode Qobuz upnpname Moode UPNP dlnaname Moode DLNA squeezelite Moode In cfg_sl PLAYERNAME and squeezelite.conf, no session var @@ -296,6 +299,8 @@ phpSession('write', 'spotifyname', ucfirst($importedHostName) . ' Spotify'); // Deezer Connect phpSession('write', 'deezername', ucfirst($importedHostName) . ' Deezer'); + // Qobuz Connect + phpSession('write', 'qobuzname', ucfirst($importedHostName) . ' Qobuz'); // Squeezelite $newName = ucfirst($importedHostName); $result = sqlQuery("UPDATE cfg_sl SET value='" . $newName . "' WHERE param='PLAYERNAME'", $dbh); @@ -1160,6 +1165,23 @@ } workerLog('worker: Deezer Connect: ' . $status); +// Start Qobuz Connect renderer +if ($_SESSION['feat_bitmask'] & FEAT_QOBUZ) { + if (isQobuzInstalled()) { + if (isset($_SESSION['qobuzsvc']) && $_SESSION['qobuzsvc'] == 1) { + $status = 'started'; + startQobuz(); + } else { + $status = 'available'; + } + } else { + $status = 'not installed'; + } +} else { + $status = 'n/a'; +} +workerLog('worker: Qobuz Connect: ' . $status); + // Start Squeezelite renderer if ($_SESSION['feat_bitmask'] & FEAT_SQUEEZELITE) { if (isset($_SESSION['slsvc']) && $_SESSION['slsvc'] == 1) { @@ -1599,7 +1621,7 @@ phpSession('write', 'volknob', '0'); sysCmd('/var/www/util/vol.sh 0'); $result = sqlQuery("UPDATE cfg_system SET value='0' WHERE param='btactive' OR param='aplactive' OR - param='spotactive' OR param='deezactive' OR param='slactive' OR param='paactive' OR param='rbactive' OR + param='spotactive' OR param='deezactive' OR param='qbzactive' OR param='slactive' OR param='paactive' OR param='rbactive' OR param='inpactive'", $dbh); workerLog('worker: Active flags: at least one true'); workerLog('worker: Reset flags: all reset to false'); @@ -1701,6 +1723,9 @@ $aplactive = '0'; $spotactive = '0'; $deezactive = '0'; +$qbzactive = '0'; +$qbztrackid = ''; +$qbzretry = 0; $slactive = '0'; $paactive = '0'; $rbactive = '0'; @@ -1939,6 +1964,10 @@ //debugLog('** chkDeezActive'); chkDeezActive(); } + if ($_SESSION['qobuzsvc'] == '1') { + //debugLog('** chkQbzActive'); + chkQbzActive(); + } if ($_SESSION['slsvc'] == '1') { //debugLog('** chkSlActive'); chkSlActive(); @@ -2183,6 +2212,85 @@ function chkDeezActive() { } } } +// Qobuz Connect +function chkQbzActive() { + // qbzd has no event hook facility so poll its control API + $result = sysCmd('curl -s --max-time 2 http://127.0.0.1:8182/api/status'); + $status = json_decode(implode('', $result), true); + if (is_null($status) || !isset($status['playback']['state'])) { + return; + } + $state = $status['playback']['state']; + + if ($state == 'playing' || $state == 'loading' || $state == 'buffering') { + // Do this section only once + if ($GLOBALS['qbzactive'] == '0') { + $GLOBALS['qbzactive'] = '1'; + phpSession('write', 'qbzactive', '1'); + $GLOBALS['scnsaver_timeout'] = $_SESSION['scnsaver_timeout']; + sendFECmd('qbzactive1'); + // Take over the audio device + if (!empty(sysCmd('mpc status | grep playing')[0])) { + sysCmd('mpc stop'); + } + if ($_SESSION['alsavolume'] != 'none') { + setALSAVolTo0dB($_SESSION['alsavolume_max']); + } + // Budget for retrying a start that failed because the device was still busy + $GLOBALS['qbzretry'] = 5; + } else if ($state == 'playing' && !empty(sysCmd('mpc status | grep playing')[0])) { + // MPD was started while Qobuz is playing: yield the device + sysCmd('qbzd pause -q'); + } + // Update metadata after a track change + if ($state == 'playing' && $GLOBALS['qbztrackid'] !== $status['playback']['track_id']) { + $GLOBALS['qbztrackid'] = $status['playback']['track_id']; + updQbzMeta(); + } + } else { + if ($GLOBALS['qbzactive'] == '1') { + // A start attempt that immediately drops back to paused means qbzd could not + // open the audio device (e.g. shairport-sync releasing it late): retry + if ($state == 'paused' && $GLOBALS['qbzretry'] > 0 && empty(sysCmd('mpc status | grep playing')[0])) { + $GLOBALS['qbzretry'] = $GLOBALS['qbzretry'] - 1; + sysCmd('qbzd play -q'); + return; + } + // Do this section only once + $GLOBALS['qbzactive'] = '0'; + $GLOBALS['qbztrackid'] = ''; + phpSession('write', 'qbzactive', '0'); + sendFECmd('qbzactive0'); + sysCmd('/var/www/util/vol.sh -restore'); + if ($status['qconnect']['session_active'] === false && $_SESSION['rsmafterqbz'] == 'Yes') { + sysCmd('mpc play'); + } + } + } +} + +function updQbzMeta() { + $result = sysCmd('curl -s --max-time 2 http://127.0.0.1:8182/api/now-playing'); + $nowPlaying = json_decode(implode('', $result), true); + if (is_null($nowPlaying) || !isset($nowPlaying['track'])) { + return; + } + $track = $nowPlaying['track']; + $playback = $nowPlaying['playback']; + $metadata = array( + 'fecmd' => 'update_qbzmeta', + 'title' => $track['title'], + 'artist' => $track['artist'], + 'album' => $track['album'], + 'duration' => $track['duration_secs'], + 'cover_url' => $track['artwork_url'], + 'sformat' => 'FLAC ' . $track['bit_depth'] . '/' . $track['sample_rate'] . ' kHz', + 'oformat' => 'PCM ' . $playback['bit_depth'] . '/' . ($playback['sample_rate'] / 1000) . ' kHz' + ); + $metadataJson = json_encode($metadata, JSON_UNESCAPED_SLASHES); + file_put_contents(QBZMETA_CACHE_FILE, $metadataJson); + sysCmd('/var/www/util/send-fecmd.php ' . escapeshellarg($metadataJson)); +} // Squeezelite function chkSlActive() { // Get directly from sql since external slpower.sh script does not update the session @@ -2234,7 +2342,7 @@ function chkRbActive() { $result = sysCmd('pgrep -c mono-sgen'); if ($result[0] > 0) { $rendererNotActive = ($_SESSION['btactive'] == '0' && $GLOBALS['aplactive'] == '0' && $GLOBALS['spotactive'] == '0' - && $GLOBALS['deezactive'] == '0' && $GLOBALS['slactive'] == '0' && $_SESSION['paactive'] + && $GLOBALS['deezactive'] == '0' && $GLOBALS['qbzactive'] == '0' && $GLOBALS['slactive'] == '0' && $_SESSION['paactive'] && $_SESSION['rxactive'] == '0' && $GLOBALS['inpactive'] == '0'); $mpdNotPlaying = empty(sysCmd('mpc status | grep playing')[0]) ? true : false; $alsaOutputActive = sysCmd('cat /proc/asound/card' . $_SESSION['cardnum'] . '/pcm0p/sub0/hw_params')[0] == 'closed' ? false : true; @@ -2573,6 +2681,8 @@ function updExtMetaFile() { $renderer = 'Spotify Active'; } else if ($GLOBALS['deezactive'] == '1') { $renderer = 'Deezer Active'; + } else if ($GLOBALS['qbzactive'] == '1') { + $renderer = 'Qobuz Active'; } else if ($GLOBALS['slactive'] == '1') { $renderer = 'Squeezelite Active'; } else if ($GLOBALS['rbactive'] == '1') { @@ -3026,6 +3136,10 @@ function runQueuedJob() { stopDeezer(); startDeezer(); } + if ($_SESSION['qobuzsvc'] == 1) { + stopQobuz(); + startQobuz(); + } if ($_SESSION['slsvc'] == 1) { stopSqueezelite(); cfgSqueezelite(); @@ -3088,6 +3202,10 @@ function runQueuedJob() { stopDeezer(); startDeezer(); } + if ($_SESSION['qobuzsvc'] == 1) { + stopQobuz(); + startQobuz(); + } if ($_SESSION['slsvc'] == 1) { stopSqueezelite(); cfgSqueezelite(); @@ -3217,6 +3335,10 @@ function runQueuedJob() { stopDeezer(); startDeezer(); } + if ($_SESSION['qobuzsvc'] == 1) { + stopQobuz(); + startQobuz(); + } // Reenable HTTP server (if indicated) setMpdHttpd(); @@ -3310,6 +3432,33 @@ function runQueuedJob() { sysCmd('mpc play'); } break; + case 'qobuzsvc': + stopQobuz(); + if ($_SESSION['qobuzsvc'] == 1) { + startQobuz(); + } + + if ($_SESSION['w_queueargs'] == 'disconnect_renderer' && $_SESSION['rsmafterqbz'] == 'Yes') { + sysCmd('mpc play'); + } + break; + case 'install_qobuz': + $fullLog = $_SESSION['home_dir'] . '/install_qobuz.log'; + sysCmd('rm "' . $fullLog . '"'); + sysCmd('/var/www/util/qobuz-installer.sh > "' . $fullLog . '" 2>&1 &'); + break; + case 'qobuz_login': + // One-shot OAuth listener: prints the login URL to the log then waits + // for the browser callback. The URL is shown on the Qobuz Config screen. + // NOTE: exec -a requires bash (sysCmd runs sh) and sets argv[0] so use pkill -f + sysCmd('pkill -f qbzd-login 2> /dev/null'); + $ipAddr = sysCmd("hostname -I | awk '{print $1}'")[0]; + sysCmd('truncate ' . QOBUZ_LOGIN_LOG . ' --size 0'); + sysCmd("bash -c 'exec -a qbzd-login qbzd login --callback-host " . $ipAddr . ' > ' . QOBUZ_LOGIN_LOG . " 2>&1 &'"); + break; + case 'qobuz_logout': + sysCmd('qbzd logout'); + break; case 'slsvc': if ($_SESSION['slsvc'] == '1') { cfgSqueezelite(); @@ -3414,6 +3563,10 @@ function runQueuedJob() { stopDeezer(); startDeezer(); } + if ($_SESSION['qobuzsvc'] == 1) { + stopQobuz(); + startQobuz(); + } break; case 'multiroom_tx_restart': stopMultiroomSender(); diff --git a/www/inc/audio.php b/www/inc/audio.php index faa38a9d5..4fa5e8893 100644 --- a/www/inc/audio.php +++ b/www/inc/audio.php @@ -188,6 +188,10 @@ function setAudioOut($output) { stopDeezer(); startDeezer(); } + if ($_SESSION['qobuzsvc'] == '1') { + stopQobuz(); + startQobuz(); + } // Set HTTP server state setMpdHttpd(); diff --git a/www/inc/autocfg.php b/www/inc/autocfg.php index 934d352d7..f8435fc06 100644 --- a/www/inc/autocfg.php +++ b/www/inc/autocfg.php @@ -109,6 +109,7 @@ function getCfgTableParams($table, $values, $prefix = '') { ['requires' => ['airplayname'], 'handler' => 'setSessVarSql'], ['requires' => ['spotifyname'], 'handler' => 'setSessVarSql'], ['requires' => ['deezername'], 'handler' => 'setSessVarSql'], + ['requires' => ['qobuzname'], 'handler' => 'setSessVarSql'], ['requires' => ['squeezelitename'], 'handler' => function($values) { $dbh = sqlConnect(); $currentName= sqlQuery("select value from cfg_sl where param='PLAYERNAME'", $dbh)[0]['value']; @@ -465,6 +466,8 @@ function getCfgTableParams($table, $values, $prefix = '') { ['requires' => ['rsmafterspot'], 'handler' => 'setSessVarSql'], ['requires' => ['deezersvc'], 'handler' => 'setSessVarSql'], ['requires' => ['rsmafterdeez'], 'handler' => 'setSessVarSql'], + ['requires' => ['qobuzsvc'], 'handler' => 'setSessVarSql'], + ['requires' => ['rsmafterqbz'], 'handler' => 'setSessVarSql'], ['requires' => ['slsvc'], 'handler' => 'setSessVarSql'], ['requires' => ['rsmaftersl'], 'handler' => 'setSessVarSql'], ['requires' => ['pasvc'], 'handler' => 'setSessVarSql'], @@ -546,6 +549,15 @@ function getCfgTableParams($table, $values, $prefix = '') { }, 'custom_write' => function($values) { return getCfgTableParams('cfg_deezer', $values, 'deezer_'); }], + 'Qobuz Connect', + ['requires' => ['qobuz_quality'], + 'optionals' => ['qobuz_gapless', 'qobuz_normalize_volume'], + 'handler' => function($values, $optionals) { + $mergedValues = array_merge($values, $optionals); + setCfgTableParams('cfg_qobuz', $mergedValues, 'qobuz_'); + }, 'custom_write' => function($values) { + return getCfgTableParams('cfg_qobuz', $values, 'qobuz_'); + }], 'Squeezelite', ['requires' => ['squeezelite_PLAYERNAME', 'squeezelite_AUDIODEVICE', 'squeezelite_ALSAPARAMS', 'squeezelite_OUTPUTBUFFERS', 'squeezelite_TASKPRIORITY', 'squeezelite_CODECS', 'squeezelite_OTHEROPTIONS'], diff --git a/www/inc/common.php b/www/inc/common.php index 8ab8dc064..f0f649fe8 100755 --- a/www/inc/common.php +++ b/www/inc/common.php @@ -321,7 +321,7 @@ function parseDelimFile($data, $delim) { // Determine if a renderer is active function chkRendererActive() { $result = sqlQuery("SELECT value from cfg_system WHERE param in ( - 'btactive', 'aplactive', 'spotactive', 'deezactive', 'slactive', 'paactive', + 'btactive', 'aplactive', 'spotactive', 'deezactive', 'qbzactive', 'slactive', 'paactive', 'rbactive', 'inpactive')", sqlConnect()); $active = false; @@ -419,6 +419,7 @@ function storeBackLink($section, $tpl) { 'eqg-config.html' => '/snd-config.php', 'eqp-config.html' => '/snd-config.php', 'gpio-config.html' => '/per-config.php', + 'qbz-config.html' => '/ren-config.php', 'spo-config.html' => '/ren-config.php', 'sqe-config.html' => '/ren-config.php', 'lib-nas-config.html' => '/lib-config.php', diff --git a/www/inc/peripheral.php b/www/inc/peripheral.php index 426481c4e..0b6dbf3bf 100755 --- a/www/inc/peripheral.php +++ b/www/inc/peripheral.php @@ -62,6 +62,10 @@ function restartMpdAndRenderers($resetAlsaCtl) { stopDeezer(); startDeezer(); } + if ($_SESSION['qobuzsvc'] == 1) { + stopQobuz(); + startQobuz(); + } } function allowPeppyInAlsaChain() { // NOTE: MPD cant play ALSA chain: _audioout -> [alsaequal or eqfa12p] -> peppy -> btstream diff --git a/www/inc/renderer.php b/www/inc/renderer.php index 24b7f6423..e31716567 100644 --- a/www/inc/renderer.php +++ b/www/inc/renderer.php @@ -329,6 +329,65 @@ function updateDeezCredentials($email, $password) { fclose($fh); } +// Qobuz Connect +function startQobuz() { + $result = sqlRead('cfg_qobuz', sqlConnect()); + $cfgQobuz = array(); + foreach ($result as $row) { + $cfgQobuz[$row['param']] = $row['value']; + } + + // Output device: ALSA hint names defined in /etc/alsa/conf.d/qbzd-devices.conf + $device = $_SESSION['audioout'] == 'Local' ? 'Moode Audio Output' : 'Moode Bluetooth Stream'; + + // Logging + $logging = $_SESSION['debuglog'] == '1' ? ' > ' . QBZD_LOG : ' > /dev/null'; + + // Start the daemon then configure it via its control API + $cmd = 'qbzd run' . $logging . ' 2>&1 &'; + debugLog('startQobuz(): (' . $cmd . ')'); + sysCmd($cmd); + + // Wait for the control API to come up (up to 5 secs) + for ($i = 0; $i < 10; $i++) { + usleep(500000); + $result = sysCmd('curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://127.0.0.1:8182/api/status'); + if (!empty($result) && $result[0] == '200') { + break; + } + } + + // Apply settings (persisted to disk by qbzd) + sysCmd('qbzd settings set audio.device "' . $device . '"'); + sysCmd('qbzd settings set playback.quality ' . $cfgQobuz['quality']); + sysCmd('qbzd settings set audio.gapless_enabled ' . ($cfgQobuz['gapless'] == 'Yes' ? 'true' : 'false')); + sysCmd('qbzd settings set audio.normalization_enabled ' . ($cfgQobuz['normalize_volume'] == 'Yes' ? 'true' : 'false')); + sysCmd('qbzd settings set playback.mpris false'); + sysCmd('qbzd settings set qconnect.device_name "' . $_SESSION['qobuzname'] . '"'); + sysCmd('qbzd qconnect enable'); +} +function stopQobuz() { + sysCmd('killall -s9 qbzd'); + + // Local + sysCmd('/var/www/util/vol.sh -restore'); + if (CamillaDSP::isMPD2CamillaDSPVolSyncEnabled()) { + sysCmd('systemctl restart mpd2cdspvolume'); + } + // Multiroom receivers + if ($_SESSION['multiroom_tx'] == "On" ) { + updReceiverVol('-restore'); + } + + phpSession('write', 'qbzactive', '0'); + $GLOBALS['qbzactive'] = '0'; + sendFECmd('qbzactive0'); +} +function isQobuzInstalled() { + $result = sysCmd('which qbzd'); + return empty($result) ? false : true; +} + // UPnP function startUPnP() { sysCmd('systemctl start upmpdcli'); @@ -404,6 +463,7 @@ function stopAllRenderers() { 'airplaysvc' => 'stopAirPlay', 'spotifysvc' => 'stopSpotify', 'deezersvc' => 'stopDeezer', + 'qobuzsvc' => 'stopQobuz', 'upnpsvc' => 'stopUPnP', 'slsvc' => 'stopSqueezeLite', 'pasvc' => 'stopPlexamp', diff --git a/www/util/renderer-onoff.php b/www/util/renderer-onoff.php index 5b2edffaa..8a4447665 100755 --- a/www/util/renderer-onoff.php +++ b/www/util/renderer-onoff.php @@ -31,6 +31,9 @@ case '--deezer': onoffDeezer($onoff); break; + case '--qobuz': + onoffQobuz($onoff); + break; case '--squeezelite': onoffSqueezelite($onoff); break; @@ -48,16 +51,17 @@ fwrite(STDERR, "This command requires sudo to print the help\n"); return; } - //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge] + //[--bluetooth | --airplay | --spotify | --deezer | --qobuz | --upnp | --squeezelite | --plexamp | --roonbridge] $btArg = $_SESSION['feat_bitmask'] & FEAT_BLUETOOTH ? "--bluetooth\tTurn Bluetooth On/Off\n" : ""; $apArg = $_SESSION['feat_bitmask'] & FEAT_AIRPLAY ? " --airplay\tTurn AirPlay On/Off\n" : ""; $spArg = $_SESSION['feat_bitmask'] & FEAT_SPOTIFY ? " --spotify\tTurn Spotify Connect On/Off\n" : ""; $dzArg = $_SESSION['feat_bitmask'] & FEAT_DEEZER ? " --deezer\tTurn Deezer ConnectOn/Off\n" : ""; + $qbArg = $_SESSION['feat_bitmask'] & FEAT_QOBUZ ? " --qobuz\tTurn Qobuz Connect On/Off\n" : ""; $upArg = $_SESSION['feat_bitmask'] & FEAT_UPMPDCLI ? " --upnp\t\tTurn UPnP On/Off\n" : ""; $slArg = $_SESSION['feat_bitmask'] & FEAT_SQUEEZELITE ? " --squeezelite\tTurn Squeezelite On/Off\n" : ""; $paArg = $_SESSION['feat_bitmask'] & FEAT_PLEXAMP ? " --plexamp\tTurn Plexamp On/Off\n" : ""; $rbArg = $_SESSION['feat_bitmask'] & FEAT_ROONBRIDGE ? " --roonbridge\tTurn RoonBridge On/Off\n" : ""; - $rendererList = ' '. $btArg . $apArg . $spArg . $dzArg . $upArg . $slArg . $paArg . $rbArg . + $rendererList = ' '. $btArg . $apArg . $spArg . $dzArg . $qbArg . $upArg . $slArg . $paArg . $rbArg . " --help\t\tPrint this help text\n"; echo "Usage: renderer-onoff [OPTION] [on|off] @@ -123,6 +127,16 @@ function onoffDeezer($onoff) { } } +function onoffQobuz($onoff) { + if ($onoff == 'on' && $_SESSION['qobuzsvc'] == '0') { + phpSession('write', 'qobuzsvc', '1'); + startQobuz(); + } else if ($onoff == 'off' && $_SESSION['qobuzsvc'] == '1') { + phpSession('write', 'qobuzsvc', '0'); + stopQobuz(); + } +} + function onoffUPnP($onoff) { if ($onoff == 'on' && $_SESSION['upnpsvc'] == '0') { phpSession('write', 'upnpsvc', '1'); diff --git a/www/util/restart-renderer.php b/www/util/restart-renderer.php index 845f1675b..fdd539b1b 100755 --- a/www/util/restart-renderer.php +++ b/www/util/restart-renderer.php @@ -33,6 +33,9 @@ case '--deezer': restartDeezer($stopOnly); break; + case '--qobuz': + restartQobuz($stopOnly); + break; case '--squeezelite': restartSqueezelite($stopOnly); break; @@ -50,16 +53,17 @@ fwrite(STDERR, "This command requires sudo to print the help\n"); return; } - //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge] + //[--bluetooth | --airplay | --spotify | --deezer | --qobuz | --upnp | --squeezelite | --plexamp | --roonbridge] $btArg = $_SESSION['feat_bitmask'] & FEAT_BLUETOOTH ? "--bluetooth\tRestart Bluetooth\n" : ""; $apArg = $_SESSION['feat_bitmask'] & FEAT_AIRPLAY ? " --airplay\tRestart AirPlay\n" : ""; $spArg = $_SESSION['feat_bitmask'] & FEAT_SPOTIFY ? " --spotify\tRestart Spotify Connect\n" : ""; $dzArg = $_SESSION['feat_bitmask'] & FEAT_DEEZER ? " --deezer\tRestart Deezer Connect\n" : ""; + $qbArg = $_SESSION['feat_bitmask'] & FEAT_QOBUZ ? " --qobuz\tRestart Qobuz Connect\n" : ""; $upArg = $_SESSION['feat_bitmask'] & FEAT_UPMPDCLI ? " --upnp\t\tRestart UPnP\n" : ""; $slArg = $_SESSION['feat_bitmask'] & FEAT_SQUEEZELITE ? " --squeezelite\tRestart Squeezelite\n" : ""; $paArg = $_SESSION['feat_bitmask'] & FEAT_PLEXAMP ? " --plexamp\tRestart Plexamp\n" : ""; $rbArg = $_SESSION['feat_bitmask'] & FEAT_ROONBRIDGE ? " --roonbridge\tRestart RoonBridge\n" : ""; - $rendererList = ' '. $btArg . $apArg . $spArg . $dzArg . $upArg . $slArg . $paArg . $rbArg . + $rendererList = ' '. $btArg . $apArg . $spArg . $dzArg . $qbArg . $upArg . $slArg . $paArg . $rbArg . " --help\t\tPrint this help text\n"; echo "Usage: restart-renderer [OPTION] [--stop] @@ -113,6 +117,13 @@ function restartDeezer($stopOnly) { } } +function restartQobuz($stopOnly) { + stopQobuz(); + if ($stopOnly === false) { + startQobuz(); + } +} + function restartUPnP($stopOnly) { stopUPnP(); if ($stopOnly === false) { diff --git a/www/util/send-fecmd.php b/www/util/send-fecmd.php index 8c298a969..8c1b28be0 100644 --- a/www/util/send-fecmd.php +++ b/www/util/send-fecmd.php @@ -17,7 +17,7 @@ "Usage: send-fecmd.php [command] Send a command to the front-end\n"; } else { - if (str_contains($argv[1], 'update_deezmeta') || str_contains($argv[1], 'update_spotmeta')) { + if (str_contains($argv[1], 'update_deezmeta') || str_contains($argv[1], 'update_qbzmeta') || str_contains($argv[1], 'update_spotmeta')) { // Special handling for certain commands $cmd = htmlspecialchars($argv[1], ENT_NOQUOTES); sendFECmd($cmd); diff --git a/www/util/sysinfo.sh b/www/util/sysinfo.sh index 5d3cb0c9f..2c1104793 100755 --- a/www/util/sysinfo.sh +++ b/www/util/sysinfo.sh @@ -126,6 +126,9 @@ AUDIO_PARAMETERS() { if [ $(($feat_bitmask & $FEAT_DEEZER)) -ne 0 ]; then echo -e "\nDeezer Connect\t\t= $deezersvc\c" fi + if [ $(($feat_bitmask & $FEAT_QOBUZ)) -ne 0 ]; then + echo -e "\nQobuz Connect\t\t= $qobuzsvc\c" + fi if [ $(($feat_bitmask & $FEAT_SQUEEZELITE)) -ne 0 ]; then echo -e "\nSqueezelite\t\t= $slsvc\c" fi @@ -375,6 +378,14 @@ RENDERER_SETTINGS() { echo -e "\nResume MPD\t\t= $rsmafterdeez\n" fi + if [ $(($feat_bitmask & $FEAT_QOBUZ)) -ne 0 ]; then + QBZVER="$(qbzd --version | awk -F" " '{print $2}')" + echo -e "Q O B U Z C O N N E C T" + echo -e "\nVersion\t\t\t= $QBZVER\c" + echo -e "\nFriendly name\t\t= $qobuzname\c" + echo -e "\nResume MPD\t\t= $rsmafterqbz\n" + fi + if [ $(($feat_bitmask & $FEAT_SQUEEZELITE)) -ne 0 ]; then SL=$(squeezelite -? | grep "Squeezelite" | awk '{print $2}' | cut -d "," -f1) squeezelite -? | grep "\-Z" >/dev/null && SLT="\"DSD/SRC enabled\"" || SLT="\"DSD/SRC disabled\"" @@ -471,6 +482,7 @@ FEAT_PLEXAMP=8192 FEAT_BLUETOOTH=16384 FEAT_MULTIROOM=65536 FEAT_PEPPYDISPLAY=131072 +FEAT_QOBUZ=262144 # Selective resampling bitmask SOX_UPSAMPLE_ALL=3 # Upsample if source < target rate @@ -941,6 +953,10 @@ library_onetouch_pl=${arr[171]} scnsaver_mode=${arr[172]} scnsaver_layout=${arr[173]} scnsaver_xmeta=${arr[174]} +rsmafterqbz=${arr[175]} +qbzactive=${arr[176]} +[[ "${arr[177]}" = "1" ]] && qobuzsvc="On" || qobuzsvc="Off" +qobuzname=${arr[178]} # Session only vars timezone=$(moodeutl -d -gv timezone) value=$(moodeutl -d -gv rotaryenc) From 8a6218cf6005103f83435ce80fd4865d9fbd2742 Mon Sep 17 00:00:00 2001 From: Filippo Vicentini Date: Tue, 18 Aug 2026 01:22:46 +0200 Subject: [PATCH 03/38] Add Qobuz Connect renderer: Renderer Config section and settings screen - 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 --- www/qbz-config.php | 83 +++++++++++++++++++++++++++++++++++ www/ren-config.php | 58 ++++++++++++++++++++++++ www/templates/qbz-config.html | 79 +++++++++++++++++++++++++++++++++ www/templates/ren-config.html | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 www/qbz-config.php create mode 100644 www/templates/qbz-config.html diff --git a/www/qbz-config.php b/www/qbz-config.php new file mode 100644 index 000000000..c7fe6d584 --- /dev/null +++ b/www/qbz-config.php @@ -0,0 +1,83 @@ + $value) { + chkValue($key, $value); + sqlUpdate('cfg_qobuz', $dbh, $key, $value); + } + $notify = $_SESSION['qobuzsvc'] == '1' ? + array('title' => NOTIFY_TITLE_INFO, 'msg' => NAME_QOBUZ . NOTIFY_MSG_SVC_RESTARTED) : + array('title' => '', 'msg' => ''); + submitJob('qobuzsvc', '', $notify['title'], $notify['msg']); +} +if (isset($_POST['qobuz_login']) && $_POST['qobuz_login'] == '1') { + submitJob('qobuz_login', '', NOTIFY_TITLE_INFO, 'Login started, refresh this screen to view the login link'); +} +if (isset($_POST['qobuz_logout']) && $_POST['qobuz_logout'] == '1') { + submitJob('qobuz_logout', '', NOTIFY_TITLE_INFO, 'Logged out from Qobuz'); +} + +phpSession('close'); + +$result = sqlRead('cfg_qobuz', $dbh); +$cfgQobuz = array(); + +foreach ($result as $row) { + $cfgQobuz[$row['param']] = $row['value']; +} + +$_select['quality'] .= "\n"; +$_select['quality'] .= "\n"; +$_select['quality'] .= "\n"; +$_select['quality'] .= "\n"; +$_select['gapless'] .= "\n"; +$_select['gapless'] .= "\n"; +$_select['normalize_volume'] .= "\n"; +$_select['normalize_volume'] .= "\n"; + +// Account: login status is read from the qbzd control API (only available when the service is on) +$_qobuz_login_hide = 'hide'; +$_qobuz_logout_hide = 'hide'; +$_qobuz_login_url_msg = ''; +if ($_SESSION['qobuzsvc'] == '1') { + $result = sysCmd('curl -s --max-time 2 http://127.0.0.1:8182/api/status'); + $status = json_decode(implode('', $result), true); + if (isset($status['auth']['state']) && $status['auth']['state'] != 'needs_auth') { + $_qobuz_logout_hide = ''; + $_qobuz_account_status = 'Logged in' . + (empty($status['auth']['subscription']) ? '' : ' (' . $status['auth']['subscription'] . ')'); + } else { + $_qobuz_login_hide = ''; + $_qobuz_account_status = 'Not logged in'; + // A login in progress prints its URL to the login log + $loginLog = file_exists(QOBUZ_LOGIN_LOG) ? file_get_contents(QOBUZ_LOGIN_LOG) : ''; + if (preg_match('#https?://\S+#', $loginLog, $matches) === 1) { + $_qobuz_login_url_msg = '' . + '' . $matches[0] . '
' . + 'Open this link in your browser to complete the Qobuz login, then refresh this screen.
'; + } + } +} else { + $_qobuz_account_status = 'Turn the renderer on in Renderer Config to log in.'; +} + +waitWorker('qbz_config'); + +$tpl = "qbz-config.html"; +$section = basename(__FILE__, '.php'); +storeBackLink($section, $tpl); + +include('header.php'); +eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");"); +include('footer.php'); diff --git a/www/ren-config.php b/www/ren-config.php index 45e4ce050..3b158d52c 100644 --- a/www/ren-config.php +++ b/www/ren-config.php @@ -141,6 +141,31 @@ submitJob('deezersvc', '', NOTIFY_TITLE_INFO, NAME_DEEZER . NOTIFY_MSG_SVC_MANUAL_RESTART); } +// Qobuz Connect +if (isset($_POST['install_qobuz'])) { + submitJob('install_qobuz'); + header('location: ren-status.php'); +} +if (isset($_POST['update_qobuz_settings'])) { + if (isset($_POST['qobuzname']) && $_POST['qobuzname'] != $_SESSION['qobuzname']) { + $update = true; + phpSession('write', 'qobuzname', $_POST['qobuzname']); + } + if (isset($_POST['qobuzsvc']) && $_POST['qobuzsvc'] != $_SESSION['qobuzsvc']) { + $update = true; + phpSession('write', 'qobuzsvc', $_POST['qobuzsvc']); + } + if (isset($update)) { + submitJob('qobuzsvc'); + } +} +if (isset($_POST['update_rsmafterqbz'])) { + phpSession('write', 'rsmafterqbz', $_POST['rsmafterqbz']); +} +if (isset($_POST['qobuzrestart']) && $_POST['qobuzrestart'] == 1 && $_SESSION['qobuzsvc'] == '1') { + submitJob('qobuzsvc', '', NOTIFY_TITLE_INFO, NAME_QOBUZ . NOTIFY_MSG_SVC_MANUAL_RESTART); +} + // UPnP client for MPD if (isset($_POST['update_upnp_settings'])) { $currentUpnpName = $_SESSION['upnpname']; @@ -343,6 +368,39 @@ $_select['rsmafterdeez_on'] .= "\n"; $_select['rsmafterdeez_off'] .= "\n"; +// Qobuz Connect +$_feat_qobuz = $_SESSION['feat_bitmask'] & FEAT_QOBUZ ? '' : 'hide'; +if (isQobuzInstalled() === true) { + $_install_qobuz_hide = 'hide'; + $_qobuz_version_hide = ''; + $_qobuz_installed_version = 'Version: ' . sysCmd('qbzd --version | awk \'{print $2}\'')[0]; + $_qobuz_svcbtn_disable = ''; + $_qobuz_editlink_disable = ''; +} else { + $_install_qobuz_hide = ''; + $_qobuz_version_hide = 'hide'; + $_qobuz_installed_version = ''; + $_qobuz_svcbtn_disable = 'disabled'; + $_qobuz_editlink_disable = 'onclick="return false;"'; +} +if ($_SESSION['qobuzsvc'] == '1') { + $result = sysCmd('curl -s --max-time 2 http://127.0.0.1:8182/api/status'); + $status = json_decode(implode('', $result), true); + $_qobuz_login_msg = (isset($status['auth']['state']) && $status['auth']['state'] == 'needs_auth') ? + 'Not logged in to Qobuz yet — use the Edit screen to log in' : ''; +} else { + $_qobuz_login_msg = ''; +} +$_SESSION['qobuzsvc'] == '1' ? $_qobuz_btn_disable = '' : $_qobuz_btn_disable = 'disabled'; +$_SESSION['qobuzsvc'] == '1' ? $_qobuz_link_disable = '' : $_qobuz_link_disable = 'onclick="return false;"'; +$autoClick = " onchange=\"autoClick('#btn-set-qobuzsvc');\" " . $_qobuz_svcbtn_disable; +$_select['qobuzsvc_on'] .= "\n"; +$_select['qobuzsvc_off'] .= "\n"; +$_select['qobuzname'] = $_SESSION['qobuzname']; +$autoClick = " onchange=\"autoClick('#btn-set-rsmafterqbz');\" " . $_qobuz_btn_disable; +$_select['rsmafterqbz_on'] .= "\n"; +$_select['rsmafterqbz_off'] .= "\n"; + // UPnP client for MPD $_feat_upmpdcli = $_SESSION['feat_bitmask'] & FEAT_UPMPDCLI ? '' : 'hide'; $_SESSION['upnpsvc'] == '1' ? $_upnp_btn_disable = '' : $_upnp_btn_disable = 'disabled'; diff --git a/www/templates/qbz-config.html b/www/templates/qbz-config.html new file mode 100644 index 000000000..d9c3da8a4 --- /dev/null +++ b/www/templates/qbz-config.html @@ -0,0 +1,79 @@ + +
+
+

Qobuz Connect

+ + + +
+ Settings + + + +
+ +
General
+ + +
+ + + + Maximum streaming quality. Tracks not available at the selected quality are streamed at the highest quality available for your subscription. + +
+ + +
+ + + + Play consecutive tracks without a gap of silence between them. + +
+ + +
+ + + + Adjusts the volume of all songs played so that they sound as though they are of equal loudness. + +
+ +
Account
+ + +
+ $_qobuz_account_status +
+ + + +
+
+ + Qobuz account +
+
+
+
+
+
diff --git a/www/templates/ren-config.html b/www/templates/ren-config.html index 6094a6b32..4e99583bb 100644 --- a/www/templates/ren-config.html +++ b/www/templates/ren-config.html @@ -290,6 +290,70 @@

Renderers

$_deezer_credentials_msg + +
+ Qobuz Connect + +
+ +
+ + + This renderer is not installed. View the Installation guide for more information. + +
+
+ +
+ +
+ $_qobuz_installed_version +
+
+ + +
+
+ $_select[qobuzsvc_on] + $_select[qobuzsvc_off] +
+ + + + qbzd by vicrodh. + +
+ + +
+ + +
+ + +
+
+ $_select[rsmafterqbz_on] + $_select[rsmafterqbz_off] +
+ + + + Resume MPD playback after Qobuz client stops playing or disconnects. + +
+ +
+ + Qobuz Connect +
+ +
+ + Qobuz Connect settings + $_qobuz_login_msg +
+
UPnP Client for MPD

This service functions as a UPnP media renderer that uses MPD for playback.

@@ -553,6 +617,19 @@

Restart Deezer connect renderer?

+
+ +
+
UPnP Client for MPD @@ -602,6 +607,20 @@

Clear credential cache?

+ +
From 31684b50910d26fa9681fb3cbc97f6c3bf7633ac Mon Sep 17 00:00:00 2001 From: Filippo Vicentini Date: Thu, 20 Aug 2026 15:55:47 +0200 Subject: [PATCH 26/38] Qobuz Connect: expose Buffer and Volume control, force headless-sane defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- var/local/www/db/moode-sqlite3.db.sql | 2 ++ www/inc/renderer.php | 14 ++++++++++++++ www/qbz-config.php | 19 ++++++++++++++----- www/templates/qbz-config.html | 26 ++++++++++++++++++++++++++ www/util/qobuz-installer.sh | 2 +- 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql index 5efcad7a1..f119498f8 100644 --- a/var/local/www/db/moode-sqlite3.db.sql +++ b/var/local/www/db/moode-sqlite3.db.sql @@ -262,6 +262,8 @@ INSERT INTO cfg_qobuz (id, param, value) VALUES (1, 'quality', 'hires_plus'); INSERT INTO cfg_qobuz (id, param, value) VALUES (2, 'gapless', 'Yes'); INSERT INTO cfg_qobuz (id, param, value) VALUES (3, 'normalize_volume', 'No'); INSERT INTO cfg_qobuz (id, param, value) VALUES (4, 'pairing', 'Yes'); +INSERT INTO cfg_qobuz (id, param, value) VALUES (5, 'buffer_seconds', '2'); +INSERT INTO cfg_qobuz (id, param, value) VALUES (6, 'volume_mode', 'software'); -- Table: cfg_radio CREATE TABLE cfg_radio (id INTEGER PRIMARY KEY, station CHAR (128), name CHAR (128), type CHAR (1), logo CHAR (128), genre CHAR (32), broadcaster CHAR (32), language CHAR (32), country CHAR (32), region CHAR (32), bitrate CHAR (32), format CHAR (32), geo_fenced CHAR (3), home_page CHAR (32), monitor CHAR (32)); diff --git a/www/inc/renderer.php b/www/inc/renderer.php index ac94c1bf5..42d2850f4 100644 --- a/www/inc/renderer.php +++ b/www/inc/renderer.php @@ -354,6 +354,20 @@ function startQobuz() { sysCmd('qbzd settings set playback.mpris false'); sysCmd('qbzd settings set qconnect.device_name "' . $_SESSION['qobuzname'] . '"'); sysCmd('qbzd settings set qconnect.pairing ' . (($cfgQobuz['pairing'] ?? 'Yes') == 'Yes' ? 'on' : 'off')); + sysCmd('qbzd settings set audio.stream_buffer_seconds ' . ($cfgQobuz['buffer_seconds'] ?? '2')); + sysCmd('qbzd settings set qconnect.volume_mode ' . ($cfgQobuz['volume_mode'] ?? 'software')); + // Non-interactive quality fallback: the stock value is "ask", which cannot + // work on a headless box — there is nobody to answer, so a track the DAC + // cannot do at full rate has no defined outcome. Play it at a supported + // rate instead of failing. + sysCmd('qbzd settings set audio.allow_quality_fallback true'); + sysCmd('qbzd settings set audio.quality_fallback_behavior always_fallback'); + // Do not restore a local queue on start. As a Connect renderer the queue + // belongs to the controlling app; a restored one is invisible to it and + // surfaced as the daemon spontaneously streaming a track nobody asked for + // after a restart. + sysCmd('qbzd settings set playback.persist_session false'); + sysCmd('qbzd settings set playback.resume_playback_position false'); sysCmd('qbzd qconnect enable'); // QBZD_HOOK: qbzd forks the script once per daemon event (QBZ_* env vars) diff --git a/www/qbz-config.php b/www/qbz-config.php index c2feb6182..ad2ca36ac 100644 --- a/www/qbz-config.php +++ b/www/qbz-config.php @@ -51,11 +51,14 @@ foreach ($result as $row) { $cfgQobuz[$row['param']] = $row['value']; } -// Self-heal a cfg_qobuz that predates the pairing param: without the row, -// the generic UPDATE in the save handler would silently no-op. -if (!isset($cfgQobuz['pairing'])) { - sqlInsert('cfg_qobuz', $dbh, "'pairing', 'Yes'"); - $cfgQobuz['pairing'] = 'Yes'; +// Self-heal a cfg_qobuz that predates a param: without the row, the generic +// UPDATE in the save handler would silently no-op. +$qobuzDefaults = array('pairing' => 'Yes', 'buffer_seconds' => '2', 'volume_mode' => 'software'); +foreach ($qobuzDefaults as $param => $default) { + if (!isset($cfgQobuz[$param])) { + sqlInsert('cfg_qobuz', $dbh, "'" . $param . "', '" . $default . "'"); + $cfgQobuz[$param] = $default; + } } // Local pairing supplies the account (the casting app hands over its own @@ -79,6 +82,12 @@ $_select['gapless'] .= "\n"; $_select['normalize_volume'] .= "\n"; $_select['normalize_volume'] .= "\n"; +foreach (array('2' => '2 seconds (Default)', '5' => '5 seconds', '10' => '10 seconds') as $secs => $label) { + $_select['buffer_seconds'] .= "\n"; +} +$_select['volume_mode'] .= "\n"; +$_select['volume_mode'] .= "\n"; // Account: login status is read from the qbzd control API (only available when the service is on) $_qobuz_login_hide = 'hide'; diff --git a/www/templates/qbz-config.html b/www/templates/qbz-config.html index 54708bbbc..59374f48c 100644 --- a/www/templates/qbz-config.html +++ b/www/templates/qbz-config.html @@ -41,6 +41,32 @@

Qobuz Connect

+ +
+ + + + How much audio to buffer before playback starts. A larger buffer rides out network + hiccups on a slow or wireless connection at the cost of a longer wait when starting + a track or switching output to this player. + +
+ + +
+ + + + Software lets the Qobuz app's volume slider attenuate the stream. Locked keeps the + stream at full scale for bit-perfect output, so the app's slider does nothing and + volume must come from your amplifier or DAC. + +
+
$_select[quality] @@ -30,17 +30,6 @@

Qobuz Connect

- -
- - - - Play consecutive tracks without a gap of silence between them. - -
-
+ $_select[output_mode] + + + + Where Qobuz playback sends audio: +
ALSA output mode (Auto) — picks Direct whenever Audio Config + has Output mode set to Direct and no DSP is enabled, in other words whenever moOde's + own output chain has nothing in it to lose. Falls back to Software otherwise. +
Software — goes through the same output chain as MPD, so your + EQ, crossfeed and CamillaDSP settings apply. The stream is converted to whatever rate + that chain is running at, which is usually not the rate of the track. +
Direct — hands the DAC the track's own rate untouched, for + bit-perfect playback. moOde's DSP is bypassed and the DAC is reserved for as long as + a Qobuz session is active, so nothing else can play through it. +
+
+ + +
+ $_qobuz_routing_resolves +
+
- Software lets the Qobuz app's volume slider attenuate the stream. Locked keeps the - stream at full scale for bit-perfect output, so the app's slider does nothing and - volume must come from your amplifier or DAC. + How the Qobuz app's volume slider is applied: +
Auto — uses the DAC's own volume control when that is possible, + otherwise software. +
DAC hardware volume — sends the slider to the DAC's volume + control, so the audio stays bit-perfect. Needs Direct routing and a DAC that has a + hardware volume control. This is the same control moOde's volume uses, so a change + made from the Qobuz app persists and affects moOde playback afterwards. +
Software — scales the samples instead. Works with any output, + but the audio is no longer bit-perfect. +
Locked at 100% — keeps the stream at full scale and ignores + the app's slider, so volume must come from your amplifier or DAC.
@@ -78,6 +102,70 @@

Qobuz Connect

+
Playback
+ + +
+ + + + Whether a track that is not already cached starts as soon as enough of it has + arrived, or waits for the whole file: +
As soon as buffered — playback starts in a second or two + instead of tens of seconds. +
After the full track downloads — steadier on an unreliable + connection, at the cost of a long pause before every new track. +
+
+ + +
+ + + + Whether finished tracks are kept on disk for instant replay: +
Stream only, do not cache — writes nothing to the SD card. + A hi-res track is 60–80 MB, so this spares the card over a long listening + session. Gapless playback is not possible without the cache. +
Cache tracks on disk — replaying a track is instant, and + gapless playback works because the next track can be loaded ahead of time. +
+
+ + +
+ + + + Play consecutive tracks without a gap of silence between them. Needs Track cache + set to keep tracks on disk: the next track has to be loaded ahead of time, which + is not possible when nothing is cached. + + $_gapless_hint +
+ + +
+ + + + What to do when a track cannot be played at the selected quality, either because + your subscription does not include it or because your DAC cannot do that rate: +
Play at a lower quality — the track still plays. +
Skip the track — quality stays guaranteed, at the cost of + silent gaps in an album. This matters more with Direct routing, where there is no + conversion step to fall back on. +
+
+
Account
From 5a7623f75a8098dcb33ffed1d3f8cb97c6494647 Mon Sep 17 00:00:00 2001 From: Filippo Vicentini Date: Fri, 21 Aug 2026 00:18:24 +0200 Subject: [PATCH 37/38] Qobuz Connect: pin qbzd 2.0.2.moode26 (range requests, dual-rate status, ALSA-direct software volume) --- www/util/qobuz-installer.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/util/qobuz-installer.sh b/www/util/qobuz-installer.sh index 7c83e7a8a..e1d0c361d 100644 --- a/www/util/qobuz-installer.sh +++ b/www/util/qobuz-installer.sh @@ -16,7 +16,7 @@ # The `.moodeN` suffix marks a moOde build of upstream 2.0.2: the binary reports # only its Cargo version, so the suffix is recorded in QBZD_BUILD_FILE for the # Renderer Config screen to display. -QBZD_VERSION="2.0.2.moode21" +QBZD_VERSION="2.0.2.moode26" QBZD_REPO="https://github.com/PhilipVinc/qbz" QBZD_TAG="qbzd-v$QBZD_VERSION" QBZD_BUILD_FILE="/var/local/www/qbzd-build" From 43894547e56cde1e1229b47a9a834c4df1b7b556 Mon Sep 17 00:00:00 2001 From: Filippo Vicentini Date: Fri, 21 Aug 2026 01:06:13 +0200 Subject: [PATCH 38/38] Qobuz Connect: cache tracks by default, so gapless works out of the box 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. --- var/local/www/db/moode-sqlite3.db.sql | 2 +- www/qbz-config.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql index e3e6a7134..c6d2afa50 100644 --- a/var/local/www/db/moode-sqlite3.db.sql +++ b/var/local/www/db/moode-sqlite3.db.sql @@ -266,7 +266,7 @@ INSERT INTO cfg_qobuz (id, param, value) VALUES (5, 'buffer_seconds', '2'); INSERT INTO cfg_qobuz (id, param, value) VALUES (6, 'volume_mode', 'auto'); INSERT INTO cfg_qobuz (id, param, value) VALUES (7, 'output_mode', 'auto'); INSERT INTO cfg_qobuz (id, param, value) VALUES (8, 'stream_first', 'Yes'); -INSERT INTO cfg_qobuz (id, param, value) VALUES (9, 'track_cache', 'No'); +INSERT INTO cfg_qobuz (id, param, value) VALUES (9, 'track_cache', 'Yes'); INSERT INTO cfg_qobuz (id, param, value) VALUES (10, 'quality_fallback', 'fallback'); -- Table: cfg_radio diff --git a/www/qbz-config.php b/www/qbz-config.php index 96cf1bcb2..8b817f9bb 100644 --- a/www/qbz-config.php +++ b/www/qbz-config.php @@ -55,7 +55,7 @@ // Self-heal a cfg_qobuz that predates a param: without the row, the generic // UPDATE in the save handler would silently no-op. $qobuzDefaults = array('pairing' => 'Yes', 'buffer_seconds' => '2', 'volume_mode' => 'auto', - 'output_mode' => 'auto', 'stream_first' => 'Yes', 'track_cache' => 'No', + 'output_mode' => 'auto', 'stream_first' => 'Yes', 'track_cache' => 'Yes', 'quality_fallback' => 'fallback'); foreach ($qobuzDefaults as $param => $default) { if (!isset($cfgQobuz[$param])) { @@ -143,8 +143,8 @@ $_select['stream_first'] .= "\n"; $_select['stream_first'] .= "\n"; -$_select['track_cache'] .= "\n"; -$_select['track_cache'] .= "\n"; +$_select['track_cache'] .= "\n"; +$_select['track_cache'] .= "\n"; $_select['quality_fallback'] .= "\n"; $_select['quality_fallback'] .= "\n";