diff --git a/PRIMER.md b/PRIMER.md index c9b4383..012e963 100644 --- a/PRIMER.md +++ b/PRIMER.md @@ -263,6 +263,7 @@ Same as the other ShowPilot repos: | 0.14.20 | **The once-a-minute mode check no longer uses up a request (ShowPilot GitHub #37).** The listener refreshes `$cachedMode` with `ofGetState()` every 60 s and only reads `mode`, but ShowPilot handed out the next jukebox request on every `/state` call, so a request handed to that call was marked as with FPP while the plugin never queued it, and FPP "skipped" it (seen in a show owner's logs: ShowPilot logged the handover 27 s before the plugin's normal near-end poll, which got the 2nd and 3rd requests; the plugin log had no line for the 1st). `ofGetState($peek)` now takes a flag and the mode check calls `/api/plugin/state?peek=1` (ShowPilot v0.33.249+ answers with the mode only; older servers ignore the parameter). ShowPilot beta.11 also keeps one request in flight and repeats it until it plays, which protects older plugins. | PHP read carefully (no `php -l`); the server side is covered by ShowPilot's scenario "The plugin's mode check can't use up a request", which reproduces the exact order from the logs on the old server code. | | 0.14.21 | **Songs played twice after a queue (ShowPilot GitHub #37).** From the show owner's logs: with several requests the plugin wrote them to the "ShowPilot Queue" playlist and inserted range 1..N after the current song. A later request while FPP was already playing from that playlist inserted it into itself; FPP nests inserts (`InsertPlaylistAsNext` goes to the innermost playlist) and resumes the outer one at its next position when the inner ends, so a song from the earlier range played again (Houdini after "My Songs Know…"). After a listener restart (pending list lost) the next request was inserted singly while FPP was still inside a Queue range, so Abracadabra played, then the range resumed and played it again. Fix: the mode check (peek) tells whether ShowPilot hands out one request at a time (`peek: true` in the reply, ShowPilot v0.33.249+). Then each Jukebox request (including a PSA in that slot) is inserted on its own from the Remote Playlist (`insertPlaylistAfterCurrent`, or `insertPlaylistImmediate` in interrupt mode when not already playing from the Remote Playlist), and `$pendingQueue` holds just that one to ignore ShowPilot's repeats; the Queue playlist isn't used. Older servers keep the old path. "Jukebox: next request …" lines are now verbose-only (ShowPilot repeats the request every poll). | PHP read carefully (no `php -l`); FPP's insert nesting checked in `Playlist.cpp` (`InsertPlaylistAsNext`, `CurrentIfNotThis`, `SwitchToInsertedPlaylist`); both replays matched line by line against the plugin and ShowPilot logs. | | 0.14.22 | **Update notice in FPP.** ShowPilot (v0.33.249-beta.18+) answers the heartbeat with the newest plugin version (`pluginLatest`) and the oldest it works properly with (`pluginRequired`). `sp_update_notice()` in the listener writes `/showpilot-update.json` (`{message, installed, latest, required}`, 0644, atomic rename) when this plugin is older, and removes it when up to date or when the server doesn't say. The fppd part (`FPPShowPilotSync.cpp`) checks that file once a minute on its own thread and shows the message as an FPP warning (`WarningHolder::AddWarning(0, msg, "plugins.php", "Plugin Manager")`, the banner on every FPP page, with a button to the Plugin Manager); the message is reduced to plain text before it's shown. The plugin's own page shows the same notice. Versions are only accepted as `X.Y.Z`. Uninstall removes the file. Only plugins from 0.14.22 on can show it; older ones are covered by the banner on ShowPilot's Dashboard. Checked: `php -l` on the changed PHP, the notice logic run against sample answers (newer, too old, up to date, malformed, old server), and `g++ -fsyntax-only` of the C++ against FPP 10.0's and current FPP's headers. | +| 0.14.23 | **Audio daemon now starts when Restart Listener is pressed.** `start_audio_daemon` only starts the daemon once a Server URL is configured, and only `postStart.sh` (fppd start) called it. On an FPP that started before the URL was entered (a fresh install, or a URL entered later), saving the URL and pressing Restart Listener brought back the listener but never the daemon, so ShowPilot had no live song position for phone audio until the next fppd restart; the log's last daemon line was "Audio daemon not started: no Server URL configured yet". `commands/restart_listener.php` now also runs the new `scripts/ensure-daemon.sh` in the background, which starts the daemon only if it isn't running (an already-running daemon is never restarted, so listeners in sync aren't interrupted). Found on a test FPP. Checked: `php -l`, `bash -n`, and the script's not-running / already-running paths with a stub environment. **Also: Server → Connection** — *Self-hosted ShowPilot* (default; behaviour unchanged) or *Hosted ShowPilot* (`connectionMode` = `self`|`hosted`, allow-listed in `showpilot_config.php`). A hosted ShowPilot isn't on the FPP's network and can't connect to the daemon, so in Hosted mode the daemon ("Outbound link" in `showpilot_audio.js`) opens one WebSocket to `/api/plugin/link` with the show token and treats it as one more client (same position / syncPoint / heartbeat messages; the server pings it for the round trip). It re-reads the settings file every 30 s (local only), so switching needs no fppd restart; switching back to Self-hosted or changing URL/token closes the link; 401/403/404 wait 5 minutes. In Self-hosted mode nothing opens a connection. The daemon gets `SP_CONFIG_FILE` / `SP_TOKEN_FILE` from `showpilot_env.sh`. Needs a ShowPilot server with the link endpoint; others answer 404 and the plugin backs off. Tested against a stand-in server: Self-hosted never connected; Hosted connected with the bearer token and relayed messages; switching back closed it within 30 s. | --- diff --git a/commands/restart_listener.php b/commands/restart_listener.php index 877d051..415bea4 100755 --- a/commands/restart_listener.php +++ b/commands/restart_listener.php @@ -33,5 +33,12 @@ @shell_exec('/usr/bin/setsid ' . $dropRoot . '/usr/bin/php ' . escapeshellarg($listenerPath) . ' >' . escapeshellarg(sp_log_file()) . ' 2>&1 &'); +// Also start the audio daemon if it isn't running. It only starts once a +// Server URL is set, and normally only when fppd starts, so an FPP that +// started before the URL was entered had no daemon until the next fppd +// restart. ensure-daemon.sh never touches a daemon that is already running. +@shell_exec('/usr/bin/setsid /bin/bash ' . escapeshellarg(dirname(__DIR__) . '/scripts/ensure-daemon.sh') + . ' /dev/null 2>&1 &'); + // Brief pause so the UI's immediate status re-poll already sees it running. usleep(200000); diff --git a/scripts/ensure-daemon.sh b/scripts/ensure-daemon.sh new file mode 100755 index 0000000..2ac76f1 --- /dev/null +++ b/scripts/ensure-daemon.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# ShowPilot — start the audio daemon if it should be running but isn't. +# +# The daemon only starts once a Server URL is set (see start_audio_daemon), +# and postStart.sh only runs when fppd starts. So on an FPP that started +# before the URL was entered, nothing ever started it. Restart Listener runs +# this. It never restarts a daemon that is already running, so phones that +# are already in sync aren't interrupted. + +. "$(dirname "${BASH_SOURCE[0]}")/showpilot_env.sh" + +if pgrep -f "$AUDIO_DAEMON" >/dev/null 2>&1; then + exit 0 +fi +ensure_runtime_files +start_audio_daemon diff --git a/scripts/showpilot_env.sh b/scripts/showpilot_env.sh index 583cd20..4550896 100644 --- a/scripts/showpilot_env.sh +++ b/scripts/showpilot_env.sh @@ -130,6 +130,7 @@ start_audio_daemon() { case "$port" in ''|*[!0-9]*) port=8090 ;; esac PORT="$port" MEDIA_ROOT="${MEDIADIR}/music" FPP_HOST="http://127.0.0.1" LOG_FILE="$PLUGIN_LOG" \ + SP_CONFIG_FILE="$CONFIG_FILE" SP_TOKEN_FILE="${DATA_DIR}/showToken" \ setsid "${run_as_fpp[@]}" "$(command -v node)" --max-old-space-size=64 "$AUDIO_DAEMON" \ >"$PLUGIN_LOG" 2>&1 9<&- & } diff --git a/showpilot_audio.js b/showpilot_audio.js index b020c68..c0a5fed 100644 --- a/showpilot_audio.js +++ b/showpilot_audio.js @@ -18,6 +18,8 @@ // MEDIA_ROOT — FPP music dir (default: /home/fpp/media/music) // FPP_HOST — FPP API base URL (default: http://127.0.0.1) // LOG_FILE — the plugin's log file (default: stderr) +// SP_CONFIG_FILE / SP_TOKEN_FILE — plugin settings + show token, for the +// outbound link used with a hosted ShowPilot (see "Outbound link") // ============================================================ 'use strict'; @@ -406,6 +408,102 @@ if (WebSocketServer) { }, 30000); } +// ---- Outbound link (Connection: Hosted ShowPilot) ---- +// A hosted ShowPilot server isn't on this network, so it can't connect to +// this daemon the way a self-hosted one does. Instead this daemon connects +// OUT to /api/plugin/link with the show token and is treated as +// one more WebSocket client: it gets exactly the messages local clients get +// (position, syncPoint, heartbeat), and answers the server's pings so the +// server can measure the round trip and correct timestamps for it. +// Only runs when the plugin's Connection setting is "Hosted ShowPilot" +// (connectionMode = hosted). With the default, Self-hosted, nothing here ever +// opens a connection: the settings file is re-read every 30 s (local only) +// so switching the setting takes effect without restarting fppd. Server URL +// and token are re-read on every attempt for the same reason. +const LINK_CONFIG_FILE = process.env.SP_CONFIG_FILE || null; +const LINK_TOKEN_FILE = process.env.SP_TOKEN_FILE || null; +const LINK_PATH = '/api/plugin/link'; +let LinkWebSocket = null; +for (const mod of [path.join(__dirname, 'vendor', 'ws'), 'ws']) { + try { LinkWebSocket = require(mod).WebSocket; if (LinkWebSocket) break; } catch (_) {} +} + +function readLinkTarget() { + try { + const cfg = fs.readFileSync(LINK_CONFIG_FILE, 'utf8'); + const mode = cfg.match(/^\s*connectionMode\s*=\s*"?([^"\r\n]*)"?\s*$/m); + if (!mode || mode[1].trim() !== 'hosted') return null; + const m = cfg.match(/^\s*serverUrl\s*=\s*"?([^"\r\n]*)"?\s*$/m); + if (!m) return null; + let url = m[1].trim(); + try { url = decodeURIComponent(url); } catch (_) {} + url = url.replace(/\/+$/, ''); + if (!/^https?:\/\/[^\s/]+/i.test(url)) return null; + const token = fs.readFileSync(LINK_TOKEN_FILE, 'utf8').trim(); + return token ? { url, token } : null; + } catch (_) { + return null; + } +} + +let linkTimer = null; +let linkDelayMs = 2000; +function scheduleLink(ms) { + if (linkTimer) return; + linkTimer = setTimeout(() => { linkTimer = null; connectLink(); }, ms); +} + +function connectLink() { + const target = readLinkTarget(); + if (!target) return scheduleLink(30000); // Self-hosted, or not configured yet + const wsUrl = target.url.replace(/^http/i, 'ws') + LINK_PATH; + let sock; + try { + sock = new LinkWebSocket(wsUrl, { headers: { Authorization: `Bearer ${target.token}` }, handshakeTimeout: 10000 }); + } catch (e) { + log(`Outbound link: ${e.message}`); + return scheduleLink(30000); + } + let opened = false; + sock.on('open', () => { + opened = true; + linkDelayMs = 2000; + wsClients.add(sock); + log(`Outbound link connected to ${target.url}`); + try { sock.send(JSON.stringify(positionMessage('position'))); } catch (_) {} + // Drop the link if the setting goes back to Self-hosted or the server changes. + const watch = setInterval(() => { + const now = readLinkTarget(); + if (!now || now.url !== target.url || now.token !== target.token) { + clearInterval(watch); + log('Outbound link settings changed — closing the link'); + try { sock.close(); } catch (_) {} + } + }, 30000); + sock.on('close', () => clearInterval(watch)); + }); + sock.on('unexpected-response', (req, res) => { + const code = res.statusCode; + // 401: wrong token. 403: audio is off for this show. 404: the server has + // no link endpoint (a self-hosted ShowPilot, which doesn't need one). + // None of these fix themselves quickly, so wait 5 minutes. + log(`Outbound link refused by server (HTTP ${code})${code === 404 ? ' — this server has no link endpoint; set Connection to Self-hosted if it is your own ShowPilot' : ''}`); + try { req.destroy(); } catch (_) {} + scheduleLink([401, 403, 404].includes(code) ? 5 * 60 * 1000 : linkDelayMs); + }); + sock.on('error', () => {}); + sock.on('close', () => { + wsClients.delete(sock); + if (opened) log('Outbound link closed'); + const wait = opened ? 1000 : linkDelayMs; + if (!opened) linkDelayMs = Math.min(linkDelayMs * 2, 60000); + scheduleLink(wait); + }); +} + +if (LinkWebSocket && LINK_CONFIG_FILE && LINK_TOKEN_FILE) connectLink(); +// No log otherwise: Self-hosted installs never use it. + // App-level heartbeat every 5s, so clients notice a stalled daemon even // while FPP is idle and no positions are flowing. setInterval(() => { diff --git a/showpilot_config.php b/showpilot_config.php index 5edbecb..a31f431 100644 --- a/showpilot_config.php +++ b/showpilot_config.php @@ -13,7 +13,7 @@ 'serverUrl', 'showToken', 'remotePlaylist', 'interruptSchedule', 'requestFetchTime', 'additionalWaitTime', 'fppStatusCheckTime', 'heartbeatIntervalSec', 'verboseLogging', 'listenerEnabled', - 'listenerRestarting', 'audioDaemonPort', + 'listenerRestarting', 'audioDaemonPort', 'connectionMode', ); function respondJson($code, $payload) { @@ -27,6 +27,8 @@ function validSetting($key, $value) { switch ($key) { case 'serverUrl': return $value === '' || sp_is_valid_server_url(rtrim($value, '/')); + case 'connectionMode': + return $value === 'self' || $value === 'hosted'; case 'audioDaemonPort': return ctype_digit($value) && (int)$value >= 1024 && (int)$value <= 65535; case 'remotePlaylist': diff --git a/showpilot_ui.html b/showpilot_ui.html index c5fa72e..fd30c3d 100644 --- a/showpilot_ui.html +++ b/showpilot_ui.html @@ -101,6 +101,17 @@

ShowPilot Blackbox v + + Self-hosted (most shows): your own ShowPilot server, which connects + to this FPP for live song position. Hosted: a ShowPilot run for you somewhere else, which can\'t + reach this FPP, so the plugin keeps one outbound connection open to it instead.'); ?> + + + http://192.168.1.230:3100 or https://lights.yourdomain.com. No trailing slash. Use https:// for any server diff --git a/version.php b/version.php index 76858c1..d29413e 100644 --- a/version.php +++ b/version.php @@ -13,4 +13,4 @@ // "0.10.0" in other files and update by hand — by design, no other file // has it. // ============================================================ -$PLUGIN_VERSION = "0.14.22"; +$PLUGIN_VERSION = "0.14.23";