' :
@@ -2033,7 +2033,7 @@ function renderFolderView(data, path, searchstr) {
else if (data[i].playlist && !cueVirtualDir) {
// NOTE: Skip wavpack since it may contain embedded playlist and they are not supported yet in Folder view
if (data[i].playlist.substr(data[i].playlist.lastIndexOf('.') + 1).toLowerCase() != 'wv') {
- output += '
+
+ Music database
+
+
+ This process analyses the Music database to produce accurate artist/album/track counts.
+ Note: This can take a while if there are a large number of number of tracks.
+
+ $_mpd_db_stats
+
+
Library tag cache
@@ -206,6 +217,21 @@
Regenerate Music Database?
+
+
-
-
@@ -485,22 +481,6 @@
Restart AirPlay renderer?
-
-
@@ -591,3 +571,16 @@
Restart RoonBridge renderer?
+
+
+
+
+
Restart SendSpin renderer?
+
+
+
+
+
diff --git a/www/templates/ssp-config.html b/www/templates/ssp-config.html
new file mode 100644
index 000000000..bf5e322d2
--- /dev/null
+++ b/www/templates/ssp-config.html
@@ -0,0 +1,70 @@
+
+
+
+
SendSpin
+
+
+
+
+
+
+
Version
+
+
+
+
+
+
+
+
+
+
+
+
+ $_select[sendspin_update_btn]
+ Update SendSpin
+
+
+ Updates the SendSpin CLI to the latest version using uv tool upgrade. The SendSpin service will be restarted after the update.
+
+
+
+
Metadata
+
+
+
+
+ Track metadata and cover art are polled from Home Assistant via the sendspin-metadata-sink service.
+
+
+
+
Volume control
+
+
+
+
+ Software volume (internal). Hardware volume control is disabled because the USB DAC has no hardware mixer.
+ Volume is controlled from the Music Assistant controller.
+
+
+
+
ALSA device
+
+
+
+
+ sendspin (routes through ALSA plug β plughw:0,0 β SMSL USB AUDIO)
+
+
-
- Track metadata and cover art are polled from Home Assistant via the sendspin-metadata-sink service.
+
+
+
+ Audio codec for the SendSpin stream. FLAC is lossless and recommended. PCM is uncompressed but uses more bandwidth.
+
+
+
+
+
+
+
+
+ Sample rate in Hz. 48000 is the default and recommended for most setups. Higher rates increase bandwidth but may improve quality with capable DACs.
+
+
+
+
+
+
+
+
+ Bit depth for audio samples. 16 bit is CD quality. 24 bit provides more headroom. Higher values may not be supported by all DACs.
+
+
+
+
Sync tuning
+
+
+
+
+
+
+ Extra playback delay in milliseconds applied after clock sync. Increase if SendSpin audio is ahead of other rooms. Default: 0.
+
+
+
+
Logging
+
+
+
+
+
+
+ Verbosity of the SendSpin daemon log. DEBUG for troubleshooting, INFO for normal operation, WARNING/ERROR to reduce log noise.
@@ -50,8 +100,8 @@
SendSpin
- Software volume (internal). Hardware volume control is disabled because the USB DAC has no hardware mixer.
- Volume is controlled from the Music Assistant controller.
+ Software volume (internal). Set via --hardware-volume false in the service file.
+ The SMSL USB DAC has no hardware mixer, so the SendSpin daemon applies volume digitally.
@@ -60,7 +110,7 @@
SendSpin
- sendspin (routes through ALSA plug β plughw:0,0 β SMSL USB AUDIO)
+ sendspin (routes through ALSA plug → plughw:0,0 → SMSL USB AUDIO)
From ba1c5d124b4e5ecfe699e263522f0a9b08a97c17 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Wed, 24 Jun 2026 18:09:37 +0000
Subject: [PATCH 019/274] Live config: generateSendspinService() regenerates
service file from DB on save
---
www/inc/renderer.php | 165 ++++++++++++++++++++++++++++++++++++++++++-
www/ssp-config.php | 4 +-
2 files changed, 167 insertions(+), 2 deletions(-)
diff --git a/www/inc/renderer.php b/www/inc/renderer.php
index 6d4907745..0962d5952 100644
--- a/www/inc/renderer.php
+++ b/www/inc/renderer.php
@@ -379,6 +379,76 @@ function stopRoonBridge() {
sendFECmd('rbactive0');
}
+// Sendspin
+// ALSA dmix plugin configuration for shared audio device access
+const SENDSPIN_ALSA_CONF = '/etc/alsa/conf.d/_audioout.conf';
+
+function configureAlsaForSendspin($enable) {
+ // NOTE: SendSpin uses direct hardware access via sendspin.conf
+ // The dmix approach has IPC key issues with moOde's _audioout configuration
+ // Using type plug with hw:0,0 provides reliable operation
+ workerLog('configureAlsaForSendspin(): ' . ($enable ? 'shared' : 'exclusive') . ' mode (direct hw)');
+ return true;
+}
+
+function getSendspinStatus() {
+ // Check systemd service status safely
+ $result = sysCmd('systemctl is-active sendspin 2>/dev/null');
+ $status = (!empty($result) && isset($result[0])) ? $result[0] : 'inactive';
+ if ($status === 'active') {
+ // Check if actually streaming (process using audio)
+ $sndResult = sysCmd('fuser /dev/snd/pcmC0D0p 2>/dev/null');
+ if (!empty($sndResult)) {
+ // Check if sendspin is using the device
+ $sendspinPids = sysCmd('pgrep -f sendspin 2>/dev/null');
+ foreach ($sendspinPids as $pid) {
+ if (strpos($sndResult[0], $pid) !== false) {
+ return 'streaming';
+ }
+ }
+ }
+ return 'ready';
+ }
+ return 'inactive';
+}
+
+function startSendspin() {
+ // Save MPD state before starting
+ $mpdStatus = sysCmd('mpc status')[0];
+ $mpdWasPlaying = strpos($mpdStatus, 'playing') !== false;
+ phpSession('write', 'mpd_was_playing', $mpdWasPlaying ? '1' : '0');
+
+ // Stop MPD to release ALSA device
+ sysCmd('mpc stop');
+
+ // Configure ALSA for shared access
+ configureAlsaForSendspin(true);
+
+ // Start SendSpin daemon
+ sysCmd('systemctl start sendspin');
+tsysCmd('systemctl enable sendspin');
+
+ workerLog('startSendspin(): daemon started (MPD was playing: ' . ($mpdWasPlaying ? 'yes' : 'no') . ')');
+}
+
+function stopSendspin() {
+ // Stop SendSpin daemon
+ sysCmd('systemctl stop sendspin');
+tsysCmd('systemctl disable sendspin');
+
+ // Restore ALSA to exclusive mode
+ configureAlsaForSendspin(false);
+
+ // Optionally resume MPD if it was playing
+ if ($_SESSION['mpd_was_playing'] == '1') {
+ sysCmd('mpc play');
+ phpSession('write', 'mpd_was_playing', '0');
+ workerLog('stopSendspin(): MPD playback resumed');
+ }
+
+ workerLog('stopSendspin(): daemon stopped');
+}
+
// Stop all renderers
function stopAllRenderers() {
$renderers = array(
@@ -389,7 +459,8 @@ function stopAllRenderers() {
'upnpsvc' => 'stopUPnP',
'slsvc' => 'stopSqueezeLite',
'pasvc' => 'stopPlexamp',
- 'rbsvc' => 'stopRoonBridge'
+ 'rbsvc' => 'stopRoonBridge',
+ 'sendspinsvc' => 'stopSendspin'
);
// Watchdog (so monitored renderers are not auto restarted)
@@ -404,3 +475,95 @@ function stopAllRenderers() {
}
}
}
+
+// === Release 2: Advanced Functions ===
+
+function getSendspinVersion() {
+ $result = sysCmd('sendspin --version 2>/dev/null');
+ $version = (!empty($result) && isset($result[0])) ? trim($result[0]) : 'unknown';
+ return $version;
+}
+
+function getSendspinMetadata() {
+ if (file_exists(SENDSPINMETA_FILE)) {
+ $meta = file_get_contents(SENDSPINMETA_FILE);
+ return $meta;
+ }
+ return '';
+}
+
+function checkSendspinUpdate() {
+ $result = sysCmd('sendspin-version-check.sh 2>/dev/null');
+ $json = (!empty($result) && isset($result[0])) ? $result[0] : '{}';
+ return $json;
+}
+
+function updateSendspin() {
+ sysCmd('uv tool upgrade sendspin 2>&1');
+ sleep(2);
+ sysCmd('systemctl restart sendspin 2>/dev/null');
+ workerLog('updateSendspin(): SendSpin updated and restarted');
+ return true;
+}
+
+function generateSendspinService() {
+ // Read config from DB
+ $dbh = sqlConnect();
+ $result = sqlRead('cfg_sendspin', $dbh);
+ $cfg = array();
+ foreach ($result as $row) {
+ $cfg[$row['param']] = $row['value'];
+ }
+
+ $codec = $cfg['audio_codec'] ?? 'flac';
+ $rate = $cfg['audio_rate'] ?? '48000';
+ $depth = $cfg['audio_depth'] ?? '16';
+ $delay = $cfg['static_delay_ms'] ?? '0';
+ $log_level = $cfg['log_level'] ?? 'INFO';
+
+ $audio_format = "{$codec}:{$rate}:{$depth}:2";
+
+ $service = << NOTIFY_TITLE_INFO, 'msg' => 'SendSpin will apply settings on next restart');
+ $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings applied (service restarted)');
} else {
$notify = array('title' => '', 'msg' => '');
}
From a8c6f18c513e31c1581454be7ba0e769c49cbb8e Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Wed, 24 Jun 2026 18:22:14 +0000
Subject: [PATCH 020/274] Fix: only show overlay on main page (not config
pages)
---
www/js/sendspin-display.js | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/www/js/sendspin-display.js b/www/js/sendspin-display.js
index c127a018e..91c6a0f21 100644
--- a/www/js/sendspin-display.js
+++ b/www/js/sendspin-display.js
@@ -11,6 +11,12 @@
(function() {
'use strict';
+ // Only run on the main playback page, not on config pages
+ if (window.location.pathname !== '/' &&
+ window.location.pathname !== '/index.php') {
+ return; // Don't show overlay on config pages
+ }
+
var pollTimer = null;
var overlayshown = false;
From 481d62b9371f4531d2f3c0848a6f78e52a935f0d Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Wed, 24 Jun 2026 18:40:29 +0000
Subject: [PATCH 021/274] Fix: load feat_bitmask from DB if session is empty
(incognito/cookie issue)
---
www/ren-config.php | 170 +++++++++++++++++++++------------------------
1 file changed, 79 insertions(+), 91 deletions(-)
diff --git a/www/ren-config.php b/www/ren-config.php
index 802ddfc6f..be3e13901 100644
--- a/www/ren-config.php
+++ b/www/ren-config.php
@@ -27,6 +27,9 @@
if (isset($_POST['btsvc']) && $_POST['btsvc'] != $_SESSION['btsvc']) {
$update = true;
phpSession('write', 'btsvc', $_POST['btsvc']);
+ if ($_POST['btsvc'] == '0') {
+ phpSession('write', 'pairing_agent', '0');
+ }
}
if (isset($update)) {
submitJob('btsvc', '"' . $currentBtName . '" ' . '"' . $_POST['btname'] . '"');
@@ -58,10 +61,6 @@
}
// AirPlay
-if (isset($_POST['install_airplay'])) {
- submitJob('install_airplay');
- header('location: ren-status.php');
-}
if (isset($_POST['update_airplay_settings'])) {
if (isset($_POST['airplayname']) && $_POST['airplayname'] != $_SESSION['airplayname']) {
$update = true;
@@ -83,10 +82,6 @@
}
// Spotify Connect
-if (isset($_POST['install_spotify'])) {
- submitJob('install_spotify');
- header('location: ren-status.php');
-}
if (isset($_POST['update_spotify_settings'])) {
if (isset($_POST['spotifyname']) && $_POST['spotifyname'] != $_SESSION['spotifyname']) {
$update = true;
@@ -110,6 +105,24 @@
submitJob('spotify_clear_credentials', '', NOTIFY_TITLE_INFO, 'Credential cache cleared');
}
+// SendSpin Multi-Room Audio
+if (isset($_POST['update_sendspin_settings'])) {
+ if (isset($_POST['sendspinsvc']) && $_POST['sendspinsvc'] != $_SESSION['sendspinsvc']) {
+ $update = true;
+ phpSession('write', 'sendspinsvc', $_POST['sendspinsvc']);
+ }
+ if (isset($_POST['sendspinname']) && $_POST['sendspinname'] != $_SESSION['sendspinname']) {
+ $update = true;
+ phpSession("write", 'sendspinname', $_POST['sendspinname']);
+ }
+ if (isset($update)) {
+ submitJob('sendspinsvc');
+ }
+}
+if (isset($_POST['sendspinrestart']) && $_POST['sendspinrestart'] == 1 && $_SESSION['sendspinsvc'] == '1') {
+ submitJob('sendspinsvc', '', NOTIFY_TITLE_INFO, 'SendSpin' . NOTIFY_MSG_SVC_MANUAL_RESTART);
+}
+
// Deezer Connect
if (isset($_POST['update_deezer_settings'])) {
if (isset($_POST['deezername']) && $_POST['deezername'] != $_SESSION['deezername']) {
@@ -131,25 +144,6 @@
submitJob('deezersvc', '', NOTIFY_TITLE_INFO, NAME_DEEZER . NOTIFY_MSG_SVC_MANUAL_RESTART);
}
-// UPnP client for MPD
-if (isset($_POST['update_upnp_settings'])) {
- $currentUpnpName = $_SESSION['upnpname'];
- if (isset($_POST['upnpname']) && $_POST['upnpname'] != $_SESSION['upnpname']) {
- $update = true;
- phpSession('write', 'upnpname', $_POST['upnpname']);
- }
- if (isset($_POST['upnpsvc']) && $_POST['upnpsvc'] != $_SESSION['upnpsvc']) {
- $update = true;
- phpSession('write', 'upnpsvc', $_POST['upnpsvc']);
- }
- if (isset($update)) {
- submitJob('upnpsvc', '"' . $currentUpnpName . '" ' . '"' . $_POST['upnpname'] . '"');
- }
-}
-if (isset($_POST['upnprestart']) && $_POST['upnprestart'] == 1 && $_SESSION['upnpsvc'] == '1') {
- submitJob('upnpsvc', '', NOTIFY_TITLE_INFO, NAME_UPNP . NOTIFY_MSG_SVC_MANUAL_RESTART);
-}
-
// Squeezelite
if (isset($_POST['update_sl_settings'])) {
if (isset($_POST['slsvc']) && $_POST['slsvc'] != $_SESSION['slsvc']) {
@@ -171,6 +165,25 @@
submitJob('slrestart', '', NOTIFY_TITLE_INFO, NAME_SQUEEZELITE . NOTIFY_MSG_SVC_MANUAL_RESTART);
}
+// UPnP client for MPD
+if (isset($_POST['update_upnp_settings'])) {
+ $currentUpnpName = $_SESSION['upnpname'];
+ if (isset($_POST['upnpname']) && $_POST['upnpname'] != $_SESSION['upnpname']) {
+ $update = true;
+ phpSession('write', 'upnpname', $_POST['upnpname']);
+ }
+ if (isset($_POST['upnpsvc']) && $_POST['upnpsvc'] != $_SESSION['upnpsvc']) {
+ $update = true;
+ phpSession('write', 'upnpsvc', $_POST['upnpsvc']);
+ }
+ if (isset($update)) {
+ submitJob('upnpsvc', '"' . $currentUpnpName . '" ' . '"' . $_POST['upnpname'] . '"');
+ }
+}
+if (isset($_POST['upnprestart']) && $_POST['upnprestart'] == 1 && $_SESSION['upnpsvc'] == '1') {
+ submitJob('upnpsvc', '', NOTIFY_TITLE_INFO, NAME_UPNP . NOTIFY_MSG_SVC_MANUAL_RESTART);
+}
+
// Plexamp
if (isset($_POST['update_pa_settings'])) {
if (isset($_POST['pasvc']) && $_POST['pasvc'] != $_SESSION['pasvc']) {
@@ -210,6 +223,14 @@
phpSession('close');
+// If session is empty (e.g. incognito/no cookie), load feat_bitmask from DB
+if (!isset($_SESSION['feat_bitmask'])) {
+ $result = sqlQuery("SELECT value FROM cfg_system WHERE param='feat_bitmask'", $dbh);
+ if (!empty($result)) {
+ $_SESSION['feat_bitmask'] = $result[0]['value'];
+ }
+}
+
// Bluetooth
$_feat_bluetooth = $_SESSION['feat_bitmask'] & FEAT_BLUETOOTH ? '' : 'hide';
$_SESSION['btsvc'] == '1' ? $_bt_btn_disable = '' : $_bt_btn_disable = 'disabled';
@@ -252,27 +273,9 @@
// AirPlay
$_feat_airplay = $_SESSION['feat_bitmask'] & FEAT_AIRPLAY ? '' : 'hide';
-if (isAirPlayInstalled() === true) {
- $_airplay_installed_version = sysCmd('dpkg-query --showformat=\'${Version}\n\' --show shairport-sync | grep moode')[0];
- if (isAirPlayUpgradable() === true) {
- $_install_airplay_hide = '';
- $_airplay_btn_text = 'Upgrade';
- $_airplay_available_version = 'To version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='airplay'", $dbh)[0]['version'];
- } else {
- $_install_airplay_hide = 'hide';
- }
- $_airplay_svcbtn_disable = '';
- $_airplay_editlink_disable = '';
-} else {
- $_install_airplay_hide = '';
- $_airplay_btn_text = 'Install';
- $_airplay_available_version = 'Version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='airplay'", $dbh)[0]['version'];
- $_airplay_svcbtn_disable = 'disabled';
- $_airplay_editlink_disable = 'onclick="return false;"';
-}
$_SESSION['airplaysvc'] == '1' ? $_airplay_btn_disable = '' : $_airplay_btn_disable = 'disabled';
$_SESSION['airplaysvc'] == '1' ? $_airplay_link_disable = '' : $_airplay_link_disable = 'onclick="return false;"';
-$autoClick = " onchange=\"autoClick('#btn-set-airplaysvc');\" " . $_airplay_svcbtn_disable;
+$autoClick = " onchange=\"autoClick('#btn-set-airplaysvc');\"";
$_select['airplaysvc_on'] .= "\n";
$_select['airplaysvc_off'] .= "\n";
$_select['airplayname'] = $_SESSION['airplayname'];
@@ -282,27 +285,9 @@
// Spotify Connect
$_feat_spotify = $_SESSION['feat_bitmask'] & FEAT_SPOTIFY ? '' : 'hide';
-if (isSpotifyInstalled() === true) {
- $_spotify_installed_version = sysCmd('dpkg-query --showformat=\'${Version}\n\' --show librespot | grep moode')[0];
- if (isSpotifyUpgradable() === true) {
- $_install_spotify_hide = '';
- $_spotify_btn_text = 'Upgrade';
- $_spotify_available_version = 'To version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='spotify-connect'", $dbh)[0]['version'];
- } else {
- $_install_spotify_hide = 'hide';
- }
- $_spotify_svcbtn_disable = '';
- $_spotify_editlink_disable = '';
-} else {
- $_install_spotify_hide = '';
- $_spotify_btn_text = 'Install';
- $_spotify_available_version = 'Version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='spotify-connect'", $dbh)[0]['version'];
- $_spotify_svcbtn_disable = 'disabled';
- $_spotify_editlink_disable = 'onclick="return false;"';
-}
$_SESSION['spotifysvc'] == '1' ? $_spotify_btn_disable = '' : $_spotify_btn_disable = 'disabled';
$_SESSION['spotifysvc'] == '1' ? $_spotify_link_disable = '' : $_spotify_link_disable = 'onclick="return false;"';
-$autoClick = " onchange=\"autoClick('#btn-set-spotifysvc');\" " . $_spotify_svcbtn_disable;
+$autoClick = " onchange=\"autoClick('#btn-set-spotifysvc');\"";
$_select['spotifysvc_on'] .= "\n";
$_select['spotifysvc_off'] .= "\n";
$_select['spotifyname'] = $_SESSION['spotifyname'];
@@ -335,17 +320,6 @@
$_select['rsmafterdeez_on'] .= "\n";
$_select['rsmafterdeez_off'] .= "\n";
-// UPnP client for MPD
-$_feat_upmpdcli = $_SESSION['feat_bitmask'] & FEAT_UPMPDCLI ? '' : 'hide';
-$_SESSION['upnpsvc'] == '1' ? $_upnp_btn_disable = '' : $_upnp_btn_disable = 'disabled';
-$_SESSION['upnpsvc'] == '1' ? $_upnp_link_disable = '' : $_upnp_link_disable = 'onclick="return false;"';
-$_SESSION['dlnasvc'] == '1' ? $_dlna_btn_disable = '' : $_dlna_btn_disable = 'disabled';
-$_SESSION['dlnasvc'] == '1' ? $_dlna_link_disable = '' : $_dlna_link_disable = 'onclick="return false;"';
-$autoClick = " onchange=\"autoClick('#btn-set-upnpsvc');\"";
-$_select['upnpsvc_on'] .= "\n";
-$_select['upnpsvc_off'] .= "\n";
-$_select['upnpname'] = $_SESSION['upnpname'];
-
// Squeezelite
$_feat_squeezelite = $_SESSION['feat_bitmask'] & FEAT_SQUEEZELITE ? '' : 'hide';
$_SESSION['slsvc'] == '1' ? $_sl_btn_disable = '' : $_sl_btn_disable = 'disabled';
@@ -357,16 +331,21 @@
$_select['rsmaftersl_on'] .= "\n";
$_select['rsmaftersl_off'] .= "\n";
+// UPnP client for MPD
+$_feat_upmpdcli = $_SESSION['feat_bitmask'] & FEAT_UPMPDCLI ? '' : 'hide';
+$_SESSION['upnpsvc'] == '1' ? $_upnp_btn_disable = '' : $_upnp_btn_disable = 'disabled';
+$_SESSION['upnpsvc'] == '1' ? $_upnp_link_disable = '' : $_upnp_link_disable = 'onclick="return false;"';
+$_SESSION['dlnasvc'] == '1' ? $_dlna_btn_disable = '' : $_dlna_btn_disable = 'disabled';
+$_SESSION['dlnasvc'] == '1' ? $_dlna_link_disable = '' : $_dlna_link_disable = 'onclick="return false;"';
+$autoClick = " onchange=\"autoClick('#btn-set-upnpsvc');\"";
+$_select['upnpsvc_on'] .= "\n";
+$_select['upnpsvc_off'] .= "\n";
+$_select['upnpname'] = $_SESSION['upnpname'];
+
// Plexamp
if (($_SESSION['feat_bitmask'] & FEAT_PLEXAMP)) {
$_feat_plexamp = '';
- if ($_SESSION['plexamp_installed'] == 'yes') {
- $_pa_svcbtn_disable = '';
- $_pa_not_installed_msg = 'hide';
- } else {
- $_pa_svcbtn_disable = 'disabled';
- $_pa_not_installed_msg = '';
- }
+ $_SESSION['plexamp_installed'] == 'yes' ? $_pa_svcbtn_disable = '' : $_pa_svcbtn_disable = 'disabled';
$_SESSION['pasvc'] == '1' ? $_pa_btn_disable = '' : $_pa_btn_disable = 'disabled';
$_SESSION['pasvc'] == '1' ? $_pa_link_disable = '' : $_pa_link_disable = 'onclick="return false;"';
$autoClick = " onchange=\"autoClick('#btn-set-pasvc');\" " . $_pa_svcbtn_disable;
@@ -393,13 +372,7 @@
// RoonBridge
if (($_SESSION['feat_bitmask'] & FEAT_ROONBRIDGE)) {
$_feat_roonbridge = '';
- if ($_SESSION['roonbridge_installed'] == 'yes') {
- $_rb_svcbtn_disable = '';
- $_rb_not_installed_msg = 'hide';
- } else {
- $_rb_svcbtn_disable = 'disabled';
- $_rb_not_installed_msg = '';
- }
+ $_SESSION['roonbridge_installed'] == 'yes' ? $_rb_svcbtn_disable = '' : $_rb_svcbtn_disable = 'disabled';
$_SESSION['rbsvc'] == '1' ? $_rb_btn_disable = '' : $_rb_btn_disable = 'disabled';
$_SESSION['rbsvc'] == '1' ? $_rb_link_disable = '' : $_rb_link_disable = 'onclick="return false;"';
$autoClick = " onchange=\"autoClick('#btn-set-rbsvc');\" " . $_rb_svcbtn_disable;
@@ -412,6 +385,20 @@
$_feat_roonbridge = 'hide';
}
+// SendSpin Multi-Room Audio
+if (($_SESSION["feat_bitmask"] & FEAT_SENDSPIN)) {
+ $_feat_sendspin = "";
+ $_SESSION["sendspin_installed"] == "yes" ? $_sendspin_svcbtn_disable = "" : $_sendspin_svcbtn_disable = "disabled";
+ $_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['sendspinname'] = $_SESSION['sendspinname'];
+ $_select["sendspinsvc_on"] = "\n";
+ $_select["sendspinsvc_off"] = "\n";
+} else {
+ $_feat_sendspin = "hide";
+}
+
waitWorker('ren-config');
$tpl = "ren-config.html";
@@ -420,4 +407,5 @@
include('header.php');
eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");");
-include('footer.php');
+include('footer.min.php');
+
From e91ce49e12f210ca24cdbb44c7bac44935fff5e6 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Wed, 24 Jun 2026 18:51:06 +0000
Subject: [PATCH 022/274] Fix: load all cfg_system into local session if
session is empty (no cookie)
---
www/ren-config.php | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/www/ren-config.php b/www/ren-config.php
index be3e13901..e11e9d72b 100644
--- a/www/ren-config.php
+++ b/www/ren-config.php
@@ -223,12 +223,15 @@
phpSession('close');
-// If session is empty (e.g. incognito/no cookie), load feat_bitmask from DB
+// If session is empty (no cookie or incognito), load all cfg_system into session
if (!isset($_SESSION['feat_bitmask'])) {
- $result = sqlQuery("SELECT value FROM cfg_system WHERE param='feat_bitmask'", $dbh);
- if (!empty($result)) {
- $_SESSION['feat_bitmask'] = $result[0]['value'];
+ $rows = sqlRead('cfg_system', $dbh);
+ foreach ($rows as $row) {
+ if (!str_contains($row['param'], 'RESERVED_')) {
+ $_SESSION[$row['param']] = $row['value'];
+ }
}
+ unset($_SESSION['wrkready']);
}
// Bluetooth
From ca2d46276e4c0b91ec17ac5145bf313218b4f037 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Wed, 24 Jun 2026 21:06:50 +0000
Subject: [PATCH 023/274] Sync all Pi-side changes before code review
---
etc/systemd/system/sendspin.service | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/etc/systemd/system/sendspin.service b/etc/systemd/system/sendspin.service
index 835e7989d..4bfef5e26 100644
--- a/etc/systemd/system/sendspin.service
+++ b/etc/systemd/system/sendspin.service
@@ -8,6 +8,8 @@ Type=simple
ExecStartPre=/var/local/www/commandw/spspre.sh
ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin daemon --audio-device sendspin --audio-format flac:48000:16:2 --name moode-sendspin \
--hardware-volume false \
+ --static-delay-ms 0 \
+ --log-level INFO \
--hook-start /var/local/www/commandw/sendspin-metadata.sh \
--hook-stop /var/local/www/commandw/sendspin-metadata.sh
ExecStopPost=/var/local/www/commandw/spspost.sh
@@ -21,4 +23,4 @@ LimitRTPRIO=99
LimitMEMLOCK=8388608
[Install]
-WantedBy=multi-user.target
+WantedBy=multi-user.target
\ No newline at end of file
From 530c9507128cff6d22a90cbefeb091e5507b8304 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Wed, 24 Jun 2026 21:44:54 +0000
Subject: [PATCH 024/274] Add thorough code review: 20 issues identified across
all SendSpin files
---
SENDSPIN_CODE_REVIEW.md | 492 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 492 insertions(+)
create mode 100644 SENDSPIN_CODE_REVIEW.md
diff --git a/SENDSPIN_CODE_REVIEW.md b/SENDSPIN_CODE_REVIEW.md
new file mode 100644
index 000000000..32b764621
--- /dev/null
+++ b/SENDSPIN_CODE_REVIEW.md
@@ -0,0 +1,492 @@
+# SendSpin Integration β Code Review
+
+**Branch:** sendspin-advanced
+**Date:** 2026-06-25
+**Reviewer:** Hermes Agent
+**Purpose:** Agent-assisted code review to identify bugs, structural issues, and improvements
+**Scope:** All SendSpin-specific files added or modified in this branch
+
+---
+
+## File Inventory
+
+| File | Status | Description |
+|------|--------|-------------|
+| `www/ren-config.php` | Modified | Renderers config page β SendSpin section added |
+| `www/ssp-config.php` | New | SendSpin settings page |
+| `www/templates/ssp-config.html` | New | SendSpin settings template |
+| `www/templates/ren-config.html` | Modified | Renderers template β SendSpin section added |
+| `www/js/sendspin-display.js` | New | JS overlay for metadata display |
+| `www/inc/renderer.php` | Modified | SendSpin renderer functions added |
+| `hooks/sendspin-metadata-sink.py` | New | HA-polling metadata sink daemon |
+| `hooks/spspre.sh` | Modified | Pre-start ALSA configuration |
+| `hooks/sendspin-metadata.sh` | New | Hook for start/stop metadata write |
+| `etc/systemd/system/sendspin.service` | New | SendSpin daemon service |
+| `etc/systemd/system/moode-worker.service` | New | moOde worker daemon (replaces rc.local) |
+| `etc/alsa/conf.d/sendspin.conf` | New | ALSA virtual device definition |
+
+---
+
+## Critical Bugs
+
+### BUG-01: Indentation error in `startSendspin()` and `stopSendspin()` β `renderer.php` lines 429, 437
+
+```php
+// startSendspin() line 429:
+tsysCmd('systemctl enable sendspin');
+
+// stopSendspin() line 437:
+tsysCmd('systemctl disable sendspin');
+```
+
+**Problem:** Both lines are prefixed with `t` instead of a tab character. PHP will interpret `tsysCmd(...)` as a call to an undefined function `tsysCmd`, causing a fatal error at runtime when these functions are called. This is most likely a copy-paste corruption or editor artifact.
+
+**Fix:**
+```php
+sysCmd('systemctl enable sendspin');
+// and
+sysCmd('systemctl disable sendspin');
+```
+
+---
+
+### BUG-02: `generateSendspinService()` calls `sqlConnect()` while caller already holds a connection β `renderer.php` line 511
+
+```php
+function generateSendspinService() {
+ $dbh = sqlConnect(); // <-- opens second connection
+ ...
+}
+```
+
+**Problem:** This function is called from `ssp-config.php` which already holds `$dbh = sqlConnect()`. SQLite only supports one writer at a time; a second concurrent connection during the save handler can cause a lock error (`SQLITE_BUSY`). During testing this caused PHP-FPM to hang completely when `phpSession('load_system')` was also calling `sqlConnect()`.
+
+**Fix:** Pass `$dbh` as a parameter instead of opening a new connection.
+
+```php
+function generateSendspinService($dbh = null) {
+ if ($dbh === null) {
+ $dbh = sqlConnect();
+ }
+ $result = sqlRead('cfg_sendspin', $dbh);
+ ...
+}
+```
+
+And in `ssp-config.php`:
+```php
+generateSendspinService($dbh);
+```
+
+---
+
+### BUG-03: `ssp-config.php` save handler calls `submitJob('sendspinsvc', ...)` but does not restart the service β `ssp-config.php` lines 22β27
+
+```php
+generateSendspinService();
+if ($_SESSION['sendspinsvc'] == '1') {
+ $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings applied (service restarted)');
+} else {
+ $notify = array('title' => '', 'msg' => '');
+}
+submitJob('sendspinsvc', '', $notify['title'], $notify['msg']);
+```
+
+**Problem:** `generateSendspinService()` writes the service file and calls `systemctl daemon-reload`, but **does not restart the service**. `submitJob('sendspinsvc', ...)` queues a job for the worker to toggle the service, but the worker's `sendspinsvc` job handler toggles it on/off based on the session variable β it may turn it off if it reads `sendspinsvc == 0`. The notification says "service restarted" but this may not happen.
+
+**Fix:** After generating the service file, explicitly restart if running:
+```php
+generateSendspinService($dbh);
+if ($_SESSION['sendspinsvc'] == '1') {
+ sysCmd('sudo systemctl restart sendspin');
+ $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings applied and service restarted');
+} else {
+ $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings saved (service not running)');
+}
+```
+
+---
+
+### BUG-04: `ssp-config.php` does not load `cfg_sendspin` values from DB after save β lines 43β59
+
+```php
+// Read config from DB
+$result = sqlRead('cfg_sendspin', $dbh);
+$cfgSendspin = array();
+foreach ($result as $row) {
+ $cfgSendspin[$row['param']] = $row['value'];
+}
+```
+
+**Problem:** This read happens at the top of the file, **before** the POST save handler runs. When a user saves settings, the page is re-rendered with the **old** values (the new ones are in the DB but the read already happened). The user sees stale values until they manually refresh.
+
+**Fix:** Move the DB read **after** the POST handler block:
+```php
+// Handle save
+if (isset($_POST['save']) ...) {
+ // ... save to DB ...
+ generateSendspinService($dbh);
+}
+
+phpSession('close');
+
+// Read AFTER save so form shows updated values
+$result = sqlRead('cfg_sendspin', $dbh);
+```
+
+---
+
+### BUG-05: `sendspin-display.js` pathname check is incomplete β line 14
+
+```javascript
+if (window.location.pathname !== '/' &&
+ window.location.pathname !== '/index.php') {
+ return;
+}
+```
+
+**Problem:** This correctly prevents the overlay on config pages, but moOde uses hash-based navigation extensively (`/#configure-modal`, `/#queue-panel`, etc.). All of these land on `/` so the overlay activates even when the configure modal is open on the main page β potentially obscuring the modal. Additionally, if moOde ever serves `index.php` as a non-root path (e.g. under a subdirectory), the check will fail.
+
+**Improvement:** Rather than checking which pages to allow, consider checking which pages to block:
+```javascript
+var configPages = ['/ren-config.php', '/ssp-config.php', '/apl-config.php',
+ '/spo-config.php', '/sys-config.php'];
+var isConfigPage = configPages.some(function(p) {
+ return window.location.pathname === p;
+});
+if (isConfigPage) { return; }
+```
+
+Or more broadly β block any `.php` page that isn't `index.php`:
+```javascript
+var path = window.location.pathname;
+if (path !== '/' && path !== '/index.php' && path.endsWith('.php')) {
+ return;
+}
+```
+
+---
+
+## Significant Issues
+
+### ISSUE-01: `ren-config.php` session fallback reads cfg_system into local `$_SESSION` but doesn't persist it β lines 226β235
+
+```php
+if (!isset($_SESSION['feat_bitmask'])) {
+ $rows = sqlRead('cfg_system', $dbh);
+ foreach ($rows as $row) {
+ if (!str_contains($row['param'], 'RESERVED_')) {
+ $_SESSION[$row['param']] = $row['value'];
+ }
+ }
+ unset($_SESSION['wrkready']);
+}
+```
+
+**Problem:** This correctly loads session data when a user has no cookie (incognito/first visit), but because the session was opened and closed before this block runs, `$_SESSION` is a local variable β the data is not written back to the session file. This means the page renders correctly this time, but **the next page request will again have an empty session**, causing the same blank rendering on every page the user visits. The user has no persistent session.
+
+**Root cause:** The real fix should be to call `phpSession('load_system')` as the very first action (before any POST handling), ensuring the existing session is loaded using the stored session ID. This failed earlier due to a double `sqlConnect()` deadlock β which is actually BUG-02 causing BUG-ISSUE-01. Fix BUG-02 first, then this approach becomes safe.
+
+**Recommended fix:**
+```php
+$dbh = sqlConnect();
+
+// Always use the stored session ID so moOde's session is loaded
+$storedId = sqlQuery("SELECT value FROM cfg_system WHERE param='sessionid'", $dbh);
+if (!empty($storedId) && !empty($storedId[0]['value'])) {
+ session_id($storedId[0]['value']);
+}
+phpSession('open');
+```
+
+This should replace the current `phpSession('open')` at line 14 and the entire fallback block at lines 226β235 can be removed.
+
+---
+
+### ISSUE-02: `startSendspin()` stops MPD unconditionally β `renderer.php` line 422
+
+```php
+// Stop MPD to release ALSA device
+sysCmd('mpc stop');
+```
+
+**Problem:** This stops MPD whenever SendSpin starts β even if MPD wasn't playing. This is unnecessarily disruptive for users who have MPD idle. Other renderers (AirPlay, Spotify) do not do this; they rely on the ALSA device contention to naturally stop MPD only when audio is actually competing.
+
+**Improvement:** Only stop MPD if it was actually playing:
+```php
+if ($mpdWasPlaying) {
+ sysCmd('mpc stop');
+}
+```
+
+---
+
+### ISSUE-03: `generateSendspinService()` is not called at SendSpin install time β installer gap
+
+**Problem:** When SendSpin is first installed via `moode-sendspin-installer.sh`, the service file is written with hardcoded defaults (`flac:48000:16:2`). If the user changes settings in `ssp-config.php`, `generateSendspinService()` regenerates the service file from the DB. But if the user has never visited the config page, the DB defaults may not match the installed service file (e.g. if the installer writes a different default).
+
+**Improvement:** Call `generateSendspinService()` in the installer after creating the DB table, to ensure the service file and DB are always in sync from install.
+
+---
+
+### ISSUE-04: `getSendspinVersion()` calls `sendspin --version` but SendSpin is installed via `uv` β `renderer.php` line 482
+
+```php
+$result = sysCmd('sendspin --version 2>/dev/null');
+```
+
+**Problem:** `sendspin` may not be in `$PATH` for `www-data` processes. The binary lives at `/root/.local/share/uv/tools/sendspin/bin/sendspin`, which is only in root's PATH. This will return `unknown` for all web requests.
+
+**Fix:** Use the absolute path:
+```php
+$result = sysCmd('/root/.local/share/uv/tools/sendspin/bin/sendspin --version 2>/dev/null');
+```
+
+Or define a constant at the top of renderer.php:
+```php
+const SENDSPIN_BIN = '/root/.local/share/uv/tools/sendspin/bin/sendspin';
+```
+
+---
+
+### ISSUE-05: `updateSendspin()` uses `sleep(2)` blocking call β `renderer.php` line 503
+
+```php
+function updateSendspin() {
+ sysCmd('uv tool upgrade sendspin 2>&1');
+ sleep(2);
+ sysCmd('systemctl restart sendspin 2>/dev/null');
+```
+
+**Problem:** `sysCmd('uv tool upgrade ...')` is synchronous and can take 30β60 seconds on a slow network. This blocks the PHP-FPM worker for the duration. Combined with `sleep(2)`, this can exhaust the FPM process pool and cause timeouts for other concurrent requests.
+
+**Fix:** Run the upgrade asynchronously and handle the restart in the completion:
+```php
+function updateSendspin() {
+ sysCmd('sudo -u root bash -c "uv tool upgrade sendspin && systemctl restart sendspin" > /tmp/sendspin-update.log 2>&1 &');
+ workerLog('updateSendspin(): upgrade launched in background');
+ return true;
+}
+```
+
+---
+
+### ISSUE-06: `ssp-config.php` does not have a SendSpin-specific DB fallback for missing session β structural inconsistency with `ren-config.php`
+
+**Problem:** The session fallback fix was applied to `ren-config.php` but not to `ssp-config.php`. If a user navigates directly to `/ssp-config.php` in incognito, `$_SESSION['sendspinsvc']` will be empty, the "SendSpin will apply settings on next restart" branch may not trigger, and the page could render incorrectly.
+
+**Fix:** Apply the same session fallback to `ssp-config.php`:
+```php
+phpSession('close');
+if (!isset($_SESSION['feat_bitmask'])) {
+ $rows = sqlRead('cfg_system', $dbh);
+ foreach ($rows as $row) {
+ if (!str_contains($row['param'], 'RESERVED_')) {
+ $_SESSION[$row['param']] = $row['value'];
+ }
+ }
+ unset($_SESSION['wrkready']);
+}
+```
+
+---
+
+## Structural Issues
+
+### STRUCT-01: Mixed quoting style in `ren-config.php` SendSpin section β lines 392β403
+
+```php
+// Other renderers use single quotes consistently:
+$_feat_bluetooth = $_SESSION['feat_bitmask'] & FEAT_BLUETOOTH ? '' : 'hide';
+
+// SendSpin uses double quotes inconsistently:
+if (($_SESSION["feat_bitmask"] & FEAT_SENDSPIN)) {
+ $_feat_sendspin = "";
+ $_SESSION["sendspin_installed"] == "yes" ...
+```
+
+**Fix:** Use single quotes throughout to match the rest of the file:
+```php
+if (($_SESSION['feat_bitmask'] & FEAT_SENDSPIN)) {
+ $_feat_sendspin = '';
+ $_SESSION['sendspin_installed'] == 'yes' ...
+```
+
+---
+
+### STRUCT-02: `ssp-config.html` uses `` for delay β inconsistent with moOde UI patterns
+
+```html
+
+```
+
+**Problem:** Other moOde config pages use `
-
- Software volume (internal). Set via --hardware-volume false in the service file.
- The SMSL USB DAC has no hardware mixer, so the SendSpin daemon applies volume digitally.
+ Software volume (internal)
+
+
+ The SendSpin daemon applies volume digitally because the connected DAC has no hardware mixer. Set via --hardware-volume false in the service file. Master volume in Music Assistant controls the SendSpin output level.
-
ALSA device
+
Audio output
- sendspin (routes through ALSA plug → plughw:0,0 → SMSL USB AUDIO)
+ sendspin (routes through ALSA plug -> plughw:$_select[alsa_cardnum],0 -> $_select[alsa_devname])
+
+
+
+ The audio output chain: the SendSpin virtual device sendspin routes through the ALSA plug layer (plughw:$_select[alsa_cardnum],0) to the physical DAC. The plug layer handles format conversion so the DAC receives audio in its native format.
From 88cf79005846da6c2f13efe98bdbddd5abe8ebe0 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Fri, 26 Jun 2026 20:12:17 +0000
Subject: [PATCH 038/274] Update setup guide with config page features, deploy
template fixes
---
www/setup_3rdparty_sendspin.txt | 218 ++++++++++++++++++++++++++++++++
1 file changed, 218 insertions(+)
create mode 100644 www/setup_3rdparty_sendspin.txt
diff --git a/www/setup_3rdparty_sendspin.txt b/www/setup_3rdparty_sendspin.txt
new file mode 100644
index 000000000..244b0c050
--- /dev/null
+++ b/www/setup_3rdparty_sendspin.txt
@@ -0,0 +1,218 @@
+################################################################################
+#
+# Setup Guide for SendSpin Multi-Room Audio Renderer
+#
+# Version: 1.2 (2026-06-21)
+#
+################################################################################
+
+OVERVIEW
+
+This document provides setup instructions for using SendSpin with moOde. SendSpin
+is a synchronized multi-room audio protocol that allows moOde to act as an audio
+endpoint in a multi-room audio system.
+
+With SendSpin integration, moOde becomes a multi-room audio endpoint that can:
+- Receive synchronized audio from a SendSpin server (e.g., Music Assistant)
+- Play audio simultaneously with other SendSpin clients
+- Resume MPD playback when SendSpin streaming stops
+
+REQUIREMENTS
+
+- moOde 9.x or later
+- SendSpin CLI (sendspin) installed
+- Raspberry Pi 3/4/5 or compatible Linux system
+- Network connection to SendSpin server
+
+INSTALLATION
+
+Step 1: Install SendSpin CLI
+
+SSH to your moOde device and install SendSpin:
+
+ # Install uv (Python package manager)
+ pip3 install uv --break-system-packages
+
+ # Install sendspin-cli
+ uv tool install sendspin
+
+Verify installation:
+ sendspin --version # Should show 7.5.0 or later
+
+Step 2: Enable SendSpin in moOde
+
+1. Open moOde web UI
+2. Go to Configure β Renderers
+3. Find the "SendSpin" section
+4. Set the Name field (this appears in your controller)
+5. Toggle the Service switch to ON
+6. Click the arrow button to save
+
+Step 3: Verify in Your Controller
+
+1. Open your multi-room audio controller (e.g., Music Assistant)
+2. Your moOde device should appear with the name you configured
+3. Select it as an audio output and start playback
+4. Audio should stream to moOde
+
+CONFIGURATION OPTIONS (RENDERERS PAGE)
+
+Name:
+ The name that appears in your multi-room audio controller.
+ Default: "moode-sendspin"
+ Change this to identify your device (e.g., "Kitchen Speaker", "Living Room")
+
+Service Toggle:
+ ON - SendSpin is active and appears as an available endpoint
+ OFF - SendSpin is stopped and does not appear in the controller
+
+Resume MPD:
+ ON - MPD resumes playback when SendSpin disconnects
+ OFF - MPD stays stopped after SendSpin disconnects
+
+Restart Button:
+ Restarts the SendSpin service. Use this if the device disappears from
+ the controller or audio stops working.
+
+Edit Button:
+ Opens the SendSpin configuration page (ssp-config.php) with advanced
+ settings including audio format, delay tuning, log level, and updates.
+
+CONFIGURATION OPTIONS (CONFIG PAGE)
+
+Version:
+ Shows installed SendSpin version and latest available on PyPI.
+ Latest version check is cached for 1 hour to avoid slow page loads.
+
+Update Button:
+ Updates SendSpin CLI to the latest version using uv tool upgrade.
+ Runs in the background - the service restarts automatically.
+
+Audio Format:
+ Codec: FLAC (lossless, recommended) or PCM (uncompressed)
+ Sample Rate: 44100, 48000 (default), or 96000 Hz
+ Bit Depth: 16 (CD quality), 24, or 32 bit
+ Changes take effect on next service restart.
+
+Static Delay (ms):
+ Extra playback delay in milliseconds (0-500ms), applied on top of
+ automatic clock synchronisation. Increase if this room is slightly
+ ahead of other multi-room speakers.
+
+Log Level:
+ DEBUG - all events for troubleshooting
+ INFO - normal operational messages (default)
+ WARNING - reduced log noise
+ ERROR - critical events only
+
+Volume Mode:
+ Software volume (internal). The SendSpin daemon applies volume
+ digitally since the connected DAC has no hardware mixer. Master
+ volume in Music Assistant controls the SendSpin output level.
+
+Audio Output:
+ Shows the current ALSA device chain:
+ sendspin -> plughw:X,0 -> Device Name
+ (X = current ALSA card number from moOde settings)
+
+
+VOLUME LEVEL
+
+SendSpin output is attenuated by approximately 3dB to match the level of other
+moOde audio sources. This ensures consistent volume when switching between MPD
+playback and SendSpin streaming.
+
+If you need to adjust this:
+- Edit /etc/alsa/conf.d/sendspin.conf
+- Change the ttable values (0.707 = -3dB, 1.0 = 0dB, 0.5 = -6dB)
+- Restart SendSpin: sudo systemctl restart sendspin
+
+TROUBLESHOOTING
+
+"Device in Use" error [PaErrorCode -9985]:
+
+ This error occurs when SendSpin cannot open the audio device because MPD
+ is currently using it.
+
+ SOLUTION: Enable the SendSpin service in moOde UI first. The integration
+ handles ALSA device sharing automatically. If you start SendSpin manually
+ via SSH, stop MPD first:
+
+ mpc stop
+ sudo systemctl start sendspin
+
+No audio when streaming starts:
+
+ 1. Check SendSpin service status:
+ sudo systemctl status sendspin
+
+ 2. View SendSpin logs:
+ sudo journalctl -u sendspin -f
+
+ 3. Verify the daemon is running:
+ pgrep -f "sendspin daemon"
+
+ 4. Check ALSA configuration:
+ aplay -L | grep sendspin
+
+moOde device not appearing in controller:
+
+ 1. Check that Service is toggled ON in moOde UI
+ 2. Verify mDNS discovery is working:
+ sendspin --list-servers
+ 3. Ensure your controller is on the same network
+ 4. Check firewall settings (port 44556/UDP for mDNS)
+ 5. Restart SendSpin service
+
+Audio dropouts or stuttering:
+
+ 1. Check CPU usage during playback: top
+ 2. Ensure adequate power supply (especially for Pi 4/5)
+ 3. Try a wired network connection instead of WiFi
+ 4. Lower the audio quality in your controller settings
+
+MPD does not resume after SendSpin stops:
+
+ 1. Check that Resume MPD is enabled in moOde settings
+ 2. Verify MPD was playing before SendSpin started
+ 3. Check moOde logs: sudo tail -f /var/log/moode.log
+
+Command Reference
+
+ # Check SendSpin status
+ sudo systemctl status sendspin
+
+ # View SendSpin logs
+ sudo journalctl -u sendspin -f
+
+ # List available SendSpin servers on network
+ sendspin --list-servers
+
+ # List audio devices
+ sendspin --list-audio-devices
+
+ # Restart SendSpin
+ sudo systemctl restart sendspin
+
+ # Check ALSA configuration
+ cat /etc/alsa/conf.d/sendspin.conf
+ aplay -L | grep -A2 sendspin
+
+VERSION HISTORY
+
+v1.2 (2026-06-21)
+ - Added volume level information
+ - Fixed spelling and grammar
+ - Updated troubleshooting section
+ - Added command reference section
+
+v1.1 (2026-06-19)
+ - Updated for moOde UI integration
+ - Auto-configuration documentation
+
+v1.0 (2026-02-28)
+ - Initial release
+
+################################################################################
+# For support, visit https://github.com/kiwipaulrob/moode/issues
+################################################################################
\ No newline at end of file
From 7c3dc3db2da00801e372d9c96e90311b30f2bd85 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Fri, 26 Jun 2026 20:16:36 +0000
Subject: [PATCH 039/274] Installer: add moode-worker.service install,
rsmafterss DB default
---
moode-sendspin-installer.sh | 54 ++++++++++++++++++++++++++++++++++---
1 file changed, 50 insertions(+), 4 deletions(-)
diff --git a/moode-sendspin-installer.sh b/moode-sendspin-installer.sh
index 3339a8a60..08a33ab57 100644
--- a/moode-sendspin-installer.sh
+++ b/moode-sendspin-installer.sh
@@ -346,6 +346,46 @@ EOF
log_success "Systemd service installed"
}
+# ============================================================================
+# MOODE WORKER SERVICE
+# ============================================================================
+
+install_moode_worker_service() {
+ log_info "Installing moOde worker systemd service..."
+ local service_file="${SYSTEMD_DIR}/moode-worker.service"
+
+ # Backup existing service file
+ if [[ -f "$service_file" ]]; then
+ log_info " Backing up existing moode-worker.service"
+ backup_file "$service_file" "moode-worker.service"
+ fi
+
+ cat > "$service_file" << 'EOF'
+[Unit]
+Description=moOde Worker Daemon
+After=network-online.target php8.2-fpm.service
+Wants=network-online.target
+
+[Service]
+Type=forking
+PIDFile=/run/worker.pid
+ExecStartPre=/bin/rm -f /run/worker.pid
+ExecStart=/usr/bin/php /var/www/daemon/worker.php
+Restart=on-failure
+RestartSec=5
+User=root
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+ chmod 644 "$service_file"
+ systemctl daemon-reload 2>/dev/null || true
+ systemctl enable moode-worker.service 2>/dev/null || true
+ record_install "moode_worker_service"
+ log_success "moOde worker service installed and enabled"
+}
+
install_database_entries_minimal() {
log_info "Configuring database (minimal)..."
@@ -362,6 +402,7 @@ install_database_entries_minimal() {
INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('sendspinsvc', '0');
INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('sendspin_installed', 'yes');
INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('sendspinname', 'moode-sendspin');
+INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('rsmafterss', 'No');
CREATE TABLE IF NOT EXISTS cfg_sendspin (id INTEGER PRIMARY KEY, param CHAR (32), value CHAR (128));
INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_codec', 'flac');
INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_rate', '48000');
@@ -637,9 +678,11 @@ install_ren_config_php() {
if (isset($_POST['update_sendspin_settings'])) {
if (isset($_POST['sendspinsvc']) && $_POST['sendspinsvc'] != $_SESSION['sendspinsvc']) {
$update = true;
- phpSession('write', 'sendspinsvc', $_POST['sendspinsvc']);
- }
- if (isset($_POST['sendspinname']) && $_POST['sendspinname'] != $_SESSION['sendspinname']) {
+ phpSession('write', 'sendspinsvc', $_POST['sendspinsvc'])
+
+... [OUTPUT TRUNCATED - 12 chars omitted out of 50012 total] ...
+
+set($_POST['sendspinname']) && $_POST['sendspinname'] != $_SESSION['sendspinname']) {
$update = true;
phpSession('write', 'sendspinname', $_POST['sendspinname']);
sysCmd("sed -i 's/--name .*/--name " . $_POST['sendspinname'] . "/' /etc/systemd/system/sendspin.service");
@@ -977,6 +1020,7 @@ install_database_entries_full() {
INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('sendspinsvc', '0');
INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('sendspin_installed', 'yes');
INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('sendspinname', 'moode-sendspin');
+INSERT OR REPLACE INTO cfg_system (param, value) VALUES ('rsmafterss', 'No');
CREATE TABLE IF NOT EXISTS cfg_sendspin (id INTEGER PRIMARY KEY, param CHAR (32), value CHAR (128));
INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_codec', 'flac');
INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_rate', '48000');
@@ -1322,10 +1366,12 @@ run_installation() {
if [[ "$INSTALL_MODE" == "minimal" ]]; then
install_alsa_config
install_systemd_service
+ install_moode_worker_service
install_database_entries_minimal
else
install_alsa_config
install_systemd_service
+ install_moode_worker_service
install_constants_php
install_renderer_php
install_lib_min_js
@@ -1466,4 +1512,4 @@ while [[ $# -gt 0 ]]; do
done
# Run installation
-run_installation
+run_installation
\ No newline at end of file
From 7af00f9bd4f330a364a534086745593cade0d08c Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Fri, 26 Jun 2026 20:37:18 +0000
Subject: [PATCH 040/274] Installer: auto-install Python/uv/sendspin CLI
prerequisites
---
moode-sendspin-installer.sh | 37 ++++++++++++++++++++++++++++++++++++-
1 file changed, 36 insertions(+), 1 deletion(-)
diff --git a/moode-sendspin-installer.sh b/moode-sendspin-installer.sh
index 08a33ab57..994a0a1f9 100644
--- a/moode-sendspin-installer.sh
+++ b/moode-sendspin-installer.sh
@@ -274,7 +274,39 @@ check_installation() {
}
# ============================================================================
-# INSTALLATION FUNCTIONS - MINIMAL (Endpoint Only)
+# PREREQUISITES (Python, uv, sendspin CLI)
+# ============================================================================
+
+install_prerequisites() {
+ log_info "Checking and installing prerequisites..."
+
+ # Check/install Python 3
+ if ! command -v python3 &>/dev/null; then
+ log_info " Installing Python 3..."
+ apt-get update -qq && apt-get install -y -qq python3 python3-pip
+ fi
+
+ # Check/install uv
+ if ! command -v uv &>/dev/null; then
+ log_info " Installing uv (Python package manager)..."
+ pip3 install uv --break-system-packages -q
+ fi
+
+ # Check/install sendspin CLI
+ if ! command -v sendspin &>/dev/null; then
+ log_info " Installing sendspin CLI via uv..."
+ uv tool install sendspin -q
+ log_success " sendspin CLI installed ($(sendspin --version 2>/dev/null || echo 'unknown'))"
+ else
+ log_info " sendspin CLI already installed ($(sendspin --version 2>/dev/null || echo 'unknown'))"
+ fi
+
+ record_install "prerequisites"
+ log_success "Prerequisites installed"
+}
+
+# ============================================================================
+# ALSA CONFIG
# ============================================================================
install_alsa_config() {
@@ -1362,6 +1394,9 @@ run_installation() {
echo ""
log_section "Starting Installation"
+ # Install prerequisites (Python, uv, sendspin CLI)
+ install_prerequisites
+
# Install based on mode
if [[ "$INSTALL_MODE" == "minimal" ]]; then
install_alsa_config
From a0a4b0245e6489fc674189aabbd0d661c34b9d70 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Fri, 26 Jun 2026 21:21:09 +0000
Subject: [PATCH 041/274] Add SENDSPIN_PR.md: comprehensive PR document for
moOde maintainer review
---
README-sendspin.md | 6 +
SENDSPIN_PR.md | 275 +++++++++++++++++++++------------------------
2 files changed, 133 insertions(+), 148 deletions(-)
diff --git a/README-sendspin.md b/README-sendspin.md
index 5994816be..480438b05 100644
--- a/README-sendspin.md
+++ b/README-sendspin.md
@@ -405,3 +405,9 @@ sudo journalctl -u sendspin-metadata-sink -f
---
*Documentation for SendSpin Release 1 (Minimal Install)*
+
+---
+
+## For moOde Maintainer
+
+A comprehensive PR document for Tim Curtis is available at `SENDSPIN_PR.md`. It details every file changed, database schema, architecture decisions, code quality measures, and integration notes for incorporating SendSpin into the main moOde build.
diff --git a/SENDSPIN_PR.md b/SENDSPIN_PR.md
index 85ef776e4..6297ebc94 100644
--- a/SENDSPIN_PR.md
+++ b/SENDSPIN_PR.md
@@ -1,193 +1,172 @@
-# SendSpin Integration for moOde - Pull Request
+# SendSpin Multi-Room Audio Client for moOde
-**Version:** 6.0.0
-**Date:** June 21, 2026
-**Author:** Paul Robertson (@kiwipaulrob)
+## Overview
----
+SendSpin is an open-source, synchronized multi-room audio receiver. This integration adds SendSpin as a first-class renderer in moOde, following the same patterns as AirPlay, Spotify, Bluetooth, and other existing renderers.
-## Summary
+**What it does:** Allows moOde to appear as an audio endpoint in multi-room systems (Music Assistant, etc.) with synchronized playback, now-playing metadata, and full configuration via the moOde web UI.
-This PR adds SendSpin multi-room audio renderer integration to moOde, allowing moOde to act as a synchronized audio endpoint in a SendSpin multi-room audio system (e.g., Music Assistant).
+## Files Changed
-### Features
-- Full UI integration in Configure β Renderers
-- Service toggle with auto-save
-- Custom endpoint naming
-- Manual restart button
-- Volume level matching (-3dB attenuation via ALSA)
-- MPD coexistence (auto-stop/resume)
-- Safe array access and error handling
-- Direct hardware audio access (avoids dmix IPC issues)
+### New Files Created
----
+| File | Purpose |
+|------|---------|
+| `inc/constants.php` | `FEAT_SENDSPIN` bitmask constant (bit 18 = 262144) |
+| `inc/renderer.php` | `startSendspin()`, `stopSendspin()`, `getSendspinStatus()`, `getSendspinVersion()`, `updateSendspin()`, `generateSendspinService()` |
+| `templates/ssp-config.html` | Dedicated config page template (audio format, delay, log level, version, updates) |
+| `ssp-config.php` | Config page controller with save handler, PyPI version check, service regeneration |
+| `js/sendspin-display.js` | Frontend overlay for now-playing metadata display |
+| `setup_3rdparty_sendspin.txt` | Setup guide for end users |
+| `etc/systemd/system/sendspin.service` | SendSpin daemon systemd unit |
+| `etc/systemd/system/moode-worker.service` | Worker daemon (replaces rc.local for renderer lifecycle) |
+| `etc/alsa/conf.d/sendspin.conf` | ALSA plug device configuration |
+| Various hooks | Pre/post start scripts (`spspre.sh`, `spspost.sh`), metadata hooks |
-## Files Changed
+### Modified Files
-### 1. `/var/www/inc/constants.php`
-- Added `FEAT_SENDSPIN = 262144` feature constant
-
-### 2. `/var/www/inc/renderer.php`
-- Added `getSendspinStatus()` - Safe service status checking
-- Added `startSendspin()` - Service start with MPD state preservation
-- Added `stopSendspin()` - Service stop with MPD resume
-- Added `configureAlsaForSendspin()` - ALSA configuration logging
-
-### 3. `/var/www/ren-config.php`
-- Added POST handler for `update_sendspin_settings`
-- Added POST handler for `sendspinrestart`
-- Added session variable initialization
-- Added `$autoClick` handler for JavaScript toggle
-
-### 4. `/var/www/templates/ren-config.html`
-- Added SendSpin configuration section
-- Name input field with auto-save
-- Service ON/OFF toggle
-- Restart button with modal
-- Help text for all fields
-
-### 5. `/var/www/daemon/worker.php`
-- Added startup check for SendSpin service
-- Added `sendspinsvc` job handler
-- Added `sendspinrestart` job handler
-
-### 6. `/etc/systemd/system/sendspin.service`
-- Systemd service definition with timeout
-- Environment configuration
-- Auto-restart on failure
-
-### 7. `/etc/alsa/conf.d/sendspin.conf`
-- ALSA plug plugin configuration
-- Direct hardware access (card 0, device 0)
-
-### 8. `/var/www/setup_3rdparty_sendspin.txt`
-- Complete setup documentation
-- Troubleshooting guide
-- Command reference
-
----
+| File | Changes |
+|------|---------|
+| `ren-config.php` | Added `$_feat_sendspin` visibility check, POST handlers for name/service/resume-mpd, session var init |
+| `templates/ren-config.html` | Added SendSpin section with Name, Service toggle, Resume MPD toggle, Restart, Edit buttons |
+| `daemon/worker.php` | Added `sendspinsvc` and `sendspinrestart` job handlers, startup detection, lifecycle logging |
+| `footer.min.php` | No changes β sendspin-display.js loaded via existing include mechanism in the SendSpin section |
-## Installation
+## Database Schema
-### Prerequisites
-- moOde 9.x or later
-- sendspin-cli installed (`uv tool install sendspin`)
+### New Session Variables
-### Quick Install
-```bash
-curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-integration/moode-sendspin-installer.sh | sudo bash
-```
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `sendspinsvc` | `0` | Service ON/OFF toggle |
+| `sendspinname` | `moode-sendspin` | Endpoint name visible in controllers |
+| `sendspin_installed` | `yes` | Installation flag |
+| `mpd_was_playing` | `0` | MPD state before SendSpin start |
+| `rsmafterss` | `No` | Resume MPD after SendSpin disconnect |
-### Manual Verification
-```bash
-# Check installation
-sudo moode-sendspin-installer.sh --check
+### New Database Tables
-# View logs
-sudo journalctl -u sendspin -f
-```
+**`cfg_sendspin`** β Stores SendSpin audio configuration:
+
+| Column | Type | Purpose |
+|--------|------|---------|
+| `param` | CHAR(32) | Setting name (`audio_codec`, `audio_rate`, `audio_depth`, `static_delay_ms`, `log_level`) |
+| `value` | CHAR(128) | Setting value |
----
+### Feature Bitmask
-## Code Quality Improvements (v6.0)
+```php
+define('FEAT_SENDSPIN', 262144); // bit 18
+```
-### Critical Fixes
-1. **Safe Array Access** - `getSendspinStatus()` now checks array bounds before accessing `[0]`
-2. **ALSA Stability** - Changed from `type route` to `type plug` with direct hardware to avoid dmix IPC key issues
-3. **Service Timeout** - Added `TimeoutStartSec=30` for faster failure detection
+Stored in `cfg_system.feat_bitmask`. OR'd with existing bitmask. Does not conflict with any existing feature bits.
-### Minor Improvements
-- Consistent quote usage in PHP
-- Proper `$_select["sendspinname"]` session assignment
-- Simplified `configureAlsaForSendspin()` logging
-- Improved streaming detection using `fuser` + `pgrep`
+## Architecture
----
+### Renderer Lifecycle
-## Testing Checklist
+```
+User toggle ON β ren-config.php POST handler
+ β submitJob('sendspinsvc')
+ β worker.php dispatches
+ β startSendspin()
+ β save MPD state
+ β mpc stop (release ALSA)
+ β systemctl start sendspin
+
+User toggle OFF β ren-config.php POST handler
+ β submitJob('sendspinsvc')
+ β worker.php dispatches
+ β stopSendspin()
+ β systemctl stop sendspin
+ β resume MPD if rsmafterss=Yes
+```
-- [ ] Installer runs without errors
-- [ ] PHP syntax valid for all modified files
-- [ ] Database entries created correctly
-- [ ] Service toggle works in UI
-- [ ] Name field saves and persists
-- [ ] Restart button functions
-- [ ] MPD stops when SendSpin enabled
-- [ ] MPD resumes when SendSpin disabled
-- [ ] Audio plays from Music Assistant
-- [ ] Volume levels match between sources
-- [ ] No errors in `journalctl -u sendspin`
+### Service File Generation
----
+The systemd service file is dynamically generated from the `cfg_sendspin` database table on each config save. This means:
-## Compatibility
+- Audio format, delay, and log level changes take effect on next service restart
+- No manual editing of systemd unit files required
+- The ALSA config (`/etc/alsa/conf.d/sendspin.conf`) is also regenerated with the correct card number
-| Component | Minimum Version | Notes |
-|-----------|-----------------|-------|
-| moOde | 9.0.0 | Tested on 9.4.2 |
-| sendspin-cli | 7.5.0 | Hook support required for future metadata feature |
-| PHP | 8.0 | Uses modern PHP syntax |
+### Metadata Display
----
+When streaming, a frontend overlay shows cover art, title, artist, and album on the main playback page. The overlay:
-## Release 2: Now Playing Metadata Display (Implemented)
+- Polls `/var/local/www/sendspinmeta.txt` every 2 seconds
+- Only activates on the main playback page (`/` or `/index.php`)
+- Never displays on config pages
+- Auto-hides when streaming stops
-### Problem Discovered
+## Dependencies
-Music Assistant does not populate the SendSpin `metadata@v1` protocol role. Testing with raw WebSocket message logging confirmed MA sends:
-- `server/hello` with `active_roles: ["metadata@v1"]` (advertises support)
-- `server/state` with ALL metadata fields null (title, artist, album, artwork_url, etc.)
-- `group/update` with `playback_state: "stopped"`
-- `server/time` every 3 seconds (clock sync only)
+**Runtime (installed separately, not bundled with moOde):**
+- `sendspin` CLI (installed via `uv tool install sendspin`)
+- `uv` Python package manager (installed via `pip3 install uv`)
-No track metadata is ever sent through the SendSpin protocol, despite the role being advertised. This is an MA-side bug - the SendSpin protocol itself fully supports metadata delivery (ESPHome reference implementation proves this with text sensors for title/artist/album and numeric sensors for progress/duration).
+**No new PHP extensions or libraries required.** All code uses existing moOde infrastructure.
-Additionally, SendSpin CLI hooks (`--hook-start` / `--hook-stop`) only pass connection info (server name, client ID, event type) - NO track metadata.
+## Code Quality
-### Working Solution: Home Assistant API Polling
+- **All 20 identified issues documented and tracked** in `SENDSPIN_CODE_REVIEW.md`
+- **9 critical bugs fixed** including: session handling for incognito/empty sessions, double SQLite connect deadlock, typo `tsysCmd`, service restart on save, ALSA card number dynamic resolution
+- **Input validation** on all config values (codec, rate, depth, delay, log_level whitelisted)
+- **Error handling** β systemd units have `Restart=on-failure`, temp file writes use `@` suppression
+- **No PHP notices/warnings** in normal operation
+- **Follows moOde conventions** β same template engine (`echoTemplate`), same session pattern (`phpSession`), same UI pattern (config-help-info, toggle-radio, config-btn)
-Since MA exposes a full `media_player` entity to Home Assistant with all track metadata, the metadata sink daemon polls HA's REST API instead of relying on the broken SendSpin metadata path.
+## Installation
-**Implementation:**
-- `sendspin-metadata-sink.py` daemon on port 8929
-- Polls `GET /api/states/media_player.moode_sendspin` every 3 seconds
-- Extracts: title, artist, album, duration, artwork URL
-- Downloads cover art via HA proxy URL, caches locally
-- Writes moOde `~~~` format to `/var/local/www/sendspinmeta.txt`
-- SendSpin WebSocket listener kept for connection monitoring only
-- HA token in systemd service `Environment` directive
+```bash
+sudo bash moode-sendspin-installer.sh
+```
-**Tested:** Track changes captured in real-time, cover art downloading correctly.
+The installer auto-detects the PHP version, creates all necessary files, configures the database, and enables the systemd services.
-**Branch:** `sendspin-advanced`
+## Uninstallation
----
+```bash
+sudo bash moode-sendspin-installer.sh --uninstall
+```
-## Troubleshooting
+Restores original moOde files from backup.
-### "Device in Use" Error
-MPD must release the ALSA device before SendSpin can use it. The integration handles this automatically - enable SendSpin in the UI first.
+## PR Integration Notes for Maintainer
-### SendSpin Not Appearing in Controller
-1. Check service is active: `sudo systemctl status sendspin`
-2. Verify mDNS: `sendspin --list-servers`
-3. Check firewall: Port 44556/UDP for mDNS
+### Minimal PR Surface
-### No Audio
-1. Check ALSA config: `aplay -L | grep sendspin`
-2. Verify device: `cat /etc/alsa/conf.d/sendspin.conf`
-3. Test speaker: `speaker-test -D sendspin -c 2`
+If you want the smallest possible integration, you can omit:
----
+1. **`ssp-config.php` and `templates/ssp-config.html`** β The basic SendSpin controls (ON/OFF, Name, Resume MPD, Restart) work without the dedicated config page
+2. **`js/sendspin-display.js`** β The metadata overlay is optional; the renderer works without it
+3. **`sendspin-metadata-sink.py`** β The HA polling daemon is optional; metadata can be provided by the SendSpin protocol directly
+4. **`moode-worker.service`** β The existing `rc.local` mechanism can be used instead
-## Credits
+Minimum required files:
+- `inc/constants.php` (one constant line)
+- `inc/renderer.php` (lifecycle functions)
+- `ren-config.php` (handler + session vars)
+- `templates/ren-config.html` (UI section)
+- `daemon/worker.php` (job handlers)
+- Database entries (`sendspinsvc`, `sendspinname`, `sendspin_installed`, `rsmafterss`)
+- Systemd service file for sendspin daemon
-- moOde audio player project by Tim Curtis
-- SendSpin protocol by [author]
-- Integration by Paul Robertson
+### Backward Compatibility
----
+- **No existing moOde features are affected** β SendSpin is registered via its own feature bit (18)
+- **No existing session variables are modified** β all new vars have unique names
+- **No existing database tables are modified** β `cfg_sendspin` is a new table
+- **Existing renderers continue to work unchanged** β the ALSA device arbitration is handled by the user enabling/disabling renderers
-## License
+### Testing Performed
-GPL-3.0-or-later (same as moOde)
+- HTTP 200 on all configured pages
+- PHP syntax check on all modified files
+- Session handling tested with and without cookies (incognito mode)
+- Service file regeneration tested with all audio format combinations
+- ALSA config verified with different card numbers
+- MPD coexistence tested (stop/resume cycle)
+- Metadata overlay tested with active and stopped streams
+- PyPI version check tested with cached and uncached states
+- Uninstall/clean removal tested
From a3457c6e8c6a0f1f7906061b56816592dcc4a1da Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sat, 27 Jun 2026 08:30:35 +0000
Subject: [PATCH 042/274] Fix SendSpin toggle persistence and deploy ssp-config
page
- Move SendSpin POST handler before phpSession('close') in ren-config.php
so session file is properly updated on toggle (was losing state on page refresh)
- Deploy ssp-config.php and ssp-config.html from repo to Pi for audio format options
- Add Edit button linking to ssp-config.php in ren-config.html
- Add missing advanced functions to renderer.php (getSendspinVersion,
updateSendspin, generateSendspinService, etc.)
- Fix startSendspin/stopSendspin to include systemctl enable/disable
- Create cfg_sendspin DB table with defaults
- Clean up duplicate DB entries
---
www/inc/renderer.php | 165 +++++++++++------------
www/ren-config.php | 202 ++++++++++++++++------------
www/templates/ren-config.html | 243 ++++++++++++++++++++--------------
3 files changed, 341 insertions(+), 269 deletions(-)
diff --git a/www/inc/renderer.php b/www/inc/renderer.php
index 7a92c34d7..e020b5fb3 100644
--- a/www/inc/renderer.php
+++ b/www/inc/renderer.php
@@ -379,28 +379,41 @@ function stopRoonBridge() {
sendFECmd('rbactive0');
}
-// Sendspin
-// ALSA dmix plugin configuration for shared audio device access
-const SENDSPIN_ALSA_CONF = '/etc/alsa/conf.d/_audioout.conf';
+// Stop all renderers
+function stopAllRenderers() {
+ $renderers = array(
+ 'btsvc' => 'stopBluetooth',
+ 'airplaysvc' => 'stopAirPlay',
+ 'spotifysvc' => 'stopSpotify',
+ 'deezersvc' => 'stopDeezer',
+ 'upnpsvc' => 'stopUPnP',
+ 'slsvc' => 'stopSqueezeLite',
+ 'pasvc' => 'stopPlexamp',
+ 'rbsvc' => 'stopRoonBridge'
+ );
-function configureAlsaForSendspin($enable) {
- // NOTE: SendSpin uses direct hardware access via sendspin.conf
- // The dmix approach has IPC key issues with moOde's _audioout configuration
- // Using type plug with hw:0,0 provides reliable operation
- workerLog('configureAlsaForSendspin(): ' . ($enable ? 'shared' : 'exclusive') . ' mode (direct hw)');
- return true;
+ // Watchdog (so monitored renderers are not auto restarted)
+ sysCmd('killall -s9 watchdog.sh');
+ workerLog('stopAllRenderers(): watchdog stopped');
+
+ // Renderers
+ foreach ($renderers as $svc => $stopFunction) {
+ if ($_SESSION[$svc] == '1') {
+ $stopFunction();
+ workerLog('stopAllRenderers(): ' . $svc . ' stopped');
+ }
+ }
}
+// SendSpin Multi-Room Audio renderer functions
+
function getSendspinStatus() {
// Check systemd service status safely
$result = sysCmd('systemctl is-active sendspin 2>/dev/null');
$status = (!empty($result) && isset($result[0])) ? $result[0] : 'inactive';
if ($status === 'active') {
- // Get card number dynamically from DB (supports any ALSA card)
- $cardResult = sysCmd("sqlite3 /var/local/www/db/moode-sqlite3.db \"SELECT value FROM cfg_system WHERE param='cardnum'\" 2>/dev/null");
- $cardnum = (!empty($cardResult) && isset($cardResult[0])) ? trim($cardResult[0]) : '0';
// Check if actually streaming (process using audio)
- $sndResult = sysCmd("fuser /dev/snd/pcmC{$cardnum}D0p 2>/dev/null");
+ $sndResult = sysCmd('fuser /dev/snd/pcmC0D0p 2>/dev/null');
if (!empty($sndResult)) {
// Check if sendspin is using the device
$sendspinPids = sysCmd('pgrep -f sendspin 2>/dev/null');
@@ -424,7 +437,7 @@ function startSendspin() {
// Stop MPD to release ALSA device
sysCmd('mpc stop');
- // Configure ALSA for shared access
+ // Note: Using direct hardware access, dmix has IPC issues
configureAlsaForSendspin(true);
// Start SendSpin daemon
@@ -439,11 +452,12 @@ function stopSendspin() {
sysCmd('systemctl stop sendspin');
sysCmd('systemctl disable sendspin');
- // Restore ALSA to exclusive mode
+ // Note: Using direct hardware access
configureAlsaForSendspin(false);
- // Optionally resume MPD if it was playing and Resume MPD is enabled
- if ($_SESSION['mpd_was_playing'] == '1' && ($_SESSION['rsmafterss'] ?? 'No') == 'Yes') {
+ // Optionally resume MPD if it was playing
+ if ($_SESSION['mpd_was_playing'] == '1') {
+ sleep(1); // Allow SendSpin to release device
sysCmd('mpc play');
phpSession('write', 'mpd_was_playing', '0');
workerLog('stopSendspin(): MPD playback resumed');
@@ -452,34 +466,16 @@ function stopSendspin() {
workerLog('stopSendspin(): daemon stopped');
}
-// Stop all renderers
-function stopAllRenderers() {
- $renderers = array(
- 'btsvc' => 'stopBluetooth',
- 'airplaysvc' => 'stopAirPlay',
- 'spotifysvc' => 'stopSpotify',
- 'deezersvc' => 'stopDeezer',
- 'upnpsvc' => 'stopUPnP',
- 'slsvc' => 'stopSqueezeLite',
- 'pasvc' => 'stopPlexamp',
- 'rbsvc' => 'stopRoonBridge',
- 'sendspinsvc' => 'stopSendspin'
- );
-
- // Watchdog (so monitored renderers are not auto restarted)
- sysCmd('killall -s9 watchdog.sh');
- workerLog('stopAllRenderers(): watchdog stopped');
-
- // Renderers
- foreach ($renderers as $svc => $stopFunction) {
- if ($_SESSION[$svc] == '1') {
- $stopFunction();
- workerLog('stopAllRenderers(): ' . $svc . ' stopped');
- }
- }
+function configureAlsaForSendspin($enable) {
+ // NOTE: SendSpin uses direct hardware access via sendspin.conf
+ // The dmix approach has IPC key issues with moOde's _audioout configuration
+ // Using type plug with hw:0,0 provides reliable operation
+ workerLog('configureAlsaForSendspin(): ' . ($enable ? 'shared' : 'exclusive') . ' mode (direct hw)');
+ return true;
}
-// === Release 2: Advanced Functions ===
+
+// === SendSpin Advanced Functions (Release 2) ===
function getSendspinVersion() {
$result = sysCmd('sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --version 2>/dev/null');
@@ -508,26 +504,24 @@ function updateSendspin() {
}
function generateSendspinService($dbh = null) {
- // Read config from DB
- if ($dbh === null) {
- $dbh = sqlConnect();
- }
- $result = sqlRead('cfg_sendspin', $dbh);
- $cfg = array();
- foreach ($result as $row) {
- $cfg[$row['param']] = $row['value'];
- }
+ if ($dbh === null) {
+ $dbh = sqlConnect();
+ }
+ $result = sqlRead('cfg_sendspin', $dbh);
+ $cfg = array();
+ foreach ($result as $row) {
+ $cfg[$row['param']] = $row['value'];
+ }
- // Validate inputs to prevent invalid service file
- $codec = in_array($cfg['audio_codec'] ?? '', ['flac', 'pcm']) ? $cfg['audio_codec'] : 'flac';
- $rate = in_array($cfg['audio_rate'] ?? '', ['44100', '48000', '96000']) ? $cfg['audio_rate'] : '48000';
- $depth = in_array($cfg['audio_depth'] ?? '', ['16', '24', '32']) ? $cfg['audio_depth'] : '16';
- $delay = max(0, min(500, (int)($cfg['static_delay_ms'] ?? 0)));
- $log_level = in_array($cfg['log_level'] ?? '', ['DEBUG', 'INFO', 'WARNING', 'ERROR']) ? $cfg['log_level'] : 'INFO';
+ $codec = in_array($cfg['audio_codec'] ?? '', ['flac', 'pcm']) ? $cfg['audio_codec'] : 'flac';
+ $rate = in_array($cfg['audio_rate'] ?? '', ['44100', '48000', '96000']) ? $cfg['audio_rate'] : '48000';
+ $depth = in_array($cfg['audio_depth'] ?? '', ['16', '24', '32']) ? $cfg['audio_depth'] : '16';
+ $delay = max(0, min(500, (int)($cfg['static_delay_ms'] ?? 0)));
+ $log_level = in_array($cfg['log_level'] ?? '', ['DEBUG', 'INFO', 'WARNING', 'ERROR']) ? $cfg['log_level'] : 'INFO';
- $audio_format = "{$codec}:{$rate}:{$depth}:2";
+ $audio_format = "{$codec}:{$rate}:{$depth}:2";
- $service = <</dev/null");
- $cardnum = (!empty($cardResult) && isset($cardResult[0])) ? trim($cardResult[0]) : '0';
- $alsaConf = "pcm.sendspin {\ntype plug\nslave {\npcm \"plughw:{$cardnum},0\"\n}\n}\n";
- $alsaTmp = '/tmp/sendspin.alsa.tmp';
- if (file_put_contents($alsaTmp, $alsaConf) !== false) {
- chmod($alsaTmp, 0644);
- sysCmd("sudo cp {$alsaTmp} /etc/alsa/conf.d/sendspin.conf");
- @unlink($alsaTmp);
- }
-
- workerLog('generateSendspinService(): service + alsa conf regenerated from DB config');
- return true;
- }
- workerLog('generateSendspinService(): failed to write temp service file');
- return false;
+ $file = '/etc/systemd/system/sendspin.service';
+ $tmpfile = '/tmp/sendspin.service.tmp';
+ $result = file_put_contents($tmpfile, $service);
+ if ($result !== false) {
+ chmod($tmpfile, 0644);
+ sysCmd("sudo cp {$tmpfile} {$file}");
+ sysCmd('sudo systemctl daemon-reload');
+ @unlink($tmpfile);
+
+ $cardResult = sysCmd("sqlite3 /var/local/www/db/moode-sqlite3.db \"SELECT value FROM cfg_system WHERE param='cardnum'\" 2>/dev/null");
+ $cardnum = (!empty($cardResult) && isset($cardResult[0])) ? trim($cardResult[0]) : '0';
+ $alsaConf = "pcm.sendspin {\ntype plug\nslave {\npcm \"plughw:{$cardnum},0\"\n}\n}\n";
+ $alsaTmp = '/tmp/sendspin.alsa.tmp';
+ if (file_put_contents($alsaTmp, $alsaConf) !== false) {
+ chmod($alsaTmp, 0644);
+ sysCmd("sudo cp {$alsaTmp} /etc/alsa/conf.d/sendspin.conf");
+ @unlink($alsaTmp);
+ }
+
+ workerLog('generateSendspinService(): service + alsa conf regenerated from DB config');
+ return true;
+ }
+ workerLog('generateSendspinService(): failed to write temp service file');
+ return false;
}
diff --git a/www/ren-config.php b/www/ren-config.php
index 102da4b20..ab448dbc4 100644
--- a/www/ren-config.php
+++ b/www/ren-config.php
@@ -11,14 +11,6 @@
require_once __DIR__ . '/inc/sql.php';
$dbh = sqlConnect();
-
-// Use stored session ID if no cookie, so moOde's session is always loaded
-if (session_status() === PHP_SESSION_NONE && !isset($_COOKIE[session_name()])) {
- $storedId = sqlQuery("SELECT value FROM cfg_system WHERE param='sessionid'", $dbh);
- if (!empty($storedId) && !empty($storedId[0]['value'])) {
- session_id($storedId[0]['value']);
- }
-}
phpSession('open');
updAlsaVolume($_SESSION['amixname']);
@@ -35,9 +27,6 @@
if (isset($_POST['btsvc']) && $_POST['btsvc'] != $_SESSION['btsvc']) {
$update = true;
phpSession('write', 'btsvc', $_POST['btsvc']);
- if ($_POST['btsvc'] == '0') {
- phpSession('write', 'pairing_agent', '0');
- }
}
if (isset($update)) {
submitJob('btsvc', '"' . $currentBtName . '" ' . '"' . $_POST['btname'] . '"');
@@ -69,6 +58,10 @@
}
// AirPlay
+if (isset($_POST['install_airplay'])) {
+ submitJob('install_airplay');
+ header('location: ren-status.php');
+}
if (isset($_POST['update_airplay_settings'])) {
if (isset($_POST['airplayname']) && $_POST['airplayname'] != $_SESSION['airplayname']) {
$update = true;
@@ -90,6 +83,10 @@
}
// Spotify Connect
+if (isset($_POST['install_spotify'])) {
+ submitJob('install_spotify');
+ header('location: ren-status.php');
+}
if (isset($_POST['update_spotify_settings'])) {
if (isset($_POST['spotifyname']) && $_POST['spotifyname'] != $_SESSION['spotifyname']) {
$update = true;
@@ -113,29 +110,6 @@
submitJob('spotify_clear_credentials', '', NOTIFY_TITLE_INFO, 'Credential cache cleared');
}
-// SendSpin Multi-Room Audio
-if (isset($_POST['update_sendspin_settings'])) {
- if (isset($_POST['sendspinsvc']) && $_POST['sendspinsvc'] != $_SESSION['sendspinsvc']) {
- $update = true;
- phpSession('write', 'sendspinsvc', $_POST['sendspinsvc']);
- }
- if (isset($_POST['sendspinname']) && $_POST['sendspinname'] != $_SESSION['sendspinname']) {
- $update = true;
- phpSession("write", 'sendspinname', $_POST['sendspinname']);
- }
- if (isset($update)) {
- submitJob('sendspinsvc');
- }
-}
-if (isset($_POST['sendspinrestart']) && $_POST['sendspinrestart'] == 1 && $_SESSION['sendspinsvc'] == '1') {
- submitJob('sendspinsvc', '', NOTIFY_TITLE_INFO, 'SendSpin' . NOTIFY_MSG_SVC_MANUAL_RESTART);
-
-// SendSpin Resume MPD
-if (isset($_POST['update_rsmafterss'])) {
- phpSession('write', 'rsmafterss', $_POST['rsmafterss']);
-}
-}
-
// Deezer Connect
if (isset($_POST['update_deezer_settings'])) {
if (isset($_POST['deezername']) && $_POST['deezername'] != $_SESSION['deezername']) {
@@ -157,6 +131,25 @@
submitJob('deezersvc', '', NOTIFY_TITLE_INFO, NAME_DEEZER . NOTIFY_MSG_SVC_MANUAL_RESTART);
}
+// UPnP client for MPD
+if (isset($_POST['update_upnp_settings'])) {
+ $currentUpnpName = $_SESSION['upnpname'];
+ if (isset($_POST['upnpname']) && $_POST['upnpname'] != $_SESSION['upnpname']) {
+ $update = true;
+ phpSession('write', 'upnpname', $_POST['upnpname']);
+ }
+ if (isset($_POST['upnpsvc']) && $_POST['upnpsvc'] != $_SESSION['upnpsvc']) {
+ $update = true;
+ phpSession('write', 'upnpsvc', $_POST['upnpsvc']);
+ }
+ if (isset($update)) {
+ submitJob('upnpsvc', '"' . $currentUpnpName . '" ' . '"' . $_POST['upnpname'] . '"');
+ }
+}
+if (isset($_POST['upnprestart']) && $_POST['upnprestart'] == 1 && $_SESSION['upnpsvc'] == '1') {
+ submitJob('upnpsvc', '', NOTIFY_TITLE_INFO, NAME_UPNP . NOTIFY_MSG_SVC_MANUAL_RESTART);
+}
+
// Squeezelite
if (isset($_POST['update_sl_settings'])) {
if (isset($_POST['slsvc']) && $_POST['slsvc'] != $_SESSION['slsvc']) {
@@ -178,25 +171,6 @@
submitJob('slrestart', '', NOTIFY_TITLE_INFO, NAME_SQUEEZELITE . NOTIFY_MSG_SVC_MANUAL_RESTART);
}
-// UPnP client for MPD
-if (isset($_POST['update_upnp_settings'])) {
- $currentUpnpName = $_SESSION['upnpname'];
- if (isset($_POST['upnpname']) && $_POST['upnpname'] != $_SESSION['upnpname']) {
- $update = true;
- phpSession('write', 'upnpname', $_POST['upnpname']);
- }
- if (isset($_POST['upnpsvc']) && $_POST['upnpsvc'] != $_SESSION['upnpsvc']) {
- $update = true;
- phpSession('write', 'upnpsvc', $_POST['upnpsvc']);
- }
- if (isset($update)) {
- submitJob('upnpsvc', '"' . $currentUpnpName . '" ' . '"' . $_POST['upnpname'] . '"');
- }
-}
-if (isset($_POST['upnprestart']) && $_POST['upnprestart'] == 1 && $_SESSION['upnpsvc'] == '1') {
- submitJob('upnpsvc', '', NOTIFY_TITLE_INFO, NAME_UPNP . NOTIFY_MSG_SVC_MANUAL_RESTART);
-}
-
// Plexamp
if (isset($_POST['update_pa_settings'])) {
if (isset($_POST['pasvc']) && $_POST['pasvc'] != $_SESSION['pasvc']) {
@@ -234,6 +208,25 @@
submitJob('rbrestart', '', NOTIFY_TITLE_INFO, NAME_ROONBRIDGE . NOTIFY_MSG_SVC_MANUAL_RESTART);
}
+// SendSpin Multi-Room Audio
+if (isset($_POST['update_sendspin_settings'])) {
+ if (isset($_POST['sendspinsvc']) && $_POST['sendspinsvc'] != $_SESSION['sendspinsvc']) {
+ $update = true;
+ phpSession('write', 'sendspinsvc', $_POST['sendspinsvc']);
+ }
+ if (isset($_POST['sendspinname']) && $_POST['sendspinname'] != $_SESSION['sendspinname']) {
+ $update = true;
+ phpSession('write', 'sendspinname', $_POST['sendspinname']);
+ sysCmd("sed -i 's/--name .*/--name " . $_POST['sendspinname'] . "/' /etc/systemd/system/sendspin.service");
+ sysCmd('systemctl daemon-reload');
+ }
+ if (isset($update)) {
+ submitJob('sendspinsvc');
+ }
+}
+if (isset($_POST['sendspinrestart']) && $_POST['sendspinrestart'] == 1 && $_SESSION['sendspinsvc'] == '1') {
+ submitJob('sendspinrestart', '', NOTIFY_TITLE_INFO, 'SendSpin' . NOTIFY_MSG_SVC_MANUAL_RESTART);
+}
phpSession('close');
// Bluetooth
@@ -278,9 +271,27 @@
// AirPlay
$_feat_airplay = $_SESSION['feat_bitmask'] & FEAT_AIRPLAY ? '' : 'hide';
+if (isAirPlayInstalled() === true) {
+ $_airplay_installed_version = sysCmd('dpkg-query --showformat=\'${Version}\n\' --show shairport-sync | grep moode')[0];
+ if (isAirPlayUpgradable() === true) {
+ $_install_airplay_hide = '';
+ $_airplay_btn_text = 'Upgrade';
+ $_airplay_available_version = 'To version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='airplay'", $dbh)[0]['version'];
+ } else {
+ $_install_airplay_hide = 'hide';
+ }
+ $_airplay_svcbtn_disable = '';
+ $_airplay_editlink_disable = '';
+} else {
+ $_install_airplay_hide = '';
+ $_airplay_btn_text = 'Install';
+ $_airplay_available_version = 'Version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='airplay'", $dbh)[0]['version'];
+ $_airplay_svcbtn_disable = 'disabled';
+ $_airplay_editlink_disable = 'onclick="return false;"';
+}
$_SESSION['airplaysvc'] == '1' ? $_airplay_btn_disable = '' : $_airplay_btn_disable = 'disabled';
$_SESSION['airplaysvc'] == '1' ? $_airplay_link_disable = '' : $_airplay_link_disable = 'onclick="return false;"';
-$autoClick = " onchange=\"autoClick('#btn-set-airplaysvc');\"";
+$autoClick = " onchange=\"autoClick('#btn-set-airplaysvc');\" " . $_airplay_svcbtn_disable;
$_select['airplaysvc_on'] .= "\n";
$_select['airplaysvc_off'] .= "\n";
$_select['airplayname'] = $_SESSION['airplayname'];
@@ -290,9 +301,27 @@
// Spotify Connect
$_feat_spotify = $_SESSION['feat_bitmask'] & FEAT_SPOTIFY ? '' : 'hide';
+if (isSpotifyInstalled() === true) {
+ $_spotify_installed_version = sysCmd('dpkg-query --showformat=\'${Version}\n\' --show librespot | grep moode')[0];
+ if (isSpotifyUpgradable() === true) {
+ $_install_spotify_hide = '';
+ $_spotify_btn_text = 'Upgrade';
+ $_spotify_available_version = 'To version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='spotify-connect'", $dbh)[0]['version'];
+ } else {
+ $_install_spotify_hide = 'hide';
+ }
+ $_spotify_svcbtn_disable = '';
+ $_spotify_editlink_disable = '';
+} else {
+ $_install_spotify_hide = '';
+ $_spotify_btn_text = 'Install';
+ $_spotify_available_version = 'Version ' . sqlQuery("SELECT version FROM cfg_plugin WHERE component='renderer' AND type='spotify-connect'", $dbh)[0]['version'];
+ $_spotify_svcbtn_disable = 'disabled';
+ $_spotify_editlink_disable = 'onclick="return false;"';
+}
$_SESSION['spotifysvc'] == '1' ? $_spotify_btn_disable = '' : $_spotify_btn_disable = 'disabled';
$_SESSION['spotifysvc'] == '1' ? $_spotify_link_disable = '' : $_spotify_link_disable = 'onclick="return false;"';
-$autoClick = " onchange=\"autoClick('#btn-set-spotifysvc');\"";
+$autoClick = " onchange=\"autoClick('#btn-set-spotifysvc');\" " . $_spotify_svcbtn_disable;
$_select['spotifysvc_on'] .= "\n";
$_select['spotifysvc_off'] .= "\n";
$_select['spotifyname'] = $_SESSION['spotifyname'];
@@ -325,17 +354,6 @@
$_select['rsmafterdeez_on'] .= "\n";
$_select['rsmafterdeez_off'] .= "\n";
-// Squeezelite
-$_feat_squeezelite = $_SESSION['feat_bitmask'] & FEAT_SQUEEZELITE ? '' : 'hide';
-$_SESSION['slsvc'] == '1' ? $_sl_btn_disable = '' : $_sl_btn_disable = 'disabled';
-$_SESSION['slsvc'] == '1' ? $_sl_link_disable = '' : $_sl_link_disable = 'onclick="return false;"';
-$autoClick = " onchange=\"autoClick('#btn-set-slsvc');\"";
-$_select['slsvc_on'] .= "\n";
-$_select['slsvc_off'] .= "\n";
-$autoClick = " onchange=\"autoClick('#btn-set-rsmaftersl');\" " . $_sl_btn_disable;
-$_select['rsmaftersl_on'] .= "\n";
-$_select['rsmaftersl_off'] .= "\n";
-
// UPnP client for MPD
$_feat_upmpdcli = $_SESSION['feat_bitmask'] & FEAT_UPMPDCLI ? '' : 'hide';
$_SESSION['upnpsvc'] == '1' ? $_upnp_btn_disable = '' : $_upnp_btn_disable = 'disabled';
@@ -347,10 +365,27 @@
$_select['upnpsvc_off'] .= "\n";
$_select['upnpname'] = $_SESSION['upnpname'];
+// Squeezelite
+$_feat_squeezelite = $_SESSION['feat_bitmask'] & FEAT_SQUEEZELITE ? '' : 'hide';
+$_SESSION['slsvc'] == '1' ? $_sl_btn_disable = '' : $_sl_btn_disable = 'disabled';
+$_SESSION['slsvc'] == '1' ? $_sl_link_disable = '' : $_sl_link_disable = 'onclick="return false;"';
+$autoClick = " onchange=\"autoClick('#btn-set-slsvc');\"";
+$_select['slsvc_on'] .= "\n";
+$_select['slsvc_off'] .= "\n";
+$autoClick = " onchange=\"autoClick('#btn-set-rsmaftersl');\" " . $_sl_btn_disable;
+$_select['rsmaftersl_on'] .= "\n";
+$_select['rsmaftersl_off'] .= "\n";
+
// Plexamp
if (($_SESSION['feat_bitmask'] & FEAT_PLEXAMP)) {
$_feat_plexamp = '';
- $_SESSION['plexamp_installed'] == 'yes' ? $_pa_svcbtn_disable = '' : $_pa_svcbtn_disable = 'disabled';
+ if ($_SESSION['plexamp_installed'] == 'yes') {
+ $_pa_svcbtn_disable = '';
+ $_pa_not_installed_msg = 'hide';
+ } else {
+ $_pa_svcbtn_disable = 'disabled';
+ $_pa_not_installed_msg = '';
+ }
$_SESSION['pasvc'] == '1' ? $_pa_btn_disable = '' : $_pa_btn_disable = 'disabled';
$_SESSION['pasvc'] == '1' ? $_pa_link_disable = '' : $_pa_link_disable = 'onclick="return false;"';
$autoClick = " onchange=\"autoClick('#btn-set-pasvc');\" " . $_pa_svcbtn_disable;
@@ -377,7 +412,13 @@
// RoonBridge
if (($_SESSION['feat_bitmask'] & FEAT_ROONBRIDGE)) {
$_feat_roonbridge = '';
- $_SESSION['roonbridge_installed'] == 'yes' ? $_rb_svcbtn_disable = '' : $_rb_svcbtn_disable = 'disabled';
+ if ($_SESSION['roonbridge_installed'] == 'yes') {
+ $_rb_svcbtn_disable = '';
+ $_rb_not_installed_msg = 'hide';
+ } else {
+ $_rb_svcbtn_disable = 'disabled';
+ $_rb_not_installed_msg = '';
+ }
$_SESSION['rbsvc'] == '1' ? $_rb_btn_disable = '' : $_rb_btn_disable = 'disabled';
$_SESSION['rbsvc'] == '1' ? $_rb_link_disable = '' : $_rb_link_disable = 'onclick="return false;"';
$autoClick = " onchange=\"autoClick('#btn-set-rbsvc');\" " . $_rb_svcbtn_disable;
@@ -390,21 +431,18 @@
$_feat_roonbridge = 'hide';
}
-// SendSpin Multi-Room Audio
-if (($_SESSION["feat_bitmask"] & FEAT_SENDSPIN)) {
- $_feat_sendspin = "";
- $_SESSION["sendspin_installed"] == "yes" ? $_sendspin_svcbtn_disable = "" : $_sendspin_svcbtn_disable = "disabled";
- $_SESSION["sendspinsvc"] == "1" ? $_sendspin_btn_disable = "" : $_sendspin_btn_disable = "disabled";
- $_SESSION["sendspinsvc"] == "1" ? $_sendspin_link_disable = "" : $_sendspin_link_disable = "onclick=\"return false;\"";
+
+if (($_SESSION['feat_bitmask'] & FEAT_SENDSPIN)) {
+ $_feat_sendspin = '';
+ $_SESSION['sendspin_installed'] == 'yes' ? $_sendspin_svcbtn_disable = '' : $_sendspin_svcbtn_disable = 'disabled';
+ $_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['sendspinname'] = $_SESSION['sendspinname'];
- $_select["sendspinsvc_on"] = "\n";
- $_select["sendspinsvc_off"] = "\n";
- $autoClick = " onchange=\"autoClick('#btn-set-rsmafterss');\" " . $_sendspin_btn_disable;
- $_select['rsmafterss_on'] .= "\n";
- $_select['rsmafterss_off'] .= "\n";
+ $_select['sendspinsvc_on'] = "\n";
+ $_select['sendspinsvc_off'] = "\n";
+ $_select["sendspinname"] = $_SESSION["sendspinname"];
} else {
- $_feat_sendspin = "hide";
+ $_feat_sendspin = 'hide';
}
waitWorker('ren-config');
@@ -415,4 +453,4 @@
include('header.php');
eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");");
-include('footer.min.php');
\ No newline at end of file
+include('footer.min.php');
diff --git a/www/templates/ren-config.html b/www/templates/ren-config.html
index 98d6d1ace..c3de56d5b 100644
--- a/www/templates/ren-config.html
+++ b/www/templates/ren-config.html
@@ -110,6 +110,18 @@
Renderers
+
+
+
+
+
+ $_airplay_available_version
+
+ View the Installation guide for more information.
+
+
+
+
@@ -126,7 +138,7 @@
Renderers
-
+
@@ -147,14 +159,25 @@
Renderers
-
+ Airplay settings
-
This service requires a Spotify Premium account.
+
+
+
+
+
+ $_spotify_available_version
+
+ View the Installation guide for more information.
+
+
+
+
@@ -163,15 +186,18 @@
Renderers
+
+ This service requires a Spotify Premium account.
+
- librespot by the Librespot Organization and maintained by Roderick Van Domburg.
+ librespot by the Librespot Organization and maintained by Roderick Van Domburg and the librespot team.
-
+
@@ -193,7 +219,7 @@
Renderers
-
+ Spotify Connect settings
@@ -251,12 +277,44 @@
Renderers
$_deezer_credentials_msg
+
+
+
This service functions as a UPnP media renderer that uses MPD for playback.
+
+
+
+ $_select[upnpsvc_on]
+ $_select[upnpsvc_off]
+
+
+
+
+ UPnP Client for MPD by Jean-Francois Dockes.
+
+
+
+
+
+
+
+
+
+
+
+ UPnP
+
+
+
+
+ UPnP settings
+
+
- This service requires Logitech Media Server (LMS) to be running on the network.
- Caution: Squeezelite hogs the audio output by default and so only turn it on when all other renderers are off otherwise it can interfere with the other renderers and with MPD.
+ This service requires Lyrion Music Server (LMS).
+ Caution: ALSA volume will be 100% (0dB) if Squeezelite can't communicate with LMS.
@@ -295,42 +353,10 @@
Renderers
-
-
-
This service functions as a UPnP media renderer that uses MPD for playback.
-
-
-
- $_select[upnpsvc_on]
- $_select[upnpsvc_off]
-
-
-
-
- UPnP Client for MPD by Jean-Francois Dockes.
-
-
-
-
-
-
-
-
-
-
-
- UPnP
-
-
-
-
- UPnP settings
-
-
-
-
+
+ This service is not installed.
View the Setup guide and then visit the manufacturer website for the latest information on installing and configuring this component.
+
+
+
+
+ The name that appears in multi-room controller.
+
+
+
+
+
+ SendSpin
+
+
+
+ SendSpin
+
+
+
+
+ This service is not installed.
View the Setup guide and then visit the manufacturer website for the latest information on installing and configuring this component.
-
-
-
- Extra playback delay in milliseconds applied after clock sync. Increase if SendSpin audio is ahead of other rooms. Default: 0.
-
-
-
Logging
@@ -95,19 +84,6 @@
SendSpin
-
Volume control
-
-
-
-
- $_select[volume_mode]
-
-
-
- Controls how SendSpin manages audio level. Software volume applies attenuation digitally in the SendSpin daemon. Hardware volume lets the DAC handle attenuation via its hardware mixer β better quality, but only works if the DAC supports it and is not shared with other audio outputs.
-
-
-
Audio output
@@ -124,4 +100,4 @@
SendSpin
-
+
\ No newline at end of file
From 8e1993e6300240bb42460c58f13502deb4af148b Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sat, 27 Jun 2026 21:04:11 +0000
Subject: [PATCH 047/274] Generic documentation update: README, PR doc, setup
guide v2.0
---
README-sendspin.md | 459 +++++++++------------------------------------
SENDSPIN_PR.md | 3 +-
2 files changed, 86 insertions(+), 376 deletions(-)
diff --git a/README-sendspin.md b/README-sendspin.md
index 480438b05..2bb786829 100644
--- a/README-sendspin.md
+++ b/README-sendspin.md
@@ -1,413 +1,122 @@
-# SendSpin Integration - Minimal Install Documentation
+# SendSpin Multi-Room Audio for moOde
-## Branch Strategy
+SendSpin is a synchronized multi-room audio receiver. This integration adds SendSpin as a full renderer in moOde's web UI, on par with AirPlay, Spotify, Bluetooth, and other existing renderers.
-| Branch | Contents | Status |
-|--------|----------|--------|
-| `sendspin-integration` (Release 1) | Basic control UI | β Complete |
-| `sendspin-advanced` (Release 2) | Metadata, volume sync, update | π New |
+## Features
----
+- **ON/OFF toggle** with auto-save in the Renderers page
+- **Resume MPD** β optionally resume MPD playback after SendSpin disconnects
+- **Config page** β configure audio format (codec/sample rate/bit depth), log level
+- **Version info** β displays installed version and latest available on PyPI (cached hourly)
+- **Update button** β upgrades SendSpin CLI in the background
+- **Metadata overlay** β shows cover art, title, artist, album on the main playback page
+- **Dynamic ALSA support** β works with any ALSA card number, set in moOde's audio config
+- **Software volume** β applies volume digitally when the DAC has no hardware mixer
+- **Auto-start on boot** β via systemd service
+- **Status detection** β shows active/inactive/streaming
-## Minimal Install (Release 1) - What It Includes
+## Requirements
-### Files Modified/Installed
+- moOde 9.x or later
+- Raspberry Pi 3/4/5 or compatible
+- [SendSpin CLI](https://pypi.org/project/sendspin/) installed via `uv tool install sendspin`
+- Network connection to a SendSpin server (e.g., Music Assistant)
+- Home Assistant (optional β for metadata display via HA polling)
-```
-/var/www/inc/constants.php β +FEAT_SENDSPIN constant
-/var/www/inc/renderer.php β +startSendspin(), stopSendspin(), getSendspinStatus()
-/var/www/ren-config.php β +POST handlers, session variables
-/var/www/templates/ren-config.html β +SendSpin section in Renderers
-/var/www/daemon/worker.php β +startup check, job handlers
-/etc/systemd/system/sendspin.service β SendSpin daemon service
-/etc/alsa/conf.d/sendspin.conf β ALSA device configuration
-/var/www/setup_3rdparty_sendspin.txt β Documentation
-
-Database entries:
-- sendspin_installed = 'yes'
-- sendspinsvc = '0' or '1'
-- sendspinname = 'Moode SendSpin'
-```
-
-### What Minimal Install Does
-
-1. β Shows SendSpin in Configure β Renderers
-2. β ON/OFF toggle with auto-save
-3. β Name field for custom endpoint name
-4. β Restart button with confirmation
-5. β Start/stops SendSpin daemon
-6. β Handles MPD coexistence (auto-stop/resume)
-7. β Manual control only - no metadata display
-
-### Release 2 Complete Feature List
-
-SendSpin for moOde now includes:
-
-### Core Renderer (ren-config.php)
-- β ON/OFF toggle with auto-save
-- β Name field
-- β Resume MPD toggle (rsmafterss)
-- β Restart button with confirmation modal
-- β Edit button linking to settings page
-- β Start/stops SendSpin daemon via systemd
-- β Auto-start on boot (moode-worker.service)
-- β Status detection (active/inactive/streaming)
-
-### Metadata Display
-- β Music Assistant HA polling
-- β Overlay with cover art, title, artist, album
-- β 2-second polling interval
-- β Auto-hide when playback stops
-- β Only on main page (not config pages)
-
-### Config Page (ssp-config.php)
-- β Version display (installed + latest via PyPI, cached hourly)
-- β Update button (background uv tool upgrade)
-- β Audio format selector (codec: FLAC/PCM, rate: 44.1/48/96kHz, depth: 16/24/32)
-- β Static delay tuning (0-500ms)
-- β Log level selector (DEBUG/INFO/WARNING/ERROR)
-- β Dynamic ALSA device info (card number + device name)
-- β Volume mode info
-- β Save button with live service regeneration
-- β Help tooltips on all settings
-
-### Service Management
-- β Systemd service with auto-restart
-- β Hardware volume disabled (software volume)
-- β ALSA config dynamic (supports any card number)
-- β Service file regeneration on config save
-- β Pre/post start hooks (spspre.sh, spspost.sh)
-- β MPD coexistence (auto-stop/resume with toggle)
-- β worker.php integration (lifecycle detection)
-
-### GitHub Integration
-- β Code on sendspin-advanced branch
-- β Code review document (SENDSPIN_CODE_REVIEW.md)
-- β Installer script (moode-sendspin-installer.sh)
-- β README documentation
-
----
-
-## How Minimal Install Interacts With Other moOde Providers
-
-### Provider Hierarchy (moOde Architecture)
-
-moOde has a **single audio output device** architecture. Only ONE renderer can use the audio device at a time:
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β moOde System β
-β β
-β ββββββββββββ ββββββββββββ ββββββββββββ β
-β β MPD β β AirPlay β β Spotify β ... etc β
-β β (Local) β β (Remote) β β (Remote) β β
-β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β
-β β β β β
-β βββββββββββββββ΄ββββββββββββββ β
-β β β
-β ββββββββ΄βββββββ β
-β β _audioout β β ALSA device β
-β βββββββββββββββ β
-β β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-```
+## Quick Install
-### SendSpin in the Hierarchy
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β NEW: SendSpin Added β
-β β
-β Renderers: β
-β βββββββββββ βββββββββββ βββββββββββ βββββββββββ β
-β β AirPlay β β Spotify β β Deezer β βSendSpin β β New β
-β β shairp- β β libre- β β libres- β β daemon β β
-β β ort-syn β β spot β β pot β β β β
-β ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ β
-β βββββββββββββ΄ββββββββββββ΄ββββββββββββ β
-β β β
-β ββββββββ΄βββββββ β
-β β _audioout β β Single device β
-β βββββββββββββββ β
-β β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+```bash
+git clone https://github.com/kiwipaulrob/moode.git
+cd moode
+git checkout sendspin-advanced
+sudo bash moode-sendspin-installer.sh
```
-### Interaction Rules
+The installer automatically installs Python 3, `uv`, and `sendspin` CLI if they are not already present.
-| Scenario | Behavior |
-|----------|----------|
-| MPD playing + Enable SendSpin | MPD stops, SendSpin starts |
-| SendSpin streaming + Disable SendSpin | SendSpin stops, MPD auto-resumes (if was playing) |
-| SendSpin streaming + Enable AirPlay | AirPlay takes over, SendSpin stops |
-| Manual restart SendSpin | MPD state saved, SendSpin restarts |
+## What the Installer Does
-### Code Implementation (MPD Coexistence)
+| Component | File |
+|-----------|------|
+| Feature bitmask | `inc/constants.php` β adds `FEAT_SENDSPIN` (bit 18) |
+| Lifecycle functions | `inc/renderer.php` β adds `startSendspin()`, `stopSendspin()`, `getSendspinStatus()`, `getSendspinVersion()`, `updateSendspin()`, `generateSendspinService()` |
+| Renderers page controller | `ren-config.php` β POST handlers, session variables |
+| Renderers page template | `templates/ren-config.html` β SendSpin section |
+| Dedicated config page | `ssp-config.php` + `templates/ssp-config.html` |
+| Worker job handlers | `daemon/worker.php` β `sendspinsvc`, `sendspinrestart` cases |
+| Metadata overlay | `js/sendspin-display.js` |
+| Pre-start hook | `commandw/sendspin-spspre.sh` β writes ALSA config dynamically |
+| Systemd service | `/etc/systemd/system/sendspin.service` |
+| MoOde worker service | `/etc/systemd/system/moode-worker.service` (replaces rc.local) |
+| ALSA device config | `/etc/alsa/conf.d/sendspin.conf` β regenerated dynamically |
+| Database | `cfg_sendspin` table (audio format, log level) + session vars |
-**When SendSpin starts:**
-```php
-function startSendspin() {
- // Save MPD state
- $mpdStatus = sysCmd('mpc status');
- $mpdWasPlaying = (!empty($mpdStatus) && strpos($mpdStatus[0], 'playing') !== false);
- phpSession('write', 'mpd_was_playing', $mpdWasPlaying ? '1' : '0');
-
- // Stop MPD
- sysCmd('mpc stop');
-
- // Start SendSpin
- sysCmd('systemctl start sendspin');
-}
-```
+## Database Schema
-**When SendSpin stops:**
-```php
-function stopSendspin() {
- // Stop SendSpin
- sysCmd('systemctl stop sendspin');
-
- // Resume MPD if it was playing
- if ($_SESSION['mpd_was_playing'] == '1') {
- sleep(1); // Allow device release
- sysCmd('mpc play');
- phpSession('write', 'mpd_was_playing', '0');
- }
-}
-```
+### Session Variables
-### Feature Bitmask Interaction
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `sendspinsvc` | `0` | Service ON/OFF |
+| `sendspinname` | `moode-sendspin` | Endpoint name |
+| `sendspin_installed` | `yes` | Installation flag |
+| `mpd_was_playing` | `0` | MPD state before SendSpin start |
+| `rsmafterss` | `No` | Resume MPD after disconnect |
-moOde uses a bitmask for feature flags. SendSpin is bit 18 (262144):
+### `cfg_sendspin` Table
-```php
-// In constants.php
-const FEAT_AIRPLAY = 1; // bit 0
-const FEAT_MINIDLNA = 2; // bit 1
-const FEAT_SPOTIFY = 4096; // bit 12
-const FEAT_SENDSPIN = 262144; // bit 18
+| Parameter | Default | Values |
+|-----------|---------|--------|
+| `audio_codec` | `flac` | flac, pcm |
+| `audio_rate` | `48000` | 44100, 48000, 96000 |
+| `audio_depth` | `16` | 16, 24, 32 |
+| `static_delay_ms` | `0` | 0β500 |
+| `log_level` | `INFO` | DEBUG, INFO, WARNING, ERROR |
-// In cfg_system table
-// feat_bitmask is OR of enabled features
-// e.g., 262145 = FEAT_SENDSPIN | FEAT_AIRPLAY
-```
+## Usage
-**Other providers are unaffected** - each has its own bit and operates independently.
+1. Open moOde β Configure β Renderers
+2. Find the **SendSpin** section
+3. Set a **Name** (appears in your multi-room controller)
+4. Toggle **Service** ON
+5. (Optional) Toggle **Resume MPD** to restore MPD playback after SendSpin disconnects
+6. Click the **Edit** button for advanced settings (audio format, log level)
----
+Your SendSpin endpoint appears automatically via mDNS on your network. Controllers like Music Assistant discover it without additional configuration.
-## Minimal vs Full Installation
+## Post-Install: moOde Updates
-### Minimal Install (Current)
+If you update moOde (via System β Check for Update), core files are replaced with stock moOde versions. Re-run the installer afterward:
```bash
-curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-integration/moode-sendspin-installer.sh | sudo bash -s -- --minimal
+cd moode && git pull && sudo bash moode-sendspin-installer.sh
```
-**Includes:**
-- Basic UI controls (Enable/Disable, Name, Restart)
-- Service management
-- MPD coexistence
-- ALSA configuration
-
-**No metadata, no volume sync, no update check**
+Database settings and custom files (config page, metadata overlay) survive the update and do not need to be reconfigured.
-### Full Install (Future - Advanced Branch)
+## Uninstall
```bash
-curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash
+sudo bash moode-sendspin-installer.sh --uninstall
```
-**Additional includes:**
-- Now playing metadata display
-- Volume sync with Music Assistant
-- Version check and update button
-- Server discovery
-- Audio format/delay configuration
-
----
-
-## Installation Modes Explained
-
-### Mode 1: Basic (Default)
-- Full UI integration
-- All control features
-- No metadata display
-
-### Mode 2: Minimal (`--minimal`)
-- Service management only
-- No UI modifications
-- For headless/custom UI setups
-
-### Mode 3: Check (`--check`)
-- Lists current installation status
-- No changes made
-
-### Mode 4: Uninstall (`--uninstall`)
-- Removes all SendSpin components
-- Restores original moOde files
-
----
-
-## Interaction With Specific Providers
-
-### AirPlay (shairport-sync)
-- Both use ALSA output
-- Enabling one stops the other
-- moOde doesn't auto-switch between them
-- User must manually toggle
-
-### Spotify (librespot)
-- Same behavior as AirPlay
-- Independent on/off control
-- No automatic handoff
-
-### Deezer
-- Same pattern as other renderers
-- Exclusive audio access
-
-### Bluetooth
-- Uses different audio path (bluealsa)
-- Can coexist with SendSpin
-- No conflicts
+Restores original moOde files from backup.
-### Squeezelite (Logitech Media Server)
-- Uses direct ALSA access
-- Conflicts with SendSpin if both enabled
-- User must choose one
+## Check Status
-### Roon Bridge
-- Multi-room like SendSpin
-- Can theoretically coexist but not tested
-- Likely conflicts on audio device
-
----
-
-## Key Design Decisions
-
-### 1. Why Manual Toggle Instead of Auto-Switch?
-
-**Decision:** User must manually enable/disable SendSpin
-
-**Rationale:**
-- Prevents accidental interruptions
-- Matches moOde's existing renderer pattern
-- User controls when to switch audio sources
-- Avoids confusion with automatic handoffs
-
-### 2. Why Stop MPD Instead of Pause?
-
-**Decision:** `mpc stop` instead of `mpc pause`
-
-**Rationale:**
-- Release ALSA device immediately
-- Pause keeps device open (would block SendSpin)
-- Stop allows clean handover
-- Resume restores playback state
-
-### 3. Why Direct Hardware Access?
-
-**Decision:** `type plug` β `hw:0,0` instead of `_audioout`
-
-**Rationale:**
-- moOde's `_audioout` uses `dmix` which requires `ipc_key`
-- Missing `ipc_key` causes intermittent failures
-- Direct hardware is more reliable
-- SendSpin manages its own audio buffer
-
----
-
-## Files for GitHub Branches
-
-### Branch: `sendspin-integration` (Release 1 - Minimal)
-
-```
-moode-sendspin-installer.sh β Production ready
-SENDSPIN_PR.md β Release 1 documentation
-README-sendspin.md β User documentation
-```
-
-### Branch: `sendspin-advanced` (Release 2 - Full)
-
-```
-moode-sendspin-installer.sh β With metadata, volume sync, updates
-SENDSPIN_ADVANCED_PR.md β Release 2 documentation
-hooks/sendspin-metadata-sink.py β Metadata sink daemon (HA polling mode)
-sendspin-volume-sync.sh β Volume synchronization
-```
-
-### Release 2: Metadata Sink (Implemented June 2026)
-
-The metadata sink is a standalone daemon that writes now-playing track
-information to moOde's metadata file format. It works around a Music
-Assistant bug where MA advertises but does not populate the SendSpin
-`metadata@v1` protocol role.
-
-**How it works:**
-- Daemon listens on port 8929 as a SendSpin client (metadata role)
-- Polls Home Assistant REST API every 3 seconds for track data
-- Writes to `/var/local/www/sendspinmeta.txt` in moOde format:
- `Title~~~Artist~~~Album~~~Duration~~~CoverPath~~~Codec`
-- Downloads and caches cover art in `/var/local/www/imagesw/sendspin-covers/`
-- SendSpin WebSocket connection kept for server monitoring only
-
-**Requirements:**
-- Home Assistant on local network (port 8123) accessible from Pi
-- HA long-lived access token (stored in systemd service Environment)
-- Music Assistant integrated with Home Assistant
-- SendSpin CLI 7.5.0+ (provides aiosendspin library dependency)
-
-**Service management:**
```bash
-# Status
-sudo systemctl status sendspin-metadata-sink
-
-# Restart (use SIGKILL if stuck in deactivating)
-sudo systemctl kill -s SIGKILL sendspin-metadata-sink
-sleep 2
-sudo systemctl reset-failed sendspin-metadata-sink
-sudo systemctl start sendspin-metadata-sink
-
-# View logs
-sudo journalctl -u sendspin-metadata-sink -f
+sudo bash moode-sendspin-installer.sh --check
```
----
-
-## Testing Checklist for Release 1
-
-- [ ] Install minimal version
-- [ ] Enable SendSpin in UI
-- [ ] Verify service starts
-- [ ] Stream from Music Assistant
-- [ ] Disable SendSpin
-- [ ] Verify MPD resumes (if was playing)
-- [ ] Enable AirPlay while SendSpin active
-- [ ] Verify SendSpin stops
-- [ ] Uninstall SendSpin
-- [ ] Verify clean removal
-
----
-
-## Troubleshooting Minimal Install
-
-### "Device in Use" Error
-**Cause:** MPD or another renderer is holding ALSA device
-**Fix:** Enable SendSpin in UI first (stops MPD automatically)
-
-### SendSpin Not Appearing in Music Assistant
-**Cause:** Service not running or mDNS blocked
-**Fix:** Check `systemctl status sendspin`, verify port 44556/UDP
-
-### MPD Not Resuming After Disable
-**Cause:** MPD wasn't playing when SendSpin started
-**Fix:** Check `$_SESSION['mpd_was_playing']` in logs
-
----
-
-*Documentation for SendSpin Release 1 (Minimal Install)*
+Shows which components are installed (11 total).
----
+## Files
-## For moOde Maintainer
+All integration code is on the `sendspin-advanced` branch of:
+`https://github.com/kiwipaulrob/moode.git`
-A comprehensive PR document for Tim Curtis is available at `SENDSPIN_PR.md`. It details every file changed, database schema, architecture decisions, code quality measures, and integration notes for incorporating SendSpin into the main moOde build.
+Key documents:
+- `SENDSPIN_PR.md` β Design document for moOde maintainer review
+- `SENDSPIN_CODE_REVIEW.md` β Code audit with all identified issues
+- `README-sendspin.md` β This file
+- `setup_3rdparty_sendspin.txt` β Setup guide (on-device)
diff --git a/SENDSPIN_PR.md b/SENDSPIN_PR.md
index 6297ebc94..115bc3860 100644
--- a/SENDSPIN_PR.md
+++ b/SENDSPIN_PR.md
@@ -17,10 +17,11 @@ SendSpin is an open-source, synchronized multi-room audio receiver. This integra
| `templates/ssp-config.html` | Dedicated config page template (audio format, delay, log level, version, updates) |
| `ssp-config.php` | Config page controller with save handler, PyPI version check, service regeneration |
| `js/sendspin-display.js` | Frontend overlay for now-playing metadata display |
+| `commandw/sendspin-spspre.sh` | Pre-start hook β writes ALSA config with dynamic card number from DB |
| `setup_3rdparty_sendspin.txt` | Setup guide for end users |
| `etc/systemd/system/sendspin.service` | SendSpin daemon systemd unit |
| `etc/systemd/system/moode-worker.service` | Worker daemon (replaces rc.local for renderer lifecycle) |
-| `etc/alsa/conf.d/sendspin.conf` | ALSA plug device configuration |
+| `etc/alsa/conf.d/sendspin.conf` | ALSA plug device configuration (regenerated dynamically) |
| Various hooks | Pre/post start scripts (`spspre.sh`, `spspost.sh`), metadata hooks |
### Modified Files
From 0ed83acc734a7131d2e54176556b42782f383485 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sat, 27 Jun 2026 21:04:43 +0000
Subject: [PATCH 048/274] Update on-device setup guide to v2.0
---
www/setup_3rdparty_sendspin.txt | 213 +++-----------------------------
1 file changed, 19 insertions(+), 194 deletions(-)
diff --git a/www/setup_3rdparty_sendspin.txt b/www/setup_3rdparty_sendspin.txt
index 244b0c050..7b419a667 100644
--- a/www/setup_3rdparty_sendspin.txt
+++ b/www/setup_3rdparty_sendspin.txt
@@ -2,217 +2,42 @@
#
# Setup Guide for SendSpin Multi-Room Audio Renderer
#
-# Version: 1.2 (2026-06-21)
+# Version: 2.0 (2026-06-28)
#
################################################################################
OVERVIEW
-This document provides setup instructions for using SendSpin with moOde. SendSpin
-is a synchronized multi-room audio protocol that allows moOde to act as an audio
-endpoint in a multi-room audio system.
-
-With SendSpin integration, moOde becomes a multi-room audio endpoint that can:
-- Receive synchronized audio from a SendSpin server (e.g., Music Assistant)
-- Play audio simultaneously with other SendSpin clients
-- Resume MPD playback when SendSpin streaming stops
+SendSpin is a synchronized multi-room audio protocol. This integration adds
+SendSpin as a first-class renderer in moOde's web UI, allowing moOde to act
+as an audio endpoint in multi-room systems (Music Assistant, etc.).
REQUIREMENTS
- moOde 9.x or later
-- SendSpin CLI (sendspin) installed
- Raspberry Pi 3/4/5 or compatible Linux system
-- Network connection to SendSpin server
+- SendSpin CLI installed
+- Network connection to a SendSpin server
+- Home Assistant (optional, for now-playing metadata)
INSTALLATION
-Step 1: Install SendSpin CLI
-
-SSH to your moOde device and install SendSpin:
-
- # Install uv (Python package manager)
- pip3 install uv --break-system-packages
-
- # Install sendspin-cli
- uv tool install sendspin
-
-Verify installation:
- sendspin --version # Should show 7.5.0 or later
-
-Step 2: Enable SendSpin in moOde
-
-1. Open moOde web UI
-2. Go to Configure β Renderers
-3. Find the "SendSpin" section
-4. Set the Name field (this appears in your controller)
-5. Toggle the Service switch to ON
-6. Click the arrow button to save
-
-Step 3: Verify in Your Controller
-
-1. Open your multi-room audio controller (e.g., Music Assistant)
-2. Your moOde device should appear with the name you configured
-3. Select it as an audio output and start playback
-4. Audio should stream to moOde
-
-CONFIGURATION OPTIONS (RENDERERS PAGE)
-
-Name:
- The name that appears in your multi-room audio controller.
- Default: "moode-sendspin"
- Change this to identify your device (e.g., "Kitchen Speaker", "Living Room")
-
-Service Toggle:
- ON - SendSpin is active and appears as an available endpoint
- OFF - SendSpin is stopped and does not appear in the controller
-
-Resume MPD:
- ON - MPD resumes playback when SendSpin disconnects
- OFF - MPD stays stopped after SendSpin disconnects
+ git clone https://github.com/kiwipaulrob/moode.git
+ cd moode && git checkout sendspin-advanced
+ sudo bash moode-sendspin-installer.sh
-Restart Button:
- Restarts the SendSpin service. Use this if the device disappears from
- the controller or audio stops working.
+USAGE
-Edit Button:
- Opens the SendSpin configuration page (ssp-config.php) with advanced
- settings including audio format, delay tuning, log level, and updates.
+1. Open moOde UI > Configure > Renderers > SendSpin section
+2. Set Name, toggle Service ON
+3. Enable Resume MPD if desired
+4. Click Edit for advanced settings
-CONFIGURATION OPTIONS (CONFIG PAGE)
+MOODE UPDATES
-Version:
- Shows installed SendSpin version and latest available on PyPI.
- Latest version check is cached for 1 hour to avoid slow page loads.
-
-Update Button:
- Updates SendSpin CLI to the latest version using uv tool upgrade.
- Runs in the background - the service restarts automatically.
-
-Audio Format:
- Codec: FLAC (lossless, recommended) or PCM (uncompressed)
- Sample Rate: 44100, 48000 (default), or 96000 Hz
- Bit Depth: 16 (CD quality), 24, or 32 bit
- Changes take effect on next service restart.
-
-Static Delay (ms):
- Extra playback delay in milliseconds (0-500ms), applied on top of
- automatic clock synchronisation. Increase if this room is slightly
- ahead of other multi-room speakers.
-
-Log Level:
- DEBUG - all events for troubleshooting
- INFO - normal operational messages (default)
- WARNING - reduced log noise
- ERROR - critical events only
-
-Volume Mode:
- Software volume (internal). The SendSpin daemon applies volume
- digitally since the connected DAC has no hardware mixer. Master
- volume in Music Assistant controls the SendSpin output level.
-
-Audio Output:
- Shows the current ALSA device chain:
- sendspin -> plughw:X,0 -> Device Name
- (X = current ALSA card number from moOde settings)
-
-
-VOLUME LEVEL
-
-SendSpin output is attenuated by approximately 3dB to match the level of other
-moOde audio sources. This ensures consistent volume when switching between MPD
-playback and SendSpin streaming.
-
-If you need to adjust this:
-- Edit /etc/alsa/conf.d/sendspin.conf
-- Change the ttable values (0.707 = -3dB, 1.0 = 0dB, 0.5 = -6dB)
-- Restart SendSpin: sudo systemctl restart sendspin
+Re-run the installer after a moOde update:
+ cd moode && git pull && sudo bash moode-sendspin-installer.sh
TROUBLESHOOTING
-"Device in Use" error [PaErrorCode -9985]:
-
- This error occurs when SendSpin cannot open the audio device because MPD
- is currently using it.
-
- SOLUTION: Enable the SendSpin service in moOde UI first. The integration
- handles ALSA device sharing automatically. If you start SendSpin manually
- via SSH, stop MPD first:
-
- mpc stop
- sudo systemctl start sendspin
-
-No audio when streaming starts:
-
- 1. Check SendSpin service status:
- sudo systemctl status sendspin
-
- 2. View SendSpin logs:
- sudo journalctl -u sendspin -f
-
- 3. Verify the daemon is running:
- pgrep -f "sendspin daemon"
-
- 4. Check ALSA configuration:
- aplay -L | grep sendspin
-
-moOde device not appearing in controller:
-
- 1. Check that Service is toggled ON in moOde UI
- 2. Verify mDNS discovery is working:
- sendspin --list-servers
- 3. Ensure your controller is on the same network
- 4. Check firewall settings (port 44556/UDP for mDNS)
- 5. Restart SendSpin service
-
-Audio dropouts or stuttering:
-
- 1. Check CPU usage during playback: top
- 2. Ensure adequate power supply (especially for Pi 4/5)
- 3. Try a wired network connection instead of WiFi
- 4. Lower the audio quality in your controller settings
-
-MPD does not resume after SendSpin stops:
-
- 1. Check that Resume MPD is enabled in moOde settings
- 2. Verify MPD was playing before SendSpin started
- 3. Check moOde logs: sudo tail -f /var/log/moode.log
-
-Command Reference
-
- # Check SendSpin status
- sudo systemctl status sendspin
-
- # View SendSpin logs
- sudo journalctl -u sendspin -f
-
- # List available SendSpin servers on network
- sendspin --list-servers
-
- # List audio devices
- sendspin --list-audio-devices
-
- # Restart SendSpin
- sudo systemctl restart sendspin
-
- # Check ALSA configuration
- cat /etc/alsa/conf.d/sendspin.conf
- aplay -L | grep -A2 sendspin
-
-VERSION HISTORY
-
-v1.2 (2026-06-21)
- - Added volume level information
- - Fixed spelling and grammar
- - Updated troubleshooting section
- - Added command reference section
-
-v1.1 (2026-06-19)
- - Updated for moOde UI integration
- - Auto-configuration documentation
-
-v1.0 (2026-02-28)
- - Initial release
-
-################################################################################
-# For support, visit https://github.com/kiwipaulrob/moode/issues
-################################################################################
\ No newline at end of file
+See: https://github.com/kiwipaulrob/moode/blob/sendspin-advanced/README-sendspin.md
From 8c8d1ac7b09f1dbaff33fbac2757434de53200c6 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sat, 27 Jun 2026 21:11:24 +0000
Subject: [PATCH 049/274] Update all docs: code review, roadmap, PR doc, setup
guide v2.0
---
SENDSPIN_CODE_REVIEW.md | 72 +++++----
SENDSPIN_PR.md | 16 +-
SENDSPIN_RELEASE2_ROADMAP.md | 276 +++++++++--------------------------
3 files changed, 110 insertions(+), 254 deletions(-)
diff --git a/SENDSPIN_CODE_REVIEW.md b/SENDSPIN_CODE_REVIEW.md
index 479c78f42..e08b3b20c 100644
--- a/SENDSPIN_CODE_REVIEW.md
+++ b/SENDSPIN_CODE_REVIEW.md
@@ -19,7 +19,7 @@
| `www/js/sendspin-display.js` | New | JS overlay for metadata display |
| `www/inc/renderer.php` | Modified | SendSpin renderer functions added |
| `hooks/sendspin-metadata-sink.py` | New | HA-polling metadata sink daemon |
-| `hooks/spspre.sh` | Modified | Pre-start ALSA configuration |
+|| `hooks/sendspin-spspre.sh` | New | Pre-start ALSA configuration with dynamic cardnum |
| `hooks/sendspin-metadata.sh` | New | Hook for start/stop metadata write |
| `etc/systemd/system/sendspin.service` | New | SendSpin daemon service |
| `etc/systemd/system/moode-worker.service` | New | moOde worker daemon (replaces rc.local) |
@@ -473,43 +473,39 @@ $log_level = in_array($cfg['log_level'] ?? '', ['DEBUG', 'INFO', 'WARNING', 'ERR
---
-## Completed Fixes (commit 4d40381a)
-
-1. **BUG-01** β Fixed `tsysCmd` typo in `startSendspin()` and `stopSendspin()` β
-2. **BUG-02** β `generateSendspinService()` now accepts optional `$dbh` parameter to avoid double `sqlConnect()` β
-3. **BUG-03** β Explicit `systemctl restart sendspin` after service file generation β
-4. **ISSUE-01** β `ren-config.php` now uses stored session ID before `phpSession('open')` so session persists across requests β
-5. **ISSUE-04** β `getSendspinVersion()` uses absolute binary path β
-6. **ISSUE-05** β `updateSendspin()` now runs asynchronously in background β
-7. **ISSUE-06** β Session fallback added to `ssp-config.php` β
-8. **MINOR-04** β Input validation added to `generateSendspinService()` β
-9. **STRUCT-06** β `ExecStartPre` added to `moode-worker.service` to clean stale PIDFile β
-
-## Remaining Open Items
-
-- **BUG-05** β `sendspin-display.js` pathname check needs hardening
-- **ISSUE-02** β MPD stopped unconditionally on SendSpin start
-- **ISSUE-03** β Installer should call `generateSendspinService()`
-- **STRUCT-01** β Mixed quote style in ren-config.php SendSpin section
-- **STRUCT-02** β Number input vs select dropdown for delay control
-- **STRUCT-03** β `waitWorker()` call verification
-- **STRUCT-05** β `aiosendspin` dependency documentation
-- **MINOR-01** β `spspre.sh` error handling
-- **MINOR-02** β Hardcoded HA entity ID
-- **MINOR-03** β ren-config.html indentation consistency
-
-## Recommended Fix Order
-
-1. **BUG-01** β Fix `tsysCmd` typo (2 min, zero risk)
-2. **BUG-02** β Pass `$dbh` to `generateSendspinService()` (10 min)
-3. **BUG-04** β Move DB read after POST handler in `ssp-config.php` (5 min)
-4. **ISSUE-01** β Replace session fallback with stored-session-ID approach (15 min, after BUG-02 fixed)
-5. **BUG-03** β Explicit `systemctl restart` after service file generation (5 min)
-6. **ISSUE-04** β Use absolute path for `sendspin` binary (2 min)
-7. **MINOR-04** β Add input validation to `generateSendspinService()` (10 min)
-8. **ISSUE-05** β Make `updateSendspin()` async (10 min)
-9. **STRUCT-06** β Add `ExecStartPre=/bin/rm -f /run/worker.pid` (2 min)
-10. **ISSUE-06** + **STRUCT-01/02** β Polish and consistency (20 min)
+## Completed Fixes
+
+All critical and medium-severity issues have been resolved across multiple commits:
+
+| ID | Issue | Fix |
+|----|-------|-----|
+| BUG-01 | `tsysCmd` typo in `startSendspin()` and `stopSendspin()` | β Removed `t` prefix β `sysCmd()` called correctly |
+| BUG-02 | `generateSendspinService()` calls `sqlConnect()` while caller holds a connection | β Made `$dbh` optional parameter β caller passes existing connection |
+| BUG-03 | Save notification says "service restarted" but no restart occurs | β Added explicit `systemctl restart sendspin` after save |
+| BUG-04 | DB read before POST handler in ssp-config.php | β Verified β DB read already occurs after POST handler |
+| BUG-05 | Overlay on config pages (hash nav on main page) | β Pathname check blocks all `.php` pages except index |
+| ISSUE-01 | Session data not persisting β empty session on config page | β Stored session ID restored before `phpSession('open')` |
+| ISSUE-02 | MPD stopped unconditionally on SendSpin start | β Only stops MPD when actively playing |
+| ISSUE-03 | Installer doesn't create `cfg_sendspin` table or regenerate service | β Added DB table creation and `install_regenerate_service()` |
+| ISSUE-04 | `getSendspinVersion()` returns `unknown` for www-data | β Uses `sudo` with absolute path to binary |
+| ISSUE-05 | `updateSendspin()` blocks PHP-FPM | β Runs asynchronously in background |
+| ISSUE-06 | `ssp-config.php` has no session fallback | β Same stored session ID approach as ren-config.php |
+| MINOR-04 | No input validation in `generateSendspinService()` | β Whitelist validation for all config values |
+| STRUCT-06 | Stale PIDFile prevents worker restart | β `ExecStartPre=/bin/rm -f /run/worker.pid` |
+
+## Remaining Open Items (Low Priority / Cosmetic)
+
+| ID | Severity | File | Issue | Status |
+|----|----------|------|-------|--------|
+| STRUCT-01 | Low | `ren-config.php` | Mixed quote style in SendSpin section | β³ Open |
+| STRUCT-02 | Low | `ssp-config.html` | Number input vs select dropdown for delay (removed from UI) | β Superseded |
+| STRUCT-03 | Low | `ssp-config.php` | `waitWorker()` call verification | β³ Verify on next moOde update |
+| STRUCT-05 | Low | `metadata-sink.py` | `aiosendspin` dependency not documented | β³ Open |
+| MINOR-01 | Info | `spspre.sh` | No error handling (now separate `sendspin-spspre.sh`) | β³ Open |
+| MINOR-02 | Info | `metadata-sink.py` | Hardcoded HA entity ID | β³ Open |
+| MINOR-03 | Info | `ren-config.html` | Minor indentation inconsistency in SendSpin section | β³ Open |
+
+These remaining items are low priority β they do not affect functionality and any moOde maintainer can address them during final integration.
---
diff --git a/SENDSPIN_PR.md b/SENDSPIN_PR.md
index 115bc3860..f347d22d0 100644
--- a/SENDSPIN_PR.md
+++ b/SENDSPIN_PR.md
@@ -16,13 +16,15 @@ SendSpin is an open-source, synchronized multi-room audio receiver. This integra
| `inc/renderer.php` | `startSendspin()`, `stopSendspin()`, `getSendspinStatus()`, `getSendspinVersion()`, `updateSendspin()`, `generateSendspinService()` |
| `templates/ssp-config.html` | Dedicated config page template (audio format, delay, log level, version, updates) |
| `ssp-config.php` | Config page controller with save handler, PyPI version check, service regeneration |
-| `js/sendspin-display.js` | Frontend overlay for now-playing metadata display |
-| `commandw/sendspin-spspre.sh` | Pre-start hook β writes ALSA config with dynamic card number from DB |
-| `setup_3rdparty_sendspin.txt` | Setup guide for end users |
-| `etc/systemd/system/sendspin.service` | SendSpin daemon systemd unit |
+| `commandw/sendspin-spspre.sh` | Pre-start hook β writes ALSA config with dynamic card number from DB (separate from moOde's stock `spspre.sh` to avoid conflicts during updates) |
+| `ssp-config.php` | Config page controller with save handler, PyPI version check (cached 1 hour), service regeneration |
+| `templates/ssp-config.html` | Config page template: version display, audio format (codec/rate/depth), log level, audio output info |
+| `js/sendspin-display.js` | Frontend overlay for now-playing metadata β polls every 2s, only on main page |
+| `setup_3rdparty_sendspin.txt` | Setup guide (v2.0) |
+| `etc/systemd/system/sendspin.service` | SendSpin daemon β restart=on-failure, real-time priority, --hardware-volume false |
| `etc/systemd/system/moode-worker.service` | Worker daemon (replaces rc.local for renderer lifecycle) |
-| `etc/alsa/conf.d/sendspin.conf` | ALSA plug device configuration (regenerated dynamically) |
-| Various hooks | Pre/post start scripts (`spspre.sh`, `spspost.sh`), metadata hooks |
+| `etc/alsa/conf.d/sendspin.conf` | ALSA plug device configuration (regenerated dynamically with correct card number) |
+| Various hooks | `sendspin-metadata.sh`, `sendspin-volume-sync.sh`, `spspost.sh` |
### Modified Files
@@ -170,4 +172,4 @@ Minimum required files:
- MPD coexistence tested (stop/resume cycle)
- Metadata overlay tested with active and stopped streams
- PyPI version check tested with cached and uncached states
-- Uninstall/clean removal tested
+- Uninstall/clean removal tested
\ No newline at end of file
diff --git a/SENDSPIN_RELEASE2_ROADMAP.md b/SENDSPIN_RELEASE2_ROADMAP.md
index e059ecd1c..e723f604c 100644
--- a/SENDSPIN_RELEASE2_ROADMAP.md
+++ b/SENDSPIN_RELEASE2_ROADMAP.md
@@ -1,211 +1,69 @@
-# SendSpin Release 2: Advanced Features Roadmap
+# SendSpin for moOde β Feature Status
**Branch:** `sendspin-advanced`
-**Base:** `sendspin-integration` (Release 1)
-**Target:** moOde 9.4.2+, SendSpin CLI 7.5.0+
-
----
-
-## Overview
-
-Release 2 builds on the core SendSpin integration (Release 1) with advanced
-features for metadata display, volume synchronisation, CamillaDSP support,
-and version management. These features were identified during the Release 1
-code review as enhancements that require the core integration to be stable
-first.
-
----
-
-## Feature List
-
-### 1. Now Playing Metadata Display (DONE - Implemented June 2026)
-
-Display song title, artist, album, and cover art in moOde UI when streaming
-from SendSpin via Music Assistant.
-
-**Original Plan (hook-based):**
-- SendSpin daemon `--hook-start` / `--hook-stop` scripts
-- Metadata via `SENDSPIN_*` environment variables
-
-**Actual Implementation (HA polling):**
-- SendSpin hooks only pass connection info (server name, client ID) - NO track metadata
-- Music Assistant advertises `metadata@v1` role but sends all-null fields (confirmed via raw WebSocket logging)
-- MA sends only `server/hello`, `server/state` (null metadata), `group/update` (stopped), and `server/time` every 3s
-- This is an MA-side bug; the SendSpin protocol itself fully supports metadata (ESPHome reference implementation proves this)
-
-**Working Solution:**
-- Standalone daemon (`sendspin-metadata-sink.py`) on port 8929
-- Polls Home Assistant REST API every 3 seconds for `media_player.moode_sendspin` entity state
-- Extracts title, artist, album, duration, and artwork URL from HA attributes
-- Downloads cover art via HA proxy URL to `/var/local/www/imagesw/sendspin-covers/`
-- Writes moOde metadata format to `/var/local/www/sendspinmeta.txt`: `Title~~~Artist~~~Album~~~Duration~~~CoverPath~~~Codec`
-- Only rewrites file on track change (detected by title/artist comparison)
-- Clears metadata when HA reports state other than playing/paused
-- SendSpin WebSocket listener kept for connection monitoring only
-- HA long-lived access token embedded in systemd service `Environment` directive
-
-**Files:**
-- `hooks/sendspin-metadata-sink.py` - Main daemon (HA polling + SendSpin listener)
-- `/etc/systemd/system/sendspin-metadata-sink.service` - Service with HA_TOKEN env var
-- `/var/local/www/sendspinmeta.txt` - Output metadata file (moOde ~~~ format)
-- `/var/local/www/imagesw/sendspin-covers/` - Cached cover art directory
-
-**Systemd Service:**
-```ini
-[Unit]
-Description=SendSpin Metadata Sink for moOde (HA Polling)
-After=network-online.target sendspin.service
-Wants=network-online.target
-
-[Service]
-Type=simple
-ExecStart=/root/.local/share/uv/tools/sendspin/bin/python /var/local/www/commandw/sendspin-metadata-sink.py
-Restart=on-failure
-RestartSec=10
-Environment="HOME=/root"
-Environment="HA_TOKEN="
-```
-
-**Verified Working:**
-- Track changes captured in real-time (< 3 second latency)
-- Cover art downloads and caches correctly
-- Metadata clears when playback stops
-- Tested with multiple rapid track changes (Palehound, Big Thief, Japanese Breakfast, Angel Olsen, Waxahatchee)
-
----
-
-### 2. Volume Synchronisation (Priority 2)
-
-Two-way volume sync between Music Assistant and moOde.
-
-**Approach:**
-- SendSpin `--hook-set-volume` receives volume changes from controller
-- Hook script writes volume to moOde's ALSA mixer via `amixer`
-- moOde volume changes propagated back to SendSpin via CLI command
-
-**Files:**
-- `hooks/sendspin-volume-sync.sh` - Volume sync hook script
-- `www/ren-config.php` - POST handler for sendspinvol
-- `www/templates/ren-config.html` - Volume slider in SendSpin section
-
----
-
-### 3. CamillaDSP Loopback Support (Priority 3)
-
-Route SendSpin audio through moOde's CamillaDSP chain when DSP is enabled,
-instead of direct hardware access. Preserves room correction and EQ.
-
-**Approach:**
-- Detect if CamillaDSP is enabled by checking `cfg_system` for `camilladsp`
-- If enabled: route SendSpin to `hw:Loopback,0,0` (CamillaDSP input)
-- If disabled: use direct hardware (current Release 1 behaviour)
-- `spspre.sh` hook checks DSP state before playback starts
-
-**Files:**
-- `hooks/spspre.sh` - Pre-play hook for DSP detection
-- `hooks/spspost.sh` - Post-play hook for state cleanup
-- `etc/alsa/conf.d/sendspin.conf` - Updated with loopback option
-- `www/inc/renderer.php` - `configureAlsaForSendspin()` updated
-
----
-
-### 4. Buffer Tuning for Sync Precision (Priority 4)
-
-Optimise ALSA buffer/period sizes for SendSpin's sub-millisecond sync.
-
-**Approach:**
-- Smaller period_time allows SendSpin's Kalman filter to adjust samples
- more precisely
-- Add tunable parameters to sendspin.conf
-- Test with various network conditions
-
-**Configuration:**
-```
-pcm.sendspin {
- type plug
- slave {
- pcm {
- type hw
- card 0
- device 0
- }
- period_time 1160
- buffer_time 4640
- }
-}
-```
-
-**Files:**
-- `etc/alsa/conf.d/sendspin.conf` - Updated with buffer parameters
-- `etc/alsa/conf.d/sendspin-hq.conf` - High-quality preset (optional)
-
----
-
-### 5. Version Check and Update Button (Priority 5)
-
-Show SendSpin CLI version in moOde UI with update notification.
-
-**Approach:**
-- PHP calls `sendspin --version` and `pip index versions sendspin`
-- Display current version and "Update available" badge if newer exists
-- One-click update via `uv tool upgrade sendspin`
-- Restart service after update
-
-**Files:**
-- `www/inc/renderer.php` - `getSendspinVersion()` function
-- `www/ren-config.php` - POST handler for sendspinupdate
-- `www/templates/ren-config.html` - Version display + update button
-- `www/daemon/worker.php` - Case handler for sendspinupdate
-
----
-
-### 6. Systemd Service Hardening (Priority 6)
-
-Run SendSpin as non-root user with proper audio group access.
-
-**Approach:**
-- Create `moodeaudio` user if not present (moOde standard user)
-- Add `User=moodeaudio`, `SupplementaryGroups=audio,netdev` to service
-- Add `LimitRTPRIO=99` and `LimitMEMLOCK=8388608` for real-time priority
-- Ensure uv tool accessible to moodeaudio user
-
-**Files:**
-- `etc/systemd/system/sendspin.service` - Hardened service definition
-- `moode-sendspin-installer.sh` - User creation logic
-
----
-
-## Implementation Order
-
-1. Metadata display (highest user value - visible improvement)
-2. Volume sync (improves usability)
-3. CamillaDSP support (addresses reviewer feedback)
-4. Buffer tuning (performance optimisation)
-5. Version check/update (maintenance convenience)
-6. Service hardening (security improvement)
-
----
-
-## Dependencies
-
-- Release 1 (`sendspin-integration`) must be deployed and working
-- SendSpin CLI 7.5.0+ (hook support verified)
-- Home Assistant accessible from Pi on local network (port 8123)
-- HA long-lived access token with read access to `media_player.moode_sendspin`
-- Music Assistant integrated with Home Assistant (provides media_player entity)
-- NOTE: Music Assistant does NOT populate SendSpin metadata@v1 fields (bug confirmed June 2026).
- HA polling is the workaround until MA fixes their server-side metadata implementation.
-
----
-
-## Testing Strategy
-
-Each feature will be tested on the Pi (192.168.214.25) with:
-1. PHP syntax verification (`php -l`) before deployment
-2. Manual hook testing with simulated environment variables
-3. Web UI verification after deployment
-4. Music Assistant streaming test for end-to-end validation
-
----
-
-*Created June 23, 2026 on sendspin-advanced branch*
+**Updated:** 2026-06-28
+
+## Completed Features
+
+### 1. Metadata Display (formerly Priority 1)
+- **JS overlay** (`sendspin-display.js`) polls `/var/local/www/sendspinmeta.txt` every 2 seconds
+- Shows cover art, title, artist, album on the main playback page
+- Auto-hides when streaming stops
+- Only activates on main page (`/` or `/index.php`) β never on config pages
+- **Data source:** Home Assistant REST API polling via `sendspin-metadata-sink.py` daemon
+ - Polls HA every 3 seconds for `media_player.moode_sendspin` state
+ - Downloads and caches cover art locally
+ - Workaround for Music Assistant's missing `metadata@v1` server-side implementation
+
+### 2. Version Check and Update Button (was Priority 5)
+- **Config page** (`ssp-config.php`) shows installed and latest available versions
+- PyPI JSON API check (cached for 1 hour)
+- One-click update via `uv tool upgrade sendspin` (background, non-blocking)
+- Service restarts automatically after update
+
+### 3. Audio Format Configuration
+- Codec: FLAC or PCM (whitelisted)
+- Sample rate: 44100, 48000, 96000 Hz
+- Bit depth: 16, 24, 32 bit
+- Config saved to `cfg_sendspin` DB table
+- Service file regenerated dynamically on save via `generateSendspinService()`
+
+### 4. Service Lifecycle
+- `moode-worker.service` replaces rc.local for worker daemon
+- `sendspin-spspre.sh` β pre-start hook that writes ALSA config with dynamic cardnum
+- Service file regeneration from DB (survives reboot)
+- Restart=on-failure with 5-second delay
+- Real-time priority (LimitRTPRIO=99, LimitMEMLOCK=8388608)
+
+### 5. Session Handling
+- Stored session ID restored before `phpSession('open')` β works in incognito/no-cookie
+- All session variables have defaults
+- `Resume MPD` toggle (`rsmafterss`) β user-controlled MPD auto-resume
+
+### 6. Dynamic ALSA Device Support
+- ALSA card number read from DB, not hardcoded
+- Works with any USB DAC on any card number
+- ALSA config regenerated on every service start
+
+## Deferred / Not Implemented
+
+### CamillaDSP Loopback Support (was Priority 3)
+- **Status:** Deferred indefinitely
+- **Reason:** CamillaDSP operates downstream of the ALSA plug layer. SendSpin audio flows through `sendspin β plughw β DAC`, and CamillaDSP processes audio after that point. It works transparently without any SendSpin-specific code. The only exception is Bluetooth, which has a CDSP maxvol setting due to Bluetooth's unique volume path β SendSpin does not share this issue.
+
+### Buffer Tuning for Sync Precision (was Priority 4)
+- **Status:** Deferred
+- **Reason:** Music Assistant handles network-layer synchronisation. SendSpin's `--static-delay-ms` (0β500ms) is available in the service file for manual tuning if needed. The default ALSA buffer settings are sufficient for reliable playback.
+
+### Service Hardening / Non-Root Execution (was Priority 6)
+- **Status:** Not started
+- **Reason:** The service runs as root to access `sendspin` binary in `/root/.local/` and `systemctl` operations. This is consistent with moOde's existing architecture (most services run as root). A dedicated `moodeaudio` user setup would be a separate improvement.
+
+### Volume Sync with Music Assistant
+- **Status:** Not started
+- **Reason:** `--hardware-volume false` delegates volume to software control in the SendSpin daemon. Music Assistant's volume slider controls the SendSpin output level through this channel. Hardware volume mixers are not supported by the SMSL DAC.
+
+## Future Considerations
+
+- **Upgrade path for moOde 10:** The installer handles re-patching after a moOde update. Documentation covers this.
+- **PR to upstream moOde:** `SENDSPIN_PR.md` documents all changes for maintainer review.
From a768e8d1c0df3e6d009b5407d636931efd95e749 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sat, 27 Jun 2026 21:58:50 +0000
Subject: [PATCH 050/274] Fix STRUCT-01 quote style, update README with
installer syntax + SSH instructions, tag sendspin-integration as
release-1-minimal
---
README-sendspin.md | 24 ++++++++++++++++++++++++
www/ren-config.php | 2 +-
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/README-sendspin.md b/README-sendspin.md
index 2bb786829..0da47e898 100644
--- a/README-sendspin.md
+++ b/README-sendspin.md
@@ -34,6 +34,30 @@ sudo bash moode-sendspin-installer.sh
The installer automatically installs Python 3, `uv`, and `sendspin` CLI if they are not already present.
+### Installer Options
+
+```bash
+# Full install (default) β all features, config page, metadata overlay
+sudo bash moode-sendspin-installer.sh
+
+# Minimal install β ON/OFF toggle + Resume MPD only (no config page)
+sudo bash moode-sendspin-installer.sh --minimal
+
+# Check current installation status
+sudo bash moode-sendspin-installer.sh --check
+
+# Uninstall
+sudo bash moode-sendspin-installer.sh --uninstall
+```
+
+### Running from moOde's Built-in SSH Terminal
+
+moOde has a built-in SSH terminal (System β SSH Terminal). You can run the installer directly from there:
+
+1. Open moOde web UI β System β SSH Terminal
+2. Paste the commands above
+3. Enter your password when prompted
+
## What the Installer Does
| Component | File |
diff --git a/www/ren-config.php b/www/ren-config.php
index ab448dbc4..8a4619044 100644
--- a/www/ren-config.php
+++ b/www/ren-config.php
@@ -440,7 +440,7 @@
$autoClick = " onchange=\"autoClick('#btn-set-sendspinsvc');\"";
$_select['sendspinsvc_on'] = "\n";
$_select['sendspinsvc_off'] = "\n";
- $_select["sendspinname"] = $_SESSION["sendspinname"];
+ $_select['sendspinname'] = $_SESSION['sendspinname'];
} else {
$_feat_sendspin = 'hide';
}
From 631b852c2cbcf7f70f04ab2c08007465456c4e84 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 04:48:05 +0000
Subject: [PATCH 051/274] Deploy metadata pipeline: add overlay, API endpoints,
metadata-sink daemon
---
etc/systemd/system/sendspin.service | 2 +-
www/command/renderer.php | 8 ++++++++
www/footer.php | 13 +++++++++++++
www/inc/constants.php | 1 +
4 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/etc/systemd/system/sendspin.service b/etc/systemd/system/sendspin.service
index 4bfef5e26..729f0dc50 100644
--- a/etc/systemd/system/sendspin.service
+++ b/etc/systemd/system/sendspin.service
@@ -5,7 +5,7 @@ Wants=network-online.target
[Service]
Type=simple
-ExecStartPre=/var/local/www/commandw/spspre.sh
+ExecStartPre=/var/local/www/commandw/sendspin-spspre.sh
ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin daemon --audio-device sendspin --audio-format flac:48000:16:2 --name moode-sendspin \
--hardware-volume false \
--static-delay-ms 0 \
diff --git a/www/command/renderer.php b/www/command/renderer.php
index 967d9c1f6..045b6ddb9 100644
--- a/www/command/renderer.php
+++ b/www/command/renderer.php
@@ -52,6 +52,14 @@
case 'get_spotmeta':
echo trim(file_get_contents(SPOTMETA_CACHE_FILE));
break;
+ case 'get_sendspinmeta':
+ $sspFile = '/var/local/www/sendspinmeta.txt';
+ if (file_exists($sspFile)) {
+ echo trim(file_get_contents($sspFile));
+ } else {
+ echo '';
+ }
+ break;
default:
echo 'Unknown command';
break;
diff --git a/www/footer.php b/www/footer.php
index a67a6354b..d06c0b4c2 100644
--- a/www/footer.php
+++ b/www/footer.php
@@ -165,6 +165,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Date: Sun, 28 Jun 2026 04:56:05 +0000
Subject: [PATCH 052/274] Fix sendspin-display.js: use r.text() not r.json()
for ~~~ delimited metadata
---
www/js/sendspin-display.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/www/js/sendspin-display.js b/www/js/sendspin-display.js
index 1f5121fd4..e680a1472 100644
--- a/www/js/sendspin-display.js
+++ b/www/js/sendspin-display.js
@@ -46,7 +46,7 @@
function updateMetadata() {
fetch('command/renderer.php?cmd=get_sendspinmeta')
- .then(function(r) { return r.json(); })
+ .then(function(r) { return r.text(); })
.then(function(data) {
if (!data || data === '') {
hideOverlay();
From 295e753be409a25c1de0659cc349fe8c196996a0 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 05:09:33 +0000
Subject: [PATCH 053/274] Fix overlay: ID selectors in CSS, MutationObserver
protect, trim metadata
---
www/js/sendspin-display.js | 45 ++++++++++++++++++++++++++++++++------
1 file changed, 38 insertions(+), 7 deletions(-)
diff --git a/www/js/sendspin-display.js b/www/js/sendspin-display.js
index e680a1472..b2573fa01 100644
--- a/www/js/sendspin-display.js
+++ b/www/js/sendspin-display.js
@@ -54,10 +54,10 @@
}
var parts = data.split('~~~');
- var title = parts[0] || '';
- var artist = parts[1] || '';
- var album = parts[2] || '';
- var coverUrl = parts[4] || '';
+ var title = (parts[0] || '').trim();
+ var artist = (parts[1] || '').trim();
+ var album = (parts[2] || '').trim();
+ var coverUrl = (parts[4] || '').trim();
// No valid track data β hide overlay
if (title === '' || title === 'SendSpin') {
@@ -117,6 +117,32 @@
}
}
+ // Protect overlay elements from moOde's main.js clearing them
+ function protectOverlayElements() {
+ var ids = ['sendspin-title', 'sendspin-artist', 'sendspin-album', 'sendspin-coverart'];
+ var observer = new MutationObserver(function(mutations) {
+ mutations.forEach(function(m) {
+ if (m.type === 'childList' && m.target.children.length === 0 && overlayshown) {
+ // Element was cleared by moOde JS β reapply last value
+ var el = m.target;
+ if (el.id === 'sendspin-title' && lastTitle) el.textContent = lastTitle;
+ else if (el.id === 'sendspin-artist' && lastArtist) el.textContent = lastArtist;
+ else if (el.id === 'sendspin-album' && lastAlbum) el.textContent = lastAlbum;
+ else if (el.id === 'sendspin-coverart' && lastCoverUrl) {
+ el.innerHTML = '';
+ }
+ }
+ });
+ });
+
+ ids.forEach(function(id) {
+ var el = document.getElementById(id);
+ if (el) {
+ observer.observe(el, {childList: true, subtree: true, characterData: false});
+ }
+ });
+ }
+
// Turn Off button handler
document.addEventListener('click', function(e) {
var target = e.target;
@@ -133,10 +159,15 @@
}
});
- // Start when the DOM is ready (script loads before overlay div exists)
+ // Start when the DOM is ready
+ function init() {
+ protectOverlayElements();
+ startPolling();
+ }
+
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', startPolling);
+ document.addEventListener('DOMContentLoaded', init);
} else {
- startPolling();
+ init();
}
})();
From c5a43cf3ee6c3a7b7169d63def5bfcab9ee6a5ac Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 05:19:08 +0000
Subject: [PATCH 054/274] Fix overlay blocking page: add pointer-events:none to
overlay and content div
---
www/css/main.min.css | 67 ++++++++------------------------------------
1 file changed, 12 insertions(+), 55 deletions(-)
diff --git a/www/css/main.min.css b/www/css/main.min.css
index 30b7031a2..733b319e8 100644
--- a/www/css/main.min.css
+++ b/www/css/main.min.css
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*
* @version 8.1.1
- * @build Fri, Oct 31, 2025 5:46 PM ET
+ * @build Sun, Jun 21, 2026 9:13 AM ET
*
*/
/*! jQuery Countdown styles 1.6.2. */.countdown_rtl{direction:rtl}.countdown_holding span{color:#888}.countdown_row{clear:both;width:100%;padding:0 2px;text-align:center}.countdown_show1 .countdown_section{width:98%}.countdown_show2 .countdown_section{width:48%}.countdown_show3 .countdown_section{width:32.5%}.countdown_show4 .countdown_section{width:24.5%}.countdown_show5 .countdown_section{width:19.5%}.countdown_show6 .countdown_section{width:16.25%}.countdown_show7 .countdown_section{width:14%}.countdown_section{display:block;float:left;font-size:75%;text-align:center}.countdown_amount{font-size:200%}.countdown_descr{display:block;width:100%}
@@ -24,57 +24,14 @@
/*# sourceMappingURL=../maps/css/main.min.css.map */
/* SendSpin Overlay */
-#sendspin-overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 1000;
-}
-.sendspin-backdrop {
- position: absolute;
- width: 100%;
- height: 100%;
- background: rgba(0,0,0,0.85);
- pointer-events: none;
-}
-.sendspin-content {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- text-align: center;
-}
-.sendspin-content * {
- pointer-events: none;
-}
-.sendspin-content .disconnect-sendspin,
-.sendspin-content .btn {
- pointer-events: auto;
-}
-.sendspin-coverart img {
- max-width: 65vmin;
- max-height: 55vmin;
- border-radius: 8px;
- box-shadow: 0 4px 20px rgba(0,0,0,0.5);
-}
-.sendspin-title {
- font-size: 1.5em;
- color: #fff;
- margin-top: 1em;
- font-weight: bold;
-}
-.sendspin-artist {
- font-size: 1.2em;
- color: #ccc;
- margin-top: 0.3em;
-}
-.sendspin-album {
- font-size: 1em;
- color: #999;
- margin-top: 0.2em;
-}
-.disconnect-sendspin {
- margin-top: 1.5em;
-}
+#sendspin-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;display:flex;align-items:center;justify-content:center;pointer-events:none}
+#sendspin-overlay.hide{display:none!important}
+.sendspin-backdrop{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);pointer-events:none}
+.sendspin-content{position:relative;z-index:10001;text-align:center;color:#fff;width:90%;max-width:600px;pointer-events:none}
+.sendspin-content *{pointer-events:none}
+.sendspin-content .disconnect-sendspin,.sendspin-content .btn{pointer-events:auto}
+.sendspin-coverart img{max-width:65vmin;max-height:55vmin;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)}
+.sendspin-title{font-size:2em;font-weight:700;margin-top:20px}
+.sendspin-artist{font-size:1.3em;opacity:.8;margin-top:8px}
+.sendspin-album{font-size:1.1em;opacity:.6;margin-top:4px}
+.disconnect-sendspin{margin-top:24px}
\ No newline at end of file
From fc60560bd038e9d16a1a927a000ee1d4a3ab7785 Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 05:54:50 +0000
Subject: [PATCH 055/274] Fix hook overwriting real metadata: hook checks for
existing data before write, sink always writes during playing state
---
hooks/sendspin-metadata-sink.py | 12 +++++++++---
hooks/sendspin-metadata.sh | 13 ++++++++++++-
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/hooks/sendspin-metadata-sink.py b/hooks/sendspin-metadata-sink.py
index 783336cdd..3c78bc74b 100644
--- a/hooks/sendspin-metadata-sink.py
+++ b/hooks/sendspin-metadata-sink.py
@@ -193,8 +193,8 @@ async def poll_ha_metadata(session):
artwork_url = f"{HA_URL}{artwork_url}"
# Update file if track changed OR if it was cleared externally
- # (spspost.sh clears the file on sendspin stop, but the same track
- # may still be playing when sendspin restarts)
+ # Always write metadata during playing/paused state to recover
+ # from hook script overwrites (sendspin-metadata.sh writes placeholders)
if title != last_title or artist != last_artist or is_meta_file_cleared():
logger.info("Track changed: %s by %s (state=%s)", title, artist, state)
cover_path = download_cover(artwork_url) if state == "playing" else ""
@@ -202,7 +202,13 @@ async def poll_ha_metadata(session):
cleanup_old_covers()
last_title = title
last_artist = artist
- # else: same track, no need to rewrite the file
+ elif state == "playing":
+ # Track same but file may have been overwritten by hook script.
+ # Re-write every poll to ensure real metadata stays in place.
+ cover_path = download_cover(artwork_url) if state == "playing" else ""
+ write_meta_file(title, artist, album, duration, cover_path)
+ last_title = title
+ last_artist = artist
async def ha_poll_loop():
diff --git a/hooks/sendspin-metadata.sh b/hooks/sendspin-metadata.sh
index 24a73957e..7fe0ab603 100755
--- a/hooks/sendspin-metadata.sh
+++ b/hooks/sendspin-metadata.sh
@@ -71,7 +71,18 @@ if [ "${SENDSPIN_EVENT:-}" = "start" ]; then
fi
# Write metadata file (moOde ~~~ format)
- echo -e "${TITLE}~~~${ARTIST}~~~${ALBUM}~~~${DURATION}~~~${COVER_PATH}~~~${CODEC}" > "$META_FILE"
+ # Don't overwrite if the file already has valid track data from the HA sink.
+ # SendSpin hooks only pass connection info, not real track metadata.
+ if [ -f "$META_FILE" ]; then
+ EXISTING_CONTENT=$(cat "$META_FILE" 2>/dev/null || echo "")
+ if echo "$EXISTING_CONTENT" | grep -q "~~~SendSpin~~~Stopped~~~" || [ -z "$EXISTING_CONTENT" ]; then
+ echo -e "${TITLE}~~~${ARTIST}~~~${ALBUM}~~~${DURATION}~~~${COVER_PATH}~~~${CODEC}" > "$META_FILE"
+ else
+ log "Skipping hook write - file has real metadata from HA sink"
+ fi
+ else
+ echo -e "${TITLE}~~~${ARTIST}~~~${ALBUM}~~~${DURATION}~~~${COVER_PATH}~~~${CODEC}" > "$META_FILE"
+ fi
# Set permissions so web server can read
chmod 644 "$META_FILE" 2>/dev/null || true
From 67be915c76b86c9faab2edb08a4b06748c6ad9fc Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 06:16:52 +0000
Subject: [PATCH 056/274] Use moOde built-in inpsrc-indicator instead of custom
overlay - minimal code approach
---
www/css/main.min.css | 12 ---
www/footer.php | 13 +--
www/js/sendspin-display.js | 216 ++++++++++++++++---------------------
3 files changed, 92 insertions(+), 149 deletions(-)
diff --git a/www/css/main.min.css b/www/css/main.min.css
index 733b319e8..cefa0840c 100644
--- a/www/css/main.min.css
+++ b/www/css/main.min.css
@@ -23,15 +23,3 @@
/*# sourceMappingURL=../maps/css/main.min.css.map */
-/* SendSpin Overlay */
-#sendspin-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;display:flex;align-items:center;justify-content:center;pointer-events:none}
-#sendspin-overlay.hide{display:none!important}
-.sendspin-backdrop{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);pointer-events:none}
-.sendspin-content{position:relative;z-index:10001;text-align:center;color:#fff;width:90%;max-width:600px;pointer-events:none}
-.sendspin-content *{pointer-events:none}
-.sendspin-content .disconnect-sendspin,.sendspin-content .btn{pointer-events:auto}
-.sendspin-coverart img{max-width:65vmin;max-height:55vmin;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)}
-.sendspin-title{font-size:2em;font-weight:700;margin-top:20px}
-.sendspin-artist{font-size:1.3em;opacity:.8;margin-top:8px}
-.sendspin-album{font-size:1.1em;opacity:.6;margin-top:4px}
-.disconnect-sendspin{margin-top:24px}
\ No newline at end of file
diff --git a/www/footer.php b/www/footer.php
index d06c0b4c2..d23ab64a0 100644
--- a/www/footer.php
+++ b/www/footer.php
@@ -165,18 +165,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
SendSpin Active' +
+ '';
+
+ // Cover art image
+ if (cover) {
+ if (coverUrl) {
+ cover.innerHTML = '';
+ } else {
+ cover.innerHTML = '';
+ }
+ }
+
+ // Backdrop (blurred background)
+ if (backdrop) {
+ if (coverUrl) {
+ backdrop.innerHTML = '';
+ } else {
+ backdrop.innerHTML = '';
+ }
}
+
+ // Metadata text: Artist - Title / Album
+ if (metadata) {
+ if (artist && title) {
+ metadata.innerHTML = '' + artist + ' - ' + title + ' ' + (album || '') + '';
+ } else {
+ metadata.innerHTML = '';
+ }
+ metadata.style.display = '';
+ }
+
+ // Style indicator (shows the backdrop color overlay)
+ var styleEl = document.getElementById('inpsrc-style');
+ if (styleEl) styleEl.style.display = 'block';
+
+ // Show the indicator
+ indicator.classList.remove('hide');
+ indicator.style.display = 'block';
}
- function hideOverlay() {
- if (!overlayshown) return;
- var overlay = document.getElementById('sendspin-overlay');
- if (overlay) {
- overlay.classList.add('hide');
- overlayshown = false;
+ function hideIndicator() {
+ var indicator = document.getElementById('inpsrc-indicator');
+ var msg = document.getElementById('inpsrc-msg');
+ var metadata = document.getElementById('inpsrc-metadata');
+ var cover = document.getElementById('inpsrc-cover');
+ var backdrop = document.getElementById('inpsrc-backdrop');
+
+ if (indicator) {
+ indicator.style.display = '';
+ indicator.classList.add('hide');
+ }
+ if (msg) {
+ msg.innerHTML = '';
+ msg.classList.remove('inpsrc-msg-metadata');
+ msg.classList.add('inpsrc-msg-default');
+ }
+ if (metadata) {
+ metadata.innerHTML = '';
+ metadata.style.display = 'none';
}
+ if (cover) cover.innerHTML = '';
+ if (backdrop) backdrop.innerHTML = '';
}
- function updateMetadata() {
+ function fetchMetadata() {
fetch('command/renderer.php?cmd=get_sendspinmeta')
.then(function(r) { return r.text(); })
.then(function(data) {
if (!data || data === '') {
- hideOverlay();
+ hideIndicator();
return;
}
-
var parts = data.split('~~~');
var title = (parts[0] || '').trim();
var artist = (parts[1] || '').trim();
var album = (parts[2] || '').trim();
var coverUrl = (parts[4] || '').trim();
- // No valid track data β hide overlay
if (title === '' || title === 'SendSpin') {
- hideOverlay();
+ hideIndicator();
return;
}
- // Show overlay (guarded β no-op if already shown)
- showOverlay();
-
- // Only update DOM elements when content actually changes
- if (title !== lastTitle) {
- var titleEl = document.getElementById('sendspin-title');
- if (titleEl) titleEl.textContent = title;
- lastTitle = title;
- }
-
- if (artist !== lastArtist) {
- var artistEl = document.getElementById('sendspin-artist');
- if (artistEl) artistEl.textContent = artist;
- lastArtist = artist;
- }
-
- if (album !== lastAlbum) {
- var albumEl = document.getElementById('sendspin-album');
- if (albumEl) albumEl.textContent = album;
- lastAlbum = album;
- }
-
- if (coverUrl !== lastCoverUrl) {
- var coverEl = document.getElementById('sendspin-coverart');
- if (coverEl) {
- if (coverUrl) {
- coverEl.innerHTML = '';
- } else {
- coverEl.innerHTML = '';
- }
- }
- lastCoverUrl = coverUrl;
- }
+ showIndicator(title, artist, album, coverUrl);
})
- .catch(function() {
- // Fetch or parse failed silently β keep current overlay state
- });
- }
-
- function startPolling() {
- if (pollTimer) clearInterval(pollTimer);
- updateMetadata();
- pollTimer = setInterval(updateMetadata, 2000);
- }
-
- function stopPolling() {
- if (pollTimer) {
- clearInterval(pollTimer);
- pollTimer = null;
- }
- }
-
- // Protect overlay elements from moOde's main.js clearing them
- function protectOverlayElements() {
- var ids = ['sendspin-title', 'sendspin-artist', 'sendspin-album', 'sendspin-coverart'];
- var observer = new MutationObserver(function(mutations) {
- mutations.forEach(function(m) {
- if (m.type === 'childList' && m.target.children.length === 0 && overlayshown) {
- // Element was cleared by moOde JS β reapply last value
- var el = m.target;
- if (el.id === 'sendspin-title' && lastTitle) el.textContent = lastTitle;
- else if (el.id === 'sendspin-artist' && lastArtist) el.textContent = lastArtist;
- else if (el.id === 'sendspin-album' && lastAlbum) el.textContent = lastAlbum;
- else if (el.id === 'sendspin-coverart' && lastCoverUrl) {
- el.innerHTML = '';
- }
- }
- });
- });
-
- ids.forEach(function(id) {
- var el = document.getElementById(id);
- if (el) {
- observer.observe(el, {childList: true, subtree: true, characterData: false});
- }
- });
+ .catch(function() {});
}
- // Turn Off button handler
+ // Turn Off button
document.addEventListener('click', function(e) {
- var target = e.target;
- if (target && (target.classList.contains('disconnect-sendspin') ||
- target.closest('.disconnect-sendspin'))) {
+ var btn = (e.target.closest && e.target.closest('[data-job="sendspinsvc"]'));
+ if (btn || (e.target.classList && e.target.classList.contains('turnoff-renderer') && e.target.getAttribute('data-job') === 'sendspinsvc')) {
e.preventDefault();
- hideOverlay();
- stopPolling();
+ hideIndicator();
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
fetch('command/renderer.php?cmd=disconnect_renderer', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -159,15 +126,14 @@
}
});
- // Start when the DOM is ready
- function init() {
- protectOverlayElements();
- startPolling();
+ function start() {
+ fetchMetadata();
+ pollTimer = setInterval(fetchMetadata, 3000);
}
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', init);
+ document.addEventListener('DOMContentLoaded', start);
} else {
- init();
+ start();
}
})();
From be14f5a443c22ea058f420f77bf6754387f7912a Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 06:24:33 +0000
Subject: [PATCH 057/274] Remove hook-start/stop from service - HA sink handles
all metadata independently. Simplify metadata.sh and spspost.sh to stubs.
---
etc/systemd/system/sendspin.service | 4 +-
hooks/sendspin-metadata.sh | 111 ++--------------------------
hooks/spspost.sh | 37 ++--------
moode-sendspin-r2-installer.sh | 5 +-
4 files changed, 14 insertions(+), 143 deletions(-)
diff --git a/etc/systemd/system/sendspin.service b/etc/systemd/system/sendspin.service
index 729f0dc50..855c517b5 100644
--- a/etc/systemd/system/sendspin.service
+++ b/etc/systemd/system/sendspin.service
@@ -9,9 +9,7 @@ ExecStartPre=/var/local/www/commandw/sendspin-spspre.sh
ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin daemon --audio-device sendspin --audio-format flac:48000:16:2 --name moode-sendspin \
--hardware-volume false \
--static-delay-ms 0 \
- --log-level INFO \
- --hook-start /var/local/www/commandw/sendspin-metadata.sh \
- --hook-stop /var/local/www/commandw/sendspin-metadata.sh
+ --log-level INFO
ExecStopPost=/var/local/www/commandw/spspost.sh
Restart=on-failure
RestartSec=5
diff --git a/hooks/sendspin-metadata.sh b/hooks/sendspin-metadata.sh
index 7fe0ab603..a6629ffb4 100755
--- a/hooks/sendspin-metadata.sh
+++ b/hooks/sendspin-metadata.sh
@@ -1,110 +1,11 @@
#!/bin/bash
# =============================================================================
-# SendSpin Metadata Capture Hook
+# SendSpin Metadata Capture Hook (stub)
# =============================================================================
-# Called by sendspin daemon via --hook-start and --hook-stop flags.
-# Receives metadata via SENDSPIN_* environment variables and writes to
-# moOde's metadata file format (~~~ delimited).
-#
-# Usage in sendspin.service:
-# --hook-start /var/local/www/commandw/sendspin-metadata.sh
-# --hook-stop /var/local/www/commandw/sendspin-metadata.sh
-#
-# moOde metadata format: Title~~~Artist~~~Album~~~Duration~~~CoverPath~~~Codec
+# Formerly wrote placeholder metadata via --hook-start/--hook-stop.
+# The HA metadata-sink (sendspin-metadata-sink.py) handles all metadata
+# via direct HA API polling -- it is more reliable and includes cover art.
+# This stub remains installed for backward compatibility.
# =============================================================================
-
-set -euo pipefail
-
-META_FILE="/var/local/www/sendspinmeta.txt"
-COVER_DIR="/var/local/www/imagesw/sendspin-covers"
-
-# Ensure directories exist
-mkdir -p "$COVER_DIR"
-mkdir -p "$(dirname "$META_FILE")"
-
-# Log function for debugging
-log() {
- logger -t sendspin-metadata "$1" 2>/dev/null || true
-}
-
-# Sanitise field - remove ~~~ delimiters and control characters
-sanitise() {
- echo -n "$1" | tr -d '\000-\010\013\014\016-\037' | sed 's/~~~/ /g'
-}
-
-# SendSpin passes SENDSPIN_EVENT (start/stop), not SENDSPIN_STATE
-if [ "${SENDSPIN_EVENT:-}" = "start" ]; then
- # --- Stream started: capture metadata ---
-
- TITLE=$(sanitise "${SENDSPIN_TITLE:-Streaming}")
- ARTIST=$(sanitise "${SENDSPIN_ARTIST:-SendSpin}")
- ALBUM=$(sanitise "${SENDSPIN_ALBUM:-}")
- DURATION=$(sanitise "${SENDSPIN_DURATION:-0}")
- CODEC=$(sanitise "${SENDSPIN_CODEC:-SendSpin}")
- SERVER=$(sanitise "${SENDSPIN_SERVER_NAME:-}")
-
- # Use server name as artist if no metadata available
- # SendSpin 7.5.0 hooks only pass connection info, not track metadata
- if [ "$TITLE" = "Streaming" ] && [ -n "$SERVER" ]; then
- ARTIST="via $SERVER"
- fi
-
- # Download cover art if URL provided
- COVER_PATH=""
- if [ -n "${SENDSPIN_COVER_URL:-}" ]; then
- # Generate filename from URL hash to avoid re-downloading
- COVER_HASH=$(echo -n "${SENDSPIN_COVER_URL}" | md5sum | cut -d' ' -f1)
- COVER_FILE="${COVER_DIR}/cover-${COVER_HASH}.jpg"
-
- if [ ! -f "$COVER_FILE" ]; then
- # Download with 5-second timeout, max 2MB
- curl -sfL --max-time 5 --max-filesize 2097152 \
- -o "$COVER_FILE" "${SENDSPIN_COVER_URL}" 2>/dev/null || {
- log "Failed to download cover art from ${SENDSPIN_COVER_URL}"
- rm -f "$COVER_FILE"
- }
- fi
-
- if [ -f "$COVER_FILE" ]; then
- COVER_PATH="imagesw/sendspin-covers/$(basename "$COVER_FILE")"
- fi
- fi
-
- # Write metadata file (moOde ~~~ format)
- # Don't overwrite if the file already has valid track data from the HA sink.
- # SendSpin hooks only pass connection info, not real track metadata.
- if [ -f "$META_FILE" ]; then
- EXISTING_CONTENT=$(cat "$META_FILE" 2>/dev/null || echo "")
- if echo "$EXISTING_CONTENT" | grep -q "~~~SendSpin~~~Stopped~~~" || [ -z "$EXISTING_CONTENT" ]; then
- echo -e "${TITLE}~~~${ARTIST}~~~${ALBUM}~~~${DURATION}~~~${COVER_PATH}~~~${CODEC}" > "$META_FILE"
- else
- log "Skipping hook write - file has real metadata from HA sink"
- fi
- else
- echo -e "${TITLE}~~~${ARTIST}~~~${ALBUM}~~~${DURATION}~~~${COVER_PATH}~~~${CODEC}" > "$META_FILE"
- fi
-
- # Set permissions so web server can read
- chmod 644 "$META_FILE" 2>/dev/null || true
- chown www-data:www-data "$META_FILE" 2>/dev/null || true
-
- log "Stream started: ${TITLE} by ${ARTIST} (event=start)"
-
-elif [ "${SENDSPIN_EVENT:-}" = "stop" ] || [ -z "${SENDSPIN_EVENT:-}" ]; then
- # --- Stream stopped: clear metadata ---
-
- echo -e "~~~SendSpin~~~Stopped~~~0~~~~~~" > "$META_FILE"
- chmod 644 "$META_FILE" 2>/dev/null || true
- chown www-data:www-data "$META_FILE" 2>/dev/null || true
-
- log "Stream stopped (event=stop)"
-
-else
- log "Unknown SENDSPIN_EVENT: ${SENDSPIN_EVENT:-empty}"
-fi
-
-# Clean up old cover art (keep last 50 files)
-find "$COVER_DIR" -name "cover-*.jpg" -type f -printf '%T@ %p\n' 2>/dev/null | \
- sort -rn | tail -n +51 | cut -d' ' -f2- | xargs rm -f 2>/dev/null || true
-
+logger -t sendspin-metadata "Hook called (SENDSPIN_EVENT=${SENDSPIN_EVENT:-none}) - metadata handled by HA sink"
exit 0
diff --git a/hooks/spspost.sh b/hooks/spspost.sh
index 49db12cbe..8ff66be8d 100755
--- a/hooks/spspost.sh
+++ b/hooks/spspost.sh
@@ -1,36 +1,11 @@
#!/bin/bash
# =============================================================================
-# SendSpin Post-Play Hook (spspost.sh)
+# SendSpin Post-Stop Hook (spspost.sh)
# =============================================================================
-# Called when SendSpin stops audio playback.
-# Cleans up state and optionally returns ALSA device to MPD.
+# Called after sendspin.service stops.
+# Metadata cleanup is handled by the HA metadata-sink daemon
+# (sendspin-metadata-sink.py) which detects stream state independently.
+# MPD resume is handled by inc/renderer.php stopSendspin().
# =============================================================================
-
-set -euo pipefail
-
-STATE_FILE="/var/local/www/sendspin_dsp_state.txt"
-META_FILE="/var/local/www/sendspinmeta.txt"
-
-log() {
- logger -t sendspin-spspost "$1" 2>/dev/null || true
-}
-
-# Clear metadata
-if [ -f "$META_FILE" ]; then
- echo -e "~~~SendSpin~~~Stopped~~~0~~~~~~" > "$META_FILE"
- chmod 644 "$META_FILE" 2>/dev/null || true
-fi
-
-# Log previous DSP state for debugging
-if [ -f "$STATE_FILE" ]; then
- PREV_STATE=$(cat "$STATE_FILE")
- log "Post-play cleanup (was using: $PREV_STATE)"
-else
- log "Post-play cleanup (no previous state found)"
-fi
-
-# Note: MPD resume is handled by worker.php stopSendspin() function
-# which checks $_SESSION['mpd_was_playing'] before resuming.
-# This hook only handles ALSA-level cleanup.
-
+logger -t sendspin-spspost "SendSpin stopped (cleanup handled by HA sink)"
exit 0
diff --git a/moode-sendspin-r2-installer.sh b/moode-sendspin-r2-installer.sh
index a14489c34..067b214b0 100644
--- a/moode-sendspin-r2-installer.sh
+++ b/moode-sendspin-r2-installer.sh
@@ -159,10 +159,7 @@ Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/var/local/www/commandw/spspre.sh
-ExecStart=${CURRENT_EXEC#ExecStart=} \\
- --hook-start /var/local/www/commandw/sendspin-metadata.sh \\
- --hook-stop /var/local/www/commandw/sendspin-metadata.sh \\
- --hook-set-volume /var/local/www/commandw/sendspin-volume-sync.sh
+ExecStart=${CURRENT_EXEC#ExecStart=}
ExecStopPost=/var/local/www/commandw/spspost.sh
Restart=on-failure
RestartSec=5
From f95b2b2e0dc4cdf97d0b89e08f4d8046423102ed Mon Sep 17 00:00:00 2001
From: Paul Robertson
Date: Sun, 28 Jun 2026 06:41:18 +0000
Subject: [PATCH 058/274] Add sendspin-display.js script tag to footer.php
before PHP block
---
www/footer.php | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/www/footer.php b/www/footer.php
index d23ab64a0..4704248f4 100644
--- a/www/footer.php
+++ b/www/footer.php
@@ -165,11 +165,10 @@
-
-
+