From 833c77a22bc62af8c65a467a25e101f5befac923 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Sat, 3 Jan 2026 16:04:23 +0100
Subject: [PATCH 01/11] add experimental Sendspin CLI renderer
---
README-SENDSPIN.md | 41 +++++++++++++++++++++++++++
etc/systemd/system/sendspin.service | 13 +++++++++
usr/local/bin/moodeutl | 28 +++++++++++++-----
var/local/www/db/moode-sqlite3.db.sql | 2 ++
www/daemon/worker.php | 37 +++++++++++++++++++++++-
www/inc/constants.php | 5 ++--
www/inc/renderer.php | 13 ++++++++-
www/js/playerlib.js | 5 ++--
www/ren-config.php | 19 +++++++++++++
www/templates/ren-config.html | 22 ++++++++++++++
www/util/renderer-onoff.php | 16 ++++++++++-
www/util/restart-renderer.php | 15 ++++++++--
www/util/sysinfo.sh | 9 ++++++
13 files changed, 209 insertions(+), 16 deletions(-)
create mode 100644 README-SENDSPIN.md
create mode 100644 etc/systemd/system/sendspin.service
diff --git a/README-SENDSPIN.md b/README-SENDSPIN.md
new file mode 100644
index 000000000..ecca49885
--- /dev/null
+++ b/README-SENDSPIN.md
@@ -0,0 +1,41 @@
+# Experiment: moOde + Sendspin
+
+Experiment: extending moOde with a renderer for Sendspin.
+
+Start a Sendspin server; e.g.
+
+```bash
+sudo docker run -v /home/your-login-name/music-assistant-data:/data --network host --cap-add=DAC_READ_SEARCH --cap-add=SYS_ADMIN --security-opt apparmor:unconfined ghcr.io/music-assistant/server:beta
+```
+
+Install [sendspin-cli](https://github.com/Sendspin/sendspin-cli) in moOde; e.g.
+
+```bash
+sudo pip install uv --break-system-packages
+sudo uv tool install sendspin
+```
+
+Test via SSH if the Sendspin server can be reached; and desired audio device is available:
+
+```bash
+sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-servers
+sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-audio-devices
+```
+
+Change the audio device by modifying `/etc/systemd/system/sendspin.service` :
+
+```service
+[Unit]
+Description=Sendspin CLI
+After=network-online.target
+Requires=network-online.target
+
+[Service]
+Type=simple
+ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin --audio-device snd_rpi_hifiberry_dacplus --headless
+Restart=on-failure
+User=root
+
+[Install]
+WantedBy=multi-user.target
+```
diff --git a/etc/systemd/system/sendspin.service b/etc/systemd/system/sendspin.service
new file mode 100644
index 000000000..32d32722e
--- /dev/null
+++ b/etc/systemd/system/sendspin.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Sendspin CLI
+After=network-online.target
+Requires=network-online.target
+
+[Service]
+Type=simple
+ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin --headless
+Restart=on-failure
+User=root
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/usr/local/bin/moodeutl b/usr/local/bin/moodeutl
index 98f50a552..8a0d1cdd7 100755
--- a/usr/local/bin/moodeutl
+++ b/usr/local/bin/moodeutl
@@ -36,7 +36,8 @@ $features = array(
FEAT_BLUETOOTH => 'Bluetooth renderer',
FEAT_DEVTWEAKS => 'Developer tweaks',
FEAT_MULTIROOM => 'Multiroom audio',
- FEAT_PEPPYDISPLAY => 'PeppyMeter display'
+ FEAT_PEPPYDISPLAY => 'PeppyMeter display',
+ FEAT_SENDSPIN => 'Sendspin renderer'
);
$featBitmask = trim(shell_exec('sqlite3 ' . SQLDB_PATH . " \"SELECT value FROM cfg_system WHERE param='feat_bitmask'\""));
@@ -160,7 +161,7 @@ switch ($option) {
echo VERSION . "\n";
break;
case '--help':
- //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge]
+ //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge | --sendspin]
$btArg = $featBitmask & FEAT_BLUETOOTH ? '--bluetooth | ' : '';
$apArg = $featBitmask & FEAT_AIRPLAY ? '--airplay | ' : '';
$spArg = $featBitmask & FEAT_SPOTIFY ? '--spotify | ' : '';
@@ -169,7 +170,8 @@ switch ($option) {
$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, ' | ');
+ $ssArg = $featBitmask & FEAT_SENDSPIN ? '--sendspin | ' : '';
+ $rendererList = rtrim($btArg . $apArg . $spArg . $dzArg . $upArg . $slArg . $paArg . $rbArg . $ssArg, ' | ');
echo
"Usage: moodeutl [OPTION]
Moode utility functions
@@ -545,12 +547,24 @@ function stopAllRenderers($featBitmask) {
} else {
echo "- roonbridge\t\tfeature disabled\n";
}
+
+ if ($featBitmask & FEAT_SENDSPIN) {
+ $sendspinSvc = trim(shell_exec('sqlite3 ' . SQLDB_PATH . " \"SELECT value FROM cfg_system WHERE param='sendspinsvc'\""));
+ if ($sendspinSvc == '1') {
+ sysCmd('/var/www/util/restart-renderer.php --sendspin --stop');
+ echo "- sendspin\t\tstopped\n";
+ } else {
+ echo "- sendspin\t\tnot on\n";
+ }
+ } else {
+ echo "- sendspin\t\tfeature disabled\n";
+ }
}
function restartRenderer($argv) {
$renderers = array('--bluetooth' => 'btsvc', '--airplay' => 'airplaysvc', '--spotify' => 'spotifysvc',
'--deezer' => 'deezersvc', '--upnp' => 'upnpsvc', '--squeezelite' => 'slsvc', '--plexamp' => 'pasvc',
- '--roonbridge' => 'rbsvc');
+ '--roonbridge' => 'rbsvc', '--sendspin' => 'sendspinsvc');
if (!isset($argv[2])) {
echo 'Missing 2nd argument [renderer name]' . "\n";
@@ -564,7 +578,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, --upnp, --squeezelite, --plexamp, --roonbridge, --sendspin' . "\n";
return;
}
@@ -575,14 +589,14 @@ function restartRenderer($argv) {
function rendererOnoff($argv) {
$renderers = array('--bluetooth' => 'btsvc', '--airplay' => 'airplaysvc', '--spotify' => 'spotifysvc',
'--deezer' => 'deezersvc', '--upnp' => 'upnpsvc', '--squeezelite' => 'slsvc', '--plexamp' => 'pasvc',
- '--roonbridge' => 'rbsvc');
+ '--roonbridge' => 'rbsvc', '--sendspin' => 'sendspinsvc');
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, --upnp, --squeezelite, --plexamp, --roonbridge, --sendspin' . "\n";
return;
} else if (!isset($argv[3])) {
echo 'Missing 3nd argument [on|off]' . "\n";
diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql
index 14e4410ce..21d1c637b 100644
--- a/var/local/www/db/moode-sqlite3.db.sql
+++ b/var/local/www/db/moode-sqlite3.db.sql
@@ -706,6 +706,8 @@ 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, 'sendspinsvc', '0');
+INSERT INTO cfg_system (id, param, value) VALUES (177, 'sendspin_installed', 'no');
-- 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/daemon/worker.php b/www/daemon/worker.php
index 8718b78c5..f29cca44b 100755
--- a/www/daemon/worker.php
+++ b/www/daemon/worker.php
@@ -604,6 +604,20 @@
}
workerLog('worker: RoonBridge: ' . $msg);
+// Sendspin
+// To install
+// - sudo pip install uv --break-system-packages
+// - sudo uv tool install sendspin
+if (file_exists('/root/.local/share/uv/tools/sendspin/bin/sendspin') === true) {
+ $msg = 'installed';
+ $_SESSION['sendspin_installed'] = 'yes';
+
+} else {
+ $_SESSION['sendspin_installed'] = 'no';
+ $msg = 'not installed';
+}
+workerLog('worker: Sendspin: ' . $msg);
+
// Allo Boss 2
// OLED: The Allo installer adds lines to rc.local which are not needed because we start/stop it via systemd unit
if (!empty(sysCmd('grep "boss2" /etc/rc.local')[0])) {
@@ -1175,6 +1189,22 @@
}
workerLog('worker: RoonBridge: ' . $status);
+// Start Sendspin renderer
+if ($_SESSION['feat_bitmask'] & FEAT_SENDSPIN) {
+ if (isset($_SESSION['sendspinsvc']) && $_SESSION['sendspinsvc'] == 1) {
+ $status = 'started';
+ startSendspin();
+ } else {
+ $status = 'available';
+ }
+ } else {
+ $status = 'not installed';
+ }
+} else {
+ $status = 'n/a';
+}
+workerLog('worker: Sendspin: ' . $status);
+
// Start Multiroom audio
if ($_SESSION['feat_bitmask'] & FEAT_MULTIROOM) {
// Sender
@@ -3197,7 +3227,12 @@ function runQueuedJob() {
}
}
break;
-
+ case 'sendspinsvc':
+ stopSendspin();
+ if ($_SESSION['sendspinsvc'] == 1) {
+ startSendspin();
+ }
+ break;
case 'multiroom_tx':
if ($_SESSION['multiroom_tx'] == 'On') {
// Reconfigure to Dummy sound driver
diff --git a/www/inc/constants.php b/www/inc/constants.php
index 8f4086284..b40d51242 100755
--- a/www/inc/constants.php
+++ b/www/inc/constants.php
@@ -202,8 +202,9 @@
const FEAT_DEVTWEAKS = 32768; // Developer tweaks
const FEAT_MULTIROOM = 65536; // y Multiroom audio
const FEAT_PEPPYDISPLAY = 131072; // y Peppy display
-// -------
-// 228279
+const FEAT_SENDSPIN = 262144; // y Sendspin renderer
+// ------
+// 524287
// Selective resampling bitmask
const SOX_UPSAMPLE_ALL = 3; // Upsample if source < target rate
diff --git a/www/inc/renderer.php b/www/inc/renderer.php
index 83ad6383e..f490f9181 100644
--- a/www/inc/renderer.php
+++ b/www/inc/renderer.php
@@ -369,6 +369,16 @@ function stopRoonBridge() {
sendFECmd('rbactive0');
}
+// Sendspin
+function startSendspin() {
+ sysCmd('mpc stop');
+ sysCmd('systemctl start sendspin');
+}
+
+function stopSendspin() {
+ sysCmd('systemctl stop sendspin');
+}
+
// Stop all renderers
function stopAllRenderers() {
$renderers = array(
@@ -379,7 +389,8 @@ function stopAllRenderers() {
'upnpsvc' => 'stopUPnP',
'slsvc' => 'stopSqueezeLite',
'pasvc' => 'stopPlexamp',
- 'rbsvc' => 'stopRoonBridge'
+ 'rbsvc' => 'stopRoonBridge',
+ 'sendspinsvc' => 'stopSendspin'
);
// Watchdog (so monitored renderers are not auto restarted)
diff --git a/www/js/playerlib.js b/www/js/playerlib.js
index 43dd4b69d..8505bb24b 100755
--- a/www/js/playerlib.js
+++ b/www/js/playerlib.js
@@ -23,8 +23,9 @@ const FEAT_BLUETOOTH = 16384; // y Bluetooth renderer
const FEAT_DEVTWEAKS = 32768; // Developer tweaks
const FEAT_MULTIROOM = 65536; // y Multiroom audio
const FEAT_PEPPYDISPLAY = 131072; // y Peppy display
+const FEAT_SENDSPIN = 262144; // y Sendspin renderer
// -------
-// 228279
+// 524287
// Notifications
const NOTIFY_TITLE_INFO = ' Info';
@@ -3697,7 +3698,7 @@ $('#btn-preferences-update').click(function(e){
// CoverView
'scnsaver_timeout': SESSION.json['scnsaver_timeout'],
- 'scnsaver_whenplaying' = SESSION.json['scnsaver_whenplaying'],
+ 'scnsaver_whenplaying' : SESSION.json['scnsaver_whenplaying'],
'auto_coverview': SESSION.json['auto_coverview'],
'scnsaver_style': SESSION.json['scnsaver_style'],
'scnsaver_mode': SESSION.json['scnsaver_mode'],
diff --git a/www/ren-config.php b/www/ren-config.php
index 8741e4cc7..60e3d107a 100644
--- a/www/ren-config.php
+++ b/www/ren-config.php
@@ -200,6 +200,17 @@
submitJob('rbrestart', '', NOTIFY_TITLE_INFO, NAME_ROONBRIDGE . NOTIFY_MSG_SVC_MANUAL_RESTART);
}
+// Sendspin
+if (isset($_POST['update_sendspin_settings'])) {
+ if (isset($_POST['sendspinsvc']) && $_POST['sendspinsvc'] != $_SESSION['sendspinsvc']) {
+ $update = true;
+ phpSession('write', 'sendspinsvc', $_POST['sendspinsvc']);
+ }
+ if (isset($update)) {
+ submitJob('sendspinsvc');
+ }
+}
+
phpSession('close');
// Bluetooth
@@ -356,6 +367,14 @@
$_feat_roonbridge = 'hide';
}
+// Sendspin
+$_feat_sendspin = $_SESSION['feat_bitmask'] & FEAT_SENDSPIN ? '' : 'hide';
+$_SESSION['sendspinsvc'] == '1' ? $_sendspin_btn_disable = '' : $_sendspin_btn_disable = 'disabled';
+$_SESSION['sendspinsvc'] == '1' ? $_sendspin_link_disable = '' : $_sendspin_link_disable = 'onclick="return false;"';
+$autoClick = " onchange=\"autoClick('#btn-set-sendspinsvc');\"";
+$_select['sendspinsvc_on'] .= "\n";
+$_select['sendspinsvc_off'] .= "\n";
+
waitWorker('ren-config');
$tpl = "ren-config.html";
diff --git a/www/templates/ren-config.html b/www/templates/ren-config.html
index aeda9c08a..d952cdebb 100644
--- a/www/templates/ren-config.html
+++ b/www/templates/ren-config.html
@@ -414,6 +414,28 @@
Renderers
RoonBridge
+
+
+
+
Sendspin must be installed manually otherwise sendspin-cli cannot start.
+
+
+
+ $_select[sendspinsvc_on]
+ $_select[sendspinsvc_off]
+
+
+
+
+ Sendspin is a project from the Open Home Foundation.
+
+
+
+
+
+
Sendspin control
+
+
diff --git a/www/util/renderer-onoff.php b/www/util/renderer-onoff.php
index 5b2edffaa..a9d8cffe8 100755
--- a/www/util/renderer-onoff.php
+++ b/www/util/renderer-onoff.php
@@ -40,6 +40,8 @@
case '--roonbridge':
onoffRoonBridge($onoff);
break;
+ case '--sendspin':
+ onoffSendspin($onoff);
case '--upnp':
onoffUPnP($onoff);
break;
@@ -57,7 +59,8 @@
$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 .
+ $ssArg = $_SESSION['feat_bitmask'] & FEAT_SENDSPIN ? " --sendspin\tTurn Sendspin On/Off\n" : "";
+ $rendererList = ' '. $btArg . $apArg . $spArg . $dzArg . $upArg . $slArg . $paArg . $rbArg . $ssArg .
" --help\t\tPrint this help text\n";
echo
"Usage: renderer-onoff [OPTION] [on|off]
@@ -162,3 +165,14 @@ function onoffRoonBridge($onoff) {
stopRoonBridge();
}
}
+
+function onoffSendspin($onoff) {
+ if ($onoff == 'on' && $_SESSION['sendspinsvc'] == '0') {
+ phpSession('write', 'sendspinsvc', '1');
+ startSendspin();
+ } else if ($onoff == 'off' && $_SESSION['sendspinsvc'] == '1') {
+ phpSession('write', 'sendspinsvc', '0');
+ stopSendspin();
+ }
+}
+
diff --git a/www/util/restart-renderer.php b/www/util/restart-renderer.php
index 845f1675b..21255d006 100755
--- a/www/util/restart-renderer.php
+++ b/www/util/restart-renderer.php
@@ -42,6 +42,9 @@
case '--roonbridge':
restartRoonBridge($stopOnly);
break;
+ case '--sendspin':
+ restartSendspin($stopOnly);
+ break;
case '--upnp':
restartUPnP($stopOnly);
break;
@@ -50,7 +53,7 @@
fwrite(STDERR, "This command requires sudo to print the help\n");
return;
}
- //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge]
+ //[--bluetooth | --airplay | --spotify | --deezer | --upnp | --squeezelite | --plexamp | --roonbridge | --sendspin]
$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" : "";
@@ -59,7 +62,8 @@
$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 .
+ $ssArg = $_SESSION['feat_bitmask'] & FEAT_SENDSPIN ? " --sendspin\tRestart Sendspin\n" : "";
+ $rendererList = ' '. $btArg . $apArg . $spArg . $dzArg . $upArg . $slArg . $paArg . $rbArg . $ssArg .
" --help\t\tPrint this help text\n";
echo
"Usage: restart-renderer [OPTION] [--stop]
@@ -141,3 +145,10 @@ function restartRoonBridge($stopOnly) {
startRoonBridge();
}
}
+
+function restartSendspin($stopOnly) {
+ stopSendspin();
+ if ($stopOnly === false) {
+ startSendspin();
+ }
+}
diff --git a/www/util/sysinfo.sh b/www/util/sysinfo.sh
index 19a1953dc..f054825c7 100755
--- a/www/util/sysinfo.sh
+++ b/www/util/sysinfo.sh
@@ -140,6 +140,9 @@ AUDIO_PARAMETERS() {
if [ $(($feat_bitmask & $FEAT_ROONBRIDGE)) -ne 0 ]; then
echo -e "\nRoonBridge\t\t= $rbsvc\c"
fi
+ if [ $(($feat_bitmask & $FEAT_SENDSPIN)) -ne 0 ]; then
+ echo -e "\nSendspin server\t= $sendspinsvc\c"
+ fi
if [ $(($feat_bitmask & $FEAT_GPIO)) -ne 0 ]; then
echo -e "\nGPIO button handler\t= $gpio_svc\c"
fi
@@ -383,6 +386,11 @@ RENDERER_SETTINGS() {
fi
fi
+ if [ $(($feat_bitmask & $FEAT_SENDSPIN)) -ne 0 ]; then
+ echo -e "S E N D S P I N"
+ echo -e "\nSendspin information here\c"
+ fi
+
if [ $(($feat_bitmask & $FEAT_LOCALDISPLAY)) -ne 0 ]; then
echo -e "A T T A C H E D D I S P L A Y"
echo -e "\nScreen blank\t\t= $scn_blank\c"
@@ -445,6 +453,7 @@ FEAT_PLEXAMP=8192
FEAT_BLUETOOTH=16384
FEAT_MULTIROOM=65536
FEAT_PEPPYDISPLAY=131072
+FEAT_SENDSPIN=262144
# Selective resampling bitmask
SOX_UPSAMPLE_ALL=3 # Upsample if source < target rate
From ebb399c77c5dd390f70178af8503ad6ab567f2fd Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Mon, 5 Jan 2026 21:50:15 +0100
Subject: [PATCH 02/11] fix font corruption for Gulp v5
---
gulpfile.js | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/gulpfile.js b/gulpfile.js
index b48ce210a..ae49e3e5d 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -458,11 +458,11 @@ gulp.task('minifyhtml', function (done) {
});
gulp.task('artwork', function(done) {
- gulp.src([ pkg.app.src+'/webfonts/**/*'
- ,pkg.app.src+'/fonts/**/*'
- ,pkg.app.src+'/images/**/*' ], {base:pkg.app.src})
- .pipe($.if(!mode.force(), $.newer( { dest: pkg.app.dest})))
-// .pipe($.size({showFiles: true, total: true}))
+ gulp.src([ pkg.app.src+'/webfonts/**/*',
+ pkg.app.src+'/fonts/**/*',
+ pkg.app.src+'/images/**/*' ], {base: pkg.app.src, encoding: false})
+ .pipe($.if(!mode.force(), $.newer( { dest: pkg.app.dest })))
+// .pipe($.size({showFiles: true, total: true}))
.pipe(gulp.dest(pkg.app.dest));
done();
});
@@ -511,7 +511,7 @@ gulp.task('deployback', gulp.series(['patchheader','patchfooter', 'patchindex',
,'!'+pkg.app.src+'/*-config.php'
,pkg.app.src+'/css/shellinabox*.css'
],
- {base: pkg.app.src})
+ {base: pkg.app.src, encoding: false})
// optional headers fields can be update and or added:
//.pipe( $.replaceTask({ patterns: REPLACEMENT_PATTERNS }))
//.pipe($.if('*.html', $.header(banner_html, {pkg: pkg}) ))
@@ -525,7 +525,7 @@ gulp.task('deployback', gulp.series(['patchheader','patchfooter', 'patchindex',
}));
gulp.task('deployfront', function (done) {
- return gulp.src( [pkg.app.dest+'/**/*', '!'+pkg.app.dest+'/index.html'] )
+ return gulp.src( [pkg.app.dest+'/**/*', '!'+pkg.app.dest+'/index.html'], { encoding: false } )
.pipe($.if(!mode.force(), $.newer( { dest: DEPLOY_LOCATION})))
.pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
.pipe(gulp.dest(DEPLOY_LOCATION))
From 2993e019dc47b68f983db5ed6d59d8abc9461c94 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Mon, 5 Jan 2026 21:52:24 +0100
Subject: [PATCH 03/11] fix Sendspin installed check for moodeutl
---
www/daemon/worker.php | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/www/daemon/worker.php b/www/daemon/worker.php
index 22473abec..7fe33055c 100755
--- a/www/daemon/worker.php
+++ b/www/daemon/worker.php
@@ -1191,12 +1191,13 @@
// Start Sendspin renderer
if ($_SESSION['feat_bitmask'] & FEAT_SENDSPIN) {
- if (isset($_SESSION['sendspinsvc']) && $_SESSION['sendspinsvc'] == 1) {
- $status = 'started';
- startSendspin();
- } else {
- $status = 'available';
- }
+ if ($_SESSION['sendspin_installed'] == 'yes') {
+ if (isset($_SESSION['sendspinsvc']) && $_SESSION['sendspinsvc'] == 1) {
+ $status = 'started';
+ startSendspin();
+ } else {
+ $status = 'available';
+ }
} else {
$status = 'not installed';
}
From e4abd9675475a761e56eedee9a8dc02c834c1ffc Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Wed, 7 Jan 2026 21:05:26 +0100
Subject: [PATCH 04/11] add Pi5 16GB
---
www/util/pirev.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/www/util/pirev.py b/www/util/pirev.py
index 4338f5555..9221b8d43 100755
--- a/www/util/pirev.py
+++ b/www/util/pirev.py
@@ -70,7 +70,8 @@
2: "1GB",
3: "2GB",
4: "4GB",
- 5: "8GB"
+ 5: "8GB",
+ 6: "16GB"
}
PI_PROC = {
From 863563a4bbe14c56d592b09f4df5aa748fdabaff Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Fri, 16 Jan 2026 23:01:44 +0100
Subject: [PATCH 05/11] update syntax compatible with sendspin-4.0.0
---
etc/systemd/system/sendspin.service | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/etc/systemd/system/sendspin.service b/etc/systemd/system/sendspin.service
index 32d32722e..0f909f63c 100644
--- a/etc/systemd/system/sendspin.service
+++ b/etc/systemd/system/sendspin.service
@@ -1,11 +1,11 @@
[Unit]
-Description=Sendspin CLI
+Description=Sendspin Multi Room Audio Client
After=network-online.target
Requires=network-online.target
[Service]
Type=simple
-ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin --headless
+ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin daemon
Restart=on-failure
User=root
From e3690ce569b814781e9db380df185801071f81e4 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Fri, 16 Jan 2026 23:07:08 +0100
Subject: [PATCH 06/11] update for sendspin 4.0.0
---
var/local/www/db/moode-sqlite3.db.sql | 11 ++++++
www/daemon/worker.php | 7 ++++
www/inc/constants.php | 1 +
www/inc/renderer.php | 17 ++++++++-
www/inc/sql.php | 27 +++++++++++++-
www/sen-config.php | 49 ++++++++++++++++++++++++
www/setup_3rdparty_sendspin.txt | 42 +++++++++++++++++++++
www/setup_renderers.txt | 9 ++++-
www/templates/ren-config.html | 9 +++--
www/templates/sen-config.html | 54 +++++++++++++++++++++++++++
10 files changed, 219 insertions(+), 7 deletions(-)
create mode 100644 www/sen-config.php
create mode 100644 www/setup_3rdparty_sendspin.txt
create mode 100644 www/templates/sen-config.html
diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql
index 21d1c637b..078e63e65 100644
--- a/var/local/www/db/moode-sqlite3.db.sql
+++ b/var/local/www/db/moode-sqlite3.db.sql
@@ -491,6 +491,17 @@ INSERT INTO cfg_sl (id, param, value) VALUES (5, 'TASKPRIORITY', '45');
INSERT INTO cfg_sl (id, param, value) VALUES (6, 'CODECS', 'flac,pcm,mp3,ogg,aac,alac,dsd');
INSERT INTO cfg_sl (id, param, value) VALUES (7, 'OTHEROPTIONS', '-W -D 500 -R E -S /var/local/www/commandw/slpower.sh');
+-- Table: cfg_sendspin
+CREATE TABLE cfg_sendspin (id INTEGER PRIMARY KEY, param CHAR (10), value CHAR (128));
+INSERT INTO cfg_sendspin (id, param, value) VALUES (1, 'last_server_url', ''); -- default: discover via mDNS, otherwise WebSocket URL of Sendspin server
+INSERT INTO cfg_sendspin (id, param, value) VALUES (2, 'name', 'moOde Sendspin Audio Client'); -- default:
+INSERT INTO cfg_sendspin (id, param, value) VALUES (3, 'client_id', 'moode-sendspin'); -- default: sendspin-cli-
+INSERT INTO cfg_sendspin (id, param, value) VALUES (4, 'log_level','INFO'); -- options: DEBUG, INFO, WARNING, ERROR or CRITICAL
+INSERT INTO cfg_sendspin (id, param, value) VALUES (5, 'static_delay_ms', '0.0'); -- extra playback delay in milliseconds (applied after clock sync)
+INSERT INTO cfg_sendspin (id, param, value) VALUES (6, 'audio_device', ''); -- audio output device index (e.g., 0, 1, 2) or name prefix (e.g., 'vc4-hdmi-1' or 'snd_rpi_hifiberry_dacplus')
+INSERT INTO cfg_sendspin (id, param, value) VALUES (7, 'use_mpris', 'false'); -- Media Player Remote Interfacing Specification - see https://github.com/abmantis/aiosendspin-mpris
+
+
-- Table: cfg_source
CREATE TABLE cfg_source (
id INTEGER PRIMARY KEY,
diff --git a/www/daemon/worker.php b/www/daemon/worker.php
index 7fe33055c..0bc3bc049 100755
--- a/www/daemon/worker.php
+++ b/www/daemon/worker.php
@@ -3236,6 +3236,13 @@ function runQueuedJob() {
startSendspin();
}
break;
+ case 'sendspincfgupdate':
+ cfgSendspin();
+ if ($_SESSION['sendspinsvc'] == '1') {
+ sysCmd('systemctl stop sendspin');
+ startSendspin();
+ }
+ break;
case 'multiroom_tx':
if ($_SESSION['multiroom_tx'] == 'On') {
// Reconfigure to Dummy sound driver
diff --git a/www/inc/constants.php b/www/inc/constants.php
index b40d51242..e60979c4b 100755
--- a/www/inc/constants.php
+++ b/www/inc/constants.php
@@ -128,6 +128,7 @@
const NAME_DLNA = 'DLNA';
const NAME_PLEXAMP = 'Plexamp';
const NAME_ROONBRIDGE = 'RoonBridge';
+const NAME_SENDSPIN = 'Sendspin';
const NAME_GPIO = 'GPIO Controller';
const NAME_LOCALDISPLAY = 'Local Display';
const NAME_PEPPYDISPLAY = 'Peppy Display';
diff --git a/www/inc/renderer.php b/www/inc/renderer.php
index f490f9181..53163bf4f 100644
--- a/www/inc/renderer.php
+++ b/www/inc/renderer.php
@@ -374,10 +374,25 @@ function startSendspin() {
sysCmd('mpc stop');
sysCmd('systemctl start sendspin');
}
-
function stopSendspin() {
sysCmd('systemctl stop sendspin');
}
+function cfgSendspin() {
+ // example, minimal 'required config' for /root/.config/sendspin/settings-daemon.json
+ //
+ // {
+ // "static_delay_ms": 0.0,
+ // "name": "moOde Sendspin Audio Client",
+ // "client_id": "moode-sendspin",
+ // "audio_device": "snd_rpi_hifiberry_dacplus",
+ // "use_mpris": false
+ // }
+
+ $filename = '/root/.config/sendspin/settings-daemon.json';
+ $data = sqlRead(table:'cfg_sendspin', dbh: sqlConnect(), format: 'json');
+
+ file_put_contents($filename, $data);
+}
// Stop all renderers
function stopAllRenderers() {
diff --git a/www/inc/sql.php b/www/inc/sql.php
index 81a6fffa0..956496ee9 100644
--- a/www/inc/sql.php
+++ b/www/inc/sql.php
@@ -16,7 +16,7 @@ function sqlConnect() {
}
}
-function sqlRead($table, $dbh, $param = '', $id = '') {
+function sqlRead($table, $dbh, $param = '', $id = '', $format = 'array') {
if (empty($param) && empty($id)) {
$queryStr = 'SELECT * FROM ' . $table;
} else if (!empty($id)) {
@@ -37,7 +37,30 @@ function sqlRead($table, $dbh, $param = '', $id = '') {
$queryStr = 'SELECT value FROM ' . $table . " WHERE param='" . $param . "'";
}
- return sqlQuery($queryStr, $dbh);
+ $rows = sqlQuery($queryStr, $dbh);
+ if ($format === 'json') {
+ $clean = [];
+ foreach ($rows as $row) {
+ $param = $row['param'];
+ $value = $row['value'];
+
+ // Universal type detection and conversion
+ if ($value === '' || $value === null) {
+ $clean[$param] = null;
+ } elseif (is_numeric($value) && strpos($value, '.') !== false) {
+ $clean[$param] = (float)$value; // "0.0" → 0.0
+ } elseif (is_numeric($value)) {
+ $clean[$param] = (int)$value; // "123" → 123
+ } elseif (in_array(strtolower($value), ['true', 'false', 'yes', 'no', '1', '0'])) {
+ $clean[$param] = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
+ } else {
+ $clean[$param] = $value; // String
+ }
+ }
+ return json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ return $rows;
}
function sqlUpdate($table, $dbh, $key = '', $value) {
diff --git a/www/sen-config.php b/www/sen-config.php
new file mode 100644
index 000000000..7b3f9d318
--- /dev/null
+++ b/www/sen-config.php
@@ -0,0 +1,49 @@
+ $value) {
+ chkValue($key, $value);
+ sqlUpdate('cfg_sendspin', $dbh, $key, $value);
+ }
+ $notify = $_SESSION['sendspinsvc'] == '1' ?
+ array('title' => NOTIFY_TITLE_INFO, 'msg' => NAME_SENDSPIN . NOTIFY_MSG_SVC_RESTARTED) :
+ array('title' => '', 'msg' => '');
+ submitJob('sendspincfgupdate', '', $notify['title'], $notify['msg']);
+}
+
+phpSession('close');
+
+$result = sqlRead(table: 'cfg_sendspin', dbh: $dbh, format: 'array');
+$cfgSENDSPIN = array();
+
+foreach ($result as $row) {
+ $cfgSENDSPIN[$row['param']] = $row['value'];
+}
+
+$_sendspin_select['last_server_url'] = $cfgSENDSPIN['last_server_url']; // Sendspin Server URL, empty for auto-detect
+// $_sendspin_select['static_delay_ms'] = $cfgSENDSPIN['static_delay_ms'];
+$_sendspin_select['name'] = $cfgSENDSPIN['name'];
+// $_sendspin_select['client_id'] = $cfgSENDSPIN['client_id'];
+$_sendspin_select['audio_device'] = $cfgSENDSPIN['audio_device'];
+
+waitWorker('sendspin_config');
+
+$tpl = "sen-config.html";
+$section = basename(__FILE__, '.php');
+storeBackLink($section, $tpl);
+
+include('header.php');
+eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");");
+include('footer.php');
diff --git a/www/setup_3rdparty_sendspin.txt b/www/setup_3rdparty_sendspin.txt
new file mode 100644
index 000000000..0d62b3bb9
--- /dev/null
+++ b/www/setup_3rdparty_sendspin.txt
@@ -0,0 +1,42 @@
+################################################################################
+#
+# Setup Guide for sendspin-cli 3rd party component
+#
+# Version: 1.0 2026-01-16
+#
+################################################################################
+
+
+OVERVIEW
+
+This document provides some information on using moOde with Sendspin.
+
+Sendspin CLI is a synchronized audio client supporting Sendspin servers.
+
+In moOde, it can be setup as a player; rendering it a multiroom audio endpoint for Sendspin servers such as Music Assistant.
+
+See https://github.com/Sendspin/sendspin-cli for more information.
+
+INSTALLATION
+
+To install sendspin-cli in moOde, SSH to your Raspberry Pi and install uv / sendspin:
+
+$ sudo pip install uv --break-system-packages
+$ sudo uv tool install sendspin@4.0.0
+
+To check if the Sendspin server is listed and the desired audio device is available:
+
+$ sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-servers
+$ sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-audio-devices
+
+TROUBLESHOOTING
+
+Trying to playing both from moOde via MPD and to the Sendspin renderer at the same moment may crash/loop sendspin and trigger [PaErrorCode -9985]
+
+Change moOde's audio output to a different device while playing through the Sendspin renderer prevents this error.
+
+This issue might be fixed in a future release of moOde.
+
+################################################################################
+# Post questions regarding this guide to http://moodeaudio.org/forum
+################################################################################
diff --git a/www/setup_renderers.txt b/www/setup_renderers.txt
index 7e4ee3a7c..4c8ea377f 100644
--- a/www/setup_renderers.txt
+++ b/www/setup_renderers.txt
@@ -2,7 +2,7 @@
#
# Setup Guide for Audio Renderers
#
-# Version: 1.2 2025-07-21
+# Version: 1.2 2026-01-16
#
# (C) Tim Curtis 2024 http://moodeaudio.org
#
@@ -44,6 +44,7 @@ from the client and whether CamillaDSP is supported in the playback chain.
+-------------------------------------------------------------------------------
| RoonBridge | Roon server | No | No
+-------------------------------------------------------------------------------
+| Sendspin | Music Assistant Server (2) | No | No
(1) On 2025-05-06 Deezer officially cancelled support for Deezer Connect but it
apparantly is still available on iOS when you enable Remote Control under
@@ -51,6 +52,11 @@ Settings > Music > Deezer Lab, and on Android when you use an older APK version.
This may stop working at any time. Official notice of cancellation:
https://en.deezercommunity.com/product-updates/say-goodbye-to-deezer-connect-80661
+(2) On 2026-01-16, Sendspin is currently in technical preview. While functional, the
+protocol and its implementation may change (see https://www.sendspin-audio.com/ )
+
+To use the Sendspin renderer in moOde, see setup_3rdparty_sendspin.txt.
+
MULTIPLE RENDERERS ON
The following audio renderers can be ON at the same time waiting for connections
@@ -72,6 +78,7 @@ when they are turned ON and thus no other renderer can be also be ON.
- Squeezelite (if -c option is not specified)
- Plexamp
- RoonBridge
+- Sendspin
RENDERER PROTOCOL TYPES
diff --git a/www/templates/ren-config.html b/www/templates/ren-config.html
index d952cdebb..bfb41488e 100644
--- a/www/templates/ren-config.html
+++ b/www/templates/ren-config.html
@@ -417,7 +417,10 @@ Renderers
-
Sendspin must be installed manually otherwise sendspin-cli cannot start.
+
+ Requires sendspin-cli, view the Setup guide.
+
+
@@ -432,8 +435,8 @@
Renderers
-
-
Sendspin control
+
+
Sendspin settings
diff --git a/www/templates/sen-config.html b/www/templates/sen-config.html
new file mode 100644
index 000000000..14b4f0cb2
--- /dev/null
+++ b/www/templates/sen-config.html
@@ -0,0 +1,54 @@
+
+
From 9599041eb2ccd1a13621a040d68df08bc8b6e154 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Fri, 16 Jan 2026 23:13:05 +0100
Subject: [PATCH 07/11] remove temporary readme
---
README-SENDSPIN.md | 41 -----------------------------------------
1 file changed, 41 deletions(-)
delete mode 100644 README-SENDSPIN.md
diff --git a/README-SENDSPIN.md b/README-SENDSPIN.md
deleted file mode 100644
index ecca49885..000000000
--- a/README-SENDSPIN.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Experiment: moOde + Sendspin
-
-Experiment: extending moOde with a renderer for Sendspin.
-
-Start a Sendspin server; e.g.
-
-```bash
-sudo docker run -v /home/your-login-name/music-assistant-data:/data --network host --cap-add=DAC_READ_SEARCH --cap-add=SYS_ADMIN --security-opt apparmor:unconfined ghcr.io/music-assistant/server:beta
-```
-
-Install [sendspin-cli](https://github.com/Sendspin/sendspin-cli) in moOde; e.g.
-
-```bash
-sudo pip install uv --break-system-packages
-sudo uv tool install sendspin
-```
-
-Test via SSH if the Sendspin server can be reached; and desired audio device is available:
-
-```bash
-sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-servers
-sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-audio-devices
-```
-
-Change the audio device by modifying `/etc/systemd/system/sendspin.service` :
-
-```service
-[Unit]
-Description=Sendspin CLI
-After=network-online.target
-Requires=network-online.target
-
-[Service]
-Type=simple
-ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin --audio-device snd_rpi_hifiberry_dacplus --headless
-Restart=on-failure
-User=root
-
-[Install]
-WantedBy=multi-user.target
-```
From 396ae3c5076769ded7731bfb18b1faeb83d4f0a5 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 22:32:25 +0100
Subject: [PATCH 08/11] remove Sendspin config
---
var/local/www/db/moode-sqlite3.db.sql | 11 ------
www/daemon/worker.php | 13 +------
www/sen-config.php | 49 ------------------------
www/setup_3rdparty_sendspin.txt | 20 +++++-----
www/templates/ren-config.html | 8 +---
www/templates/sen-config.html | 54 ---------------------------
6 files changed, 14 insertions(+), 141 deletions(-)
delete mode 100644 www/sen-config.php
delete mode 100644 www/templates/sen-config.html
diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql
index c933e960a..b079e91f6 100644
--- a/var/local/www/db/moode-sqlite3.db.sql
+++ b/var/local/www/db/moode-sqlite3.db.sql
@@ -491,17 +491,6 @@ INSERT INTO cfg_sl (id, param, value) VALUES (5, 'TASKPRIORITY', '45');
INSERT INTO cfg_sl (id, param, value) VALUES (6, 'CODECS', 'flac,pcm,mp3,ogg,aac,alac,dsd');
INSERT INTO cfg_sl (id, param, value) VALUES (7, 'OTHEROPTIONS', '-W -D 500 -R E -S /var/local/www/commandw/slpower.sh');
--- Table: cfg_sendspin
-CREATE TABLE cfg_sendspin (id INTEGER PRIMARY KEY, param CHAR (10), value CHAR (128));
-INSERT INTO cfg_sendspin (id, param, value) VALUES (1, 'last_server_url', ''); -- default: discover via mDNS, otherwise WebSocket URL of Sendspin server
-INSERT INTO cfg_sendspin (id, param, value) VALUES (2, 'name', 'moOde Sendspin Audio Client'); -- default:
-INSERT INTO cfg_sendspin (id, param, value) VALUES (3, 'client_id', 'moode-sendspin'); -- default: sendspin-cli-
-INSERT INTO cfg_sendspin (id, param, value) VALUES (4, 'log_level','INFO'); -- options: DEBUG, INFO, WARNING, ERROR or CRITICAL
-INSERT INTO cfg_sendspin (id, param, value) VALUES (5, 'static_delay_ms', '0.0'); -- extra playback delay in milliseconds (applied after clock sync)
-INSERT INTO cfg_sendspin (id, param, value) VALUES (6, 'audio_device', ''); -- audio output device index (e.g., 0, 1, 2) or name prefix (e.g., 'vc4-hdmi-1' or 'snd_rpi_hifiberry_dacplus')
-INSERT INTO cfg_sendspin (id, param, value) VALUES (7, 'use_mpris', 'false'); -- Media Player Remote Interfacing Specification - see https://github.com/abmantis/aiosendspin-mpris
-
-
-- Table: cfg_source
CREATE TABLE cfg_source (
id INTEGER PRIMARY KEY,
diff --git a/www/daemon/worker.php b/www/daemon/worker.php
index 1e70a9046..8f8e1c428 100755
--- a/www/daemon/worker.php
+++ b/www/daemon/worker.php
@@ -616,10 +616,8 @@
workerLog('worker: RoonBridge: ' . $msg);
// Sendspin
-// To install
-// - sudo pip install uv --break-system-packages
-// - sudo uv tool install sendspin
-if (file_exists('/root/.local/share/uv/tools/sendspin/bin/sendspin') === true) {
+// Installer: https://github.com/Sendspin/sendspin-cli/blob/main/scripts/systemd/install-systemd.sh
+if (file_exists('/home/sendspin/.local/share/uv/tools/sendspin/bin/sendspin') === true) {
$msg = 'installed';
$_SESSION['sendspin_installed'] = 'yes';
@@ -3299,13 +3297,6 @@ function runQueuedJob() {
startSendspin();
}
break;
- case 'sendspincfgupdate':
- cfgSendspin();
- if ($_SESSION['sendspinsvc'] == '1') {
- sysCmd('systemctl stop sendspin');
- startSendspin();
- }
- break;
case 'multiroom_tx':
if ($_SESSION['multiroom_tx'] == 'On') {
// Reconfigure to Dummy sound driver
diff --git a/www/sen-config.php b/www/sen-config.php
deleted file mode 100644
index 7b3f9d318..000000000
--- a/www/sen-config.php
+++ /dev/null
@@ -1,49 +0,0 @@
- $value) {
- chkValue($key, $value);
- sqlUpdate('cfg_sendspin', $dbh, $key, $value);
- }
- $notify = $_SESSION['sendspinsvc'] == '1' ?
- array('title' => NOTIFY_TITLE_INFO, 'msg' => NAME_SENDSPIN . NOTIFY_MSG_SVC_RESTARTED) :
- array('title' => '', 'msg' => '');
- submitJob('sendspincfgupdate', '', $notify['title'], $notify['msg']);
-}
-
-phpSession('close');
-
-$result = sqlRead(table: 'cfg_sendspin', dbh: $dbh, format: 'array');
-$cfgSENDSPIN = array();
-
-foreach ($result as $row) {
- $cfgSENDSPIN[$row['param']] = $row['value'];
-}
-
-$_sendspin_select['last_server_url'] = $cfgSENDSPIN['last_server_url']; // Sendspin Server URL, empty for auto-detect
-// $_sendspin_select['static_delay_ms'] = $cfgSENDSPIN['static_delay_ms'];
-$_sendspin_select['name'] = $cfgSENDSPIN['name'];
-// $_sendspin_select['client_id'] = $cfgSENDSPIN['client_id'];
-$_sendspin_select['audio_device'] = $cfgSENDSPIN['audio_device'];
-
-waitWorker('sendspin_config');
-
-$tpl = "sen-config.html";
-$section = basename(__FILE__, '.php');
-storeBackLink($section, $tpl);
-
-include('header.php');
-eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");");
-include('footer.php');
diff --git a/www/setup_3rdparty_sendspin.txt b/www/setup_3rdparty_sendspin.txt
index 0d62b3bb9..47cdd3672 100644
--- a/www/setup_3rdparty_sendspin.txt
+++ b/www/setup_3rdparty_sendspin.txt
@@ -2,7 +2,7 @@
#
# Setup Guide for sendspin-cli 3rd party component
#
-# Version: 1.0 2026-01-16
+# Version: 1.0 2026-02-28
#
################################################################################
@@ -19,15 +19,9 @@ See https://github.com/Sendspin/sendspin-cli for more information.
INSTALLATION
-To install sendspin-cli in moOde, SSH to your Raspberry Pi and install uv / sendspin:
+To install sendspin-cli in moOde, SSH to your Raspberry Pi and start the installer script to configure Sendspin as daemon.
-$ sudo pip install uv --break-system-packages
-$ sudo uv tool install sendspin@4.0.0
-
-To check if the Sendspin server is listed and the desired audio device is available:
-
-$ sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-servers
-$ sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --list-audio-devices
+$ curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cli/refs/heads/main/scripts/systemd/install-systemd.sh | sudo bash
TROUBLESHOOTING
@@ -35,7 +29,13 @@ Trying to playing both from moOde via MPD and to the Sendspin renderer at the sa
Change moOde's audio output to a different device while playing through the Sendspin renderer prevents this error.
-This issue might be fixed in a future release of moOde.
+This issue might be fixed in the future release.
+
+To check if the Sendspin server is listed and the desired audio device is available:
+
+$ sudo /home/sendspin/.local/share/uv/tools/sendspin/bin/sendspin --list-servers
+$ sudo /home/sendspin/.local/share/uv/tools/sendspin/bin/sendspin --list-audio-devices
+
################################################################################
# Post questions regarding this guide to http://moodeaudio.org/forum
diff --git a/www/templates/ren-config.html b/www/templates/ren-config.html
index bfb41488e..7906278bf 100644
--- a/www/templates/ren-config.html
+++ b/www/templates/ren-config.html
@@ -418,7 +418,7 @@ Renderers
- Requires sendspin-cli, view the Setup guide.
+ Requires sendspin-cli, view the setup guide.
@@ -430,14 +430,10 @@
Renderers
- Sendspin is a project from the Open Home Foundation.
+ Sendspin is a project from the Open Home Foundation.
-
-
-
Sendspin settings
-
diff --git a/www/templates/sen-config.html b/www/templates/sen-config.html
deleted file mode 100644
index 14b4f0cb2..000000000
--- a/www/templates/sen-config.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
From 1b35d61fb19b7a79b4e0a1caeee3148d7d0a1ee7 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 22:33:57 +0100
Subject: [PATCH 09/11] fix gulp-chown io_uring error
---
gulpfile.js | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/gulpfile.js b/gulpfile.js
index ae49e3e5d..1ba79a748 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -399,7 +399,7 @@ gulp.task('patchheader', function (done) {
.pipe($.if('header.php', $.cacheBust({
type: 'timestamp'
})) )
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe($.size({showFiles: true, total: false}))
.pipe(gulp.dest(DEPLOY_LOCATION))
.on('end', done);
@@ -415,7 +415,7 @@ gulp.task('patchfooter', function (done) {
.pipe($.rename(function (path) {
path.basename += '.min';
}))
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe($.size({showFiles: true, total: false}))
.pipe(gulp.dest(DEPLOY_LOCATION))
.on('end', done);
@@ -426,7 +426,7 @@ gulp.task('patchindex', function (done) {
.pipe($.if(!mode.force(), $.newer( { dest: pkg.app.dist})))
.pipe($.replace(/indextpl[.]html/g, "indextpl.min.html"))
.pipe($.replace(/footer[.]php/g, "footer.min.php"))
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe($.size({showFiles: true, total: false}))
.pipe(gulp.dest(DEPLOY_LOCATION))
.on('end', done);
@@ -436,7 +436,7 @@ gulp.task('patchconfigs', function (done) {
return gulp.src(pkg.app.src+'/*-config.php')
.pipe($.if(!mode.force(), $.newer( { dest: pkg.app.dist})))
.pipe($.replace(/footer[.]php/g, "footer.min.php"))
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe($.size({showFiles: true, total: false}))
.pipe(gulp.dest(DEPLOY_LOCATION))
.on('end', done);
@@ -451,7 +451,7 @@ gulp.task('minifyhtml', function (done) {
.pipe($.rename(function (path) {
path.basename += '.min';
}))
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe($.size({showFiles: true, total: false}))
.pipe(gulp.dest(DEPLOY_LOCATION+'/templates'))
.on('end', done);
@@ -515,7 +515,7 @@ gulp.task('deployback', gulp.series(['patchheader','patchfooter', 'patchindex',
// optional headers fields can be update and or added:
//.pipe( $.replaceTask({ patterns: REPLACEMENT_PATTERNS }))
//.pipe($.if('*.html', $.header(banner_html, {pkg: pkg}) ))
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe($.if(!mode.force(), $.newer( { dest: DEPLOY_LOCATION})))
//.pipe($.size({showFiles: true, total: true}))
.pipe($.if('*.html', $.replaceTask({ patterns: REPLACEMENT_PATTERNS})))
@@ -527,7 +527,7 @@ gulp.task('deployback', gulp.series(['patchheader','patchfooter', 'patchindex',
gulp.task('deployfront', function (done) {
return gulp.src( [pkg.app.dest+'/**/*', '!'+pkg.app.dest+'/index.html'], { encoding: false } )
.pipe($.if(!mode.force(), $.newer( { dest: DEPLOY_LOCATION})))
- .pipe($.if(!(mode.test()||mode.remote()), $.chown('root','root')))
+ .pipe($.if(!(mode.test()||mode.remote()), $.chown(0o644)))
.pipe(gulp.dest(DEPLOY_LOCATION))
.on('end', done);
});
From 5c8244e2716680dc75e9f4b5ddd3e2f94dad5d38 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 22:34:27 +0100
Subject: [PATCH 10/11] fix bitmask
---
www/inc/constants.php | 8 ++++----
www/js/playerlib.js | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/www/inc/constants.php b/www/inc/constants.php
index 3e4cce5fd..e2f3ebb87 100755
--- a/www/inc/constants.php
+++ b/www/inc/constants.php
@@ -194,24 +194,24 @@
const FEAT_HTTPS = 1; // y HTTPS mode
const FEAT_AIRPLAY = 2; // y AirPlay renderer
const FEAT_MINIDLNA = 4; // y DLNA server
-const FEAT_RECORDER = 8; // Stream recorder
+const FEAT_RECORDER = 8; // n Stream recorder
const FEAT_SQUEEZELITE = 16; // y Squeezelite renderer
const FEAT_UPMPDCLI = 32; // y UPnP client for MPD
const FEAT_DEEZER = 64; // n Deezer Connect renderer
const FEAT_ROONBRIDGE = 128; // y RoonBridge renderer
const FEAT_LOCALDISPLAY = 256; // y Local display
const FEAT_INPSOURCE = 512; // y Input source select
-const FEAT_UPNPSYNC = 1024; // UPnP volume sync
+const FEAT_UPNPSYNC = 1024; // n UPnP volume sync
const FEAT_SPOTIFY = 2048; // y Spotify Connect renderer
const FEAT_GPIO = 4096; // y GPIO button handler
const FEAT_PLEXAMP = 8192; // y Plexamp renderer
const FEAT_BLUETOOTH = 16384; // y Bluetooth renderer
-const FEAT_DEVTWEAKS = 32768; // Developer tweaks
+const FEAT_DEVTWEAKS = 32768; // n Developer tweaks
const FEAT_MULTIROOM = 65536; // y Multiroom audio
const FEAT_PEPPYDISPLAY = 131072; // y Peppy display
const FEAT_SENDSPIN = 262144; // y Sendspin renderer
// ------
-// 524287
+// 490423
// Selective resampling bitmask
const SOX_UPSAMPLE_ALL = 3; // Upsample if source < target rate
diff --git a/www/js/playerlib.js b/www/js/playerlib.js
index 8d4fa279e..a05f750d4 100755
--- a/www/js/playerlib.js
+++ b/www/js/playerlib.js
@@ -25,7 +25,7 @@ const FEAT_MULTIROOM = 65536; // y Multiroom audio
const FEAT_PEPPYDISPLAY = 131072; // y Peppy display
const FEAT_SENDSPIN = 262144; // y Sendspin renderer
// -------
-// 524287
+// 490423
// Notifications
const NOTIFY_TITLE_INFO = ' Info';
From b8eefaf76336a1f2a6a4467d307b63dcb9a779f1 Mon Sep 17 00:00:00 2001
From: Mathieu Markus <72262120+m-mrks@users.noreply.github.com>
Date: Sat, 28 Feb 2026 22:35:12 +0100
Subject: [PATCH 11/11] remove Sendspin config helper functions
---
www/inc/renderer.php | 16 ----------------
www/inc/sql.php | 24 +-----------------------
2 files changed, 1 insertion(+), 39 deletions(-)
diff --git a/www/inc/renderer.php b/www/inc/renderer.php
index 53163bf4f..d5ce4533d 100644
--- a/www/inc/renderer.php
+++ b/www/inc/renderer.php
@@ -377,22 +377,6 @@ function startSendspin() {
function stopSendspin() {
sysCmd('systemctl stop sendspin');
}
-function cfgSendspin() {
- // example, minimal 'required config' for /root/.config/sendspin/settings-daemon.json
- //
- // {
- // "static_delay_ms": 0.0,
- // "name": "moOde Sendspin Audio Client",
- // "client_id": "moode-sendspin",
- // "audio_device": "snd_rpi_hifiberry_dacplus",
- // "use_mpris": false
- // }
-
- $filename = '/root/.config/sendspin/settings-daemon.json';
- $data = sqlRead(table:'cfg_sendspin', dbh: sqlConnect(), format: 'json');
-
- file_put_contents($filename, $data);
-}
// Stop all renderers
function stopAllRenderers() {
diff --git a/www/inc/sql.php b/www/inc/sql.php
index 956496ee9..e216b1958 100644
--- a/www/inc/sql.php
+++ b/www/inc/sql.php
@@ -16,7 +16,7 @@ function sqlConnect() {
}
}
-function sqlRead($table, $dbh, $param = '', $id = '', $format = 'array') {
+function sqlRead($table, $dbh, $param = '', $id = '') {
if (empty($param) && empty($id)) {
$queryStr = 'SELECT * FROM ' . $table;
} else if (!empty($id)) {
@@ -38,28 +38,6 @@ function sqlRead($table, $dbh, $param = '', $id = '', $format = 'array') {
}
$rows = sqlQuery($queryStr, $dbh);
- if ($format === 'json') {
- $clean = [];
- foreach ($rows as $row) {
- $param = $row['param'];
- $value = $row['value'];
-
- // Universal type detection and conversion
- if ($value === '' || $value === null) {
- $clean[$param] = null;
- } elseif (is_numeric($value) && strpos($value, '.') !== false) {
- $clean[$param] = (float)$value; // "0.0" → 0.0
- } elseif (is_numeric($value)) {
- $clean[$param] = (int)$value; // "123" → 123
- } elseif (in_array(strtolower($value), ['true', 'false', 'yes', 'no', '1', '0'])) {
- $clean[$param] = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
- } else {
- $clean[$param] = $value; // String
- }
- }
- return json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- }
-
return $rows;
}