Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PRIMER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<config>/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 `<Server URL>/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. |

---

Expand Down
7 changes: 7 additions & 0 deletions commands/restart_listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,12 @@
@shell_exec('/usr/bin/setsid ' . $dropRoot . '/usr/bin/php ' . escapeshellarg($listenerPath)
. ' </dev/null >>' . 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 >/dev/null 2>&1 &');

// Brief pause so the UI's immediate status re-poll already sees it running.
usleep(200000);
16 changes: 16 additions & 0 deletions scripts/ensure-daemon.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions scripts/showpilot_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
</dev/null >>"$PLUGIN_LOG" 2>&1 9<&- &
}
Expand Down
98 changes: 98 additions & 0 deletions showpilot_audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <Server URL>/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(() => {
Expand Down
4 changes: 3 additions & 1 deletion showpilot_config.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
'serverUrl', 'showToken', 'remotePlaylist', 'interruptSchedule',
'requestFetchTime', 'additionalWaitTime', 'fppStatusCheckTime',
'heartbeatIntervalSec', 'verboseLogging', 'listenerEnabled',
'listenerRestarting', 'audioDaemonPort',
'listenerRestarting', 'audioDaemonPort', 'connectionMode',
);

function respondJson($code, $payload) {
Expand All @@ -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':
Expand Down
11 changes: 11 additions & 0 deletions showpilot_ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ <h2 class="mb-0">ShowPilot Blackbox <small class="fs-6 text-body-secondary">v<?p
</div>
</div>

<?php $connMode = val($pluginSettings, 'connectionMode') === 'hosted' ? 'hosted' : 'self'; ?>
<?php spRowStart('Connection', 'connectionMode',
'Where your ShowPilot runs. <strong>Self-hosted</strong> (most shows): your own ShowPilot server, which connects
to this FPP for live song position. <strong>Hosted</strong>: a ShowPilot run for you somewhere else, which can\'t
reach this FPP, so the plugin keeps one outbound connection open to it instead.'); ?>
<select class="form-select" id="connectionMode" <?php echo spInputAttrs($pluginName, 'connectionMode', 'this.value'); ?>>
<option value="self" <?php echo $connMode === 'self' ? 'selected' : ''; ?>>Self-hosted ShowPilot</option>
<option value="hosted" <?php echo $connMode === 'hosted' ? 'selected' : ''; ?>>Hosted ShowPilot</option>
</select>
<?php spRowEnd(); ?>

<?php spRowStart('Server URL', 'serverUrl',
'Where your self-hosted ShowPilot server is reachable, e.g. <code>http://192.168.1.230:3100</code> or
<code>https://lights.yourdomain.com</code>. No trailing slash. Use <code>https://</code> for any server
Expand Down
2 changes: 1 addition & 1 deletion version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";