mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Merge: BrightSign native sync, wired end to end and chosen per group
This commit is contained in:
commit
90553852bf
|
|
@ -147,6 +147,30 @@ because the BrightSigns would look perfectly synchronised while the odd panel dr
|
|||
|
||||
A player paired before this port is still recognised, by its BrightSign user agent.
|
||||
|
||||
### How the choice reaches a screen
|
||||
|
||||
`device_groups.sync_backend` (`auto` | `screentinker` | `brightsign`) is the operator's **request**.
|
||||
The server resolves it per push through `resolveSyncBackend()` and sends the answer — plus the
|
||||
reason and a `downgraded` flag — in the `group_sync` payload, so the players, the dashboard and the
|
||||
stored setting can never disagree about which protocol is running.
|
||||
|
||||
Three things force a fallback to our protocol, and each is reported rather than applied silently:
|
||||
|
||||
| condition | why native sync cannot run |
|
||||
|---|---|
|
||||
| any non-BrightSign member | BrightWall cannot include a foreign screen |
|
||||
| members on different subnets | it is multicast; it does not cross networks |
|
||||
| the elected leader is offline | it is leader/follower — nobody would broadcast |
|
||||
|
||||
That last one has no equivalent in our protocol, which is leaderless and carries on regardless.
|
||||
Leadership uses the existing election (`resolveGroupLeader`): the pinned leader if it is an online
|
||||
member on the shared playlist, else the first online member, else the first member by id.
|
||||
|
||||
**Item selection stays clock-derived under both backends.** Native sync only replaces the
|
||||
seek/nudge drift correction, because `setSyncParams` has the video element hold its own alignment —
|
||||
and correcting it ourselves would fight the platform. That also keeps images and widgets, which have
|
||||
no `setSyncParams`, advancing with the videos instead of drifting off on their own.
|
||||
|
||||
## Command parity
|
||||
|
||||
The web player handles four of the ~20 fleet commands — `launch`, `refresh`, `screen_on`,
|
||||
|
|
@ -194,13 +218,14 @@ have no BrightSign equivalent — a signage player has no per-window brightness
|
|||
|
||||
Stated plainly so nobody reads this as finished:
|
||||
|
||||
- **No server-side plumbing**: no `sync_backend` column, no dashboard control, nothing sends
|
||||
`set-sync-backend` down, and nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields
|
||||
the player now reports. The resolver is ready for all of it.
|
||||
- **Native sync is implemented but not yet driven by the playlist engine.** `st-sync.js` wraps
|
||||
SyncManager and is tested (`server/test/brightsign-sync.test.js`), but nothing in the player
|
||||
calls `announce()` on item advance or binds `attachVideo()` yet, and no leader is designated.
|
||||
That wiring is the next step and wants hardware to validate.
|
||||
- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports. Device
|
||||
telemetry (temperature, storage) also has no schema to land in yet.
|
||||
- **Native sync is wired but UNPROVEN on hardware.** The player drives it end to end — the leader
|
||||
announces on each advance, every member (leader included) binds via `attachVideo()` on a new id,
|
||||
and the resolved backend is chosen per group and pushed down. It cannot be verified with one
|
||||
player: a single unit is trivially "in sync with itself". **Two BrightSigns on one subnet are
|
||||
needed** to confirm frame alignment, that the leader does not run ahead, and that the 1Hz repeat
|
||||
causes no visible reload.
|
||||
|
||||
```js
|
||||
const SyncManager = require('@brightsign/syncmanager'); // BrightSignOS 8.2.10+
|
||||
|
|
|
|||
|
|
@ -207,6 +207,12 @@ export default {
|
|||
'dashboard.group_sync.toast_on': 'Synchronized playback enabled',
|
||||
'dashboard.group_sync.toast_off': 'Synchronized playback disabled',
|
||||
'dashboard.group_sync.toast_resync': 'Resync sent to group',
|
||||
'dashboard.group_sync.backend_auto': 'Sync: Auto',
|
||||
'dashboard.group_sync.backend_screentinker': 'Sync: Standard',
|
||||
'dashboard.group_sync.backend_brightsign': 'Sync: BrightSign',
|
||||
'dashboard.group_sync.backend_hint': "Which synchronisation protocol this group uses. Standard works across every player type and keeps displays aligned to the second, with no leader and no internet needed. BrightSign is frame-accurate but only works when every display in the group is a BrightSign on the same network, and it synchronises video only. Auto picks BrightSign when the group can actually run it, and Standard otherwise.",
|
||||
'dashboard.group_sync.toast_backend': 'Sync protocol updated',
|
||||
'dashboard.group_sync.toast_downgraded': 'Saved, but this group cannot run that protocol:',
|
||||
'dashboard.manage_tooltip': 'Add/remove devices',
|
||||
'dashboard.delete_group_tooltip': 'Delete group',
|
||||
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@ function renderGroupSection(group, devices, playlists) {
|
|||
<input type="checkbox" class="group-sync-cb" data-group-id="${group.id}" ${group.sync_enabled ? 'checked' : ''}> ${t('dashboard.group_sync.label')}
|
||||
</label>
|
||||
${group.sync_enabled ? `
|
||||
<select class="input group-backend-select" data-group-id="${group.id}" style="width:130px;padding:4px 8px;font-size:12px;background:var(--bg-input)" title="${esc(t('dashboard.group_sync.backend_hint'))}">
|
||||
<option value="auto" ${(group.sync_backend || 'auto') === 'auto' ? 'selected' : ''}>${t('dashboard.group_sync.backend_auto')}</option>
|
||||
<option value="screentinker" ${group.sync_backend === 'screentinker' ? 'selected' : ''}>${t('dashboard.group_sync.backend_screentinker')}</option>
|
||||
<option value="brightsign" ${group.sync_backend === 'brightsign' ? 'selected' : ''}>${t('dashboard.group_sync.backend_brightsign')}</option>
|
||||
</select>
|
||||
${group.sync_effective ? `
|
||||
<span style="font-size:11px;color:${group.sync_downgraded ? 'var(--warning, #d97706)' : 'var(--text-muted)'};white-space:nowrap"
|
||||
title="${esc(group.sync_reason || '')}">${group.sync_downgraded ? '⚠ ' : ''}${esc(group.sync_effective)}${group.sync_reason ? ' — ' + esc(group.sync_reason) : ''}</span>` : ''}
|
||||
<button class="btn group-resync-btn" data-group-id="${group.id}" style="padding:4px 10px;font-size:12px" title="${esc(t('dashboard.group_sync.resync_hint'))}">${t('dashboard.group_sync.resync')}</button>` : ''}
|
||||
` : ''}
|
||||
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
|
||||
|
|
@ -862,6 +870,31 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
});
|
||||
});
|
||||
|
||||
// Choose the sync protocol. The server may refuse the choice (native sync needs every member to
|
||||
// be a BrightSign on one L2 network), so re-render from its answer rather than assuming the
|
||||
// request took — showing a setting that isn't in force is exactly what makes a drifting wall
|
||||
// impossible to diagnose.
|
||||
document.querySelectorAll('.group-backend-select').forEach(sel => {
|
||||
sel.addEventListener('change', async (e) => {
|
||||
const groupId = e.target.dataset.groupId;
|
||||
const previous = sel.dataset.previous || 'auto';
|
||||
const chosen = e.target.value;
|
||||
try {
|
||||
const updated = await api.updateGroup(groupId, { sync_backend: chosen });
|
||||
if (updated?.sync_downgraded && updated?.sync_reason) {
|
||||
showToast(t('dashboard.group_sync.toast_downgraded') + ' ' + updated.sync_reason, 'warning');
|
||||
} else {
|
||||
showToast(t('dashboard.group_sync.toast_backend'), 'success');
|
||||
}
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
e.target.value = previous;
|
||||
}
|
||||
});
|
||||
sel.dataset.previous = sel.value;
|
||||
});
|
||||
|
||||
// #group-sync: manual "Resync now" — nudge all members to re-snap to the shared schedule.
|
||||
document.querySelectorAll('.group-resync-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
|
|
|
|||
|
|
@ -167,6 +167,12 @@ const migrations = [
|
|||
// or offline the server auto-elects the first online member on the matching playlist.
|
||||
"ALTER TABLE device_groups ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE device_groups ADD COLUMN leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL",
|
||||
// Which synchronisation protocol the group runs: 'auto' | 'screentinker' | 'brightsign'.
|
||||
// BrightSign's native SyncManager is frame-accurate but exists only between BrightSign players
|
||||
// on one L2 network, so it cannot be the default — 'auto' picks it only when the group can
|
||||
// actually run it. See server/lib/sync-backend.js; the resolver is the single source of that
|
||||
// decision and this column is only the operator's request.
|
||||
"ALTER TABLE device_groups ADD COLUMN sync_backend TEXT NOT NULL DEFAULT 'auto'",
|
||||
// Wall-level playlist: video walls now play a playlist (not just one content).
|
||||
"ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
|
||||
// Free-form canvas layout: walls store a player rect; member devices store
|
||||
|
|
|
|||
|
|
@ -597,6 +597,15 @@
|
|||
let groupAlignPending = true;
|
||||
let groupLastAlignedIndex = -1;
|
||||
let groupLastSeekAt = 0; // seek cooldown — don't hard-seek every tick (decoder-thrash guard)
|
||||
// BrightSign native sync (SyncManager). An ALTERNATIVE to the clock-derived correction above,
|
||||
// never an addition: when it is running, the seek/nudge maths is skipped entirely because the
|
||||
// video element aligns itself once setSyncParams has been applied. Item SELECTION stays
|
||||
// clock-derived either way — that is what keeps images and widgets, which have no
|
||||
// setSyncParams, advancing together with the videos.
|
||||
let nativeSync = null; // the ScreenTinkerBSSync instance while active
|
||||
let nativeSyncEvent = null; // latest sync event awaiting a video to bind
|
||||
let nativeSyncBoundId = null; // sync id already bound, so we attach once per session
|
||||
let nativeSyncAnnounced = -1; // last index the LEADER announced, to announce once per advance
|
||||
// Double buffer: a hidden <video> for the NEXT clip, buffered/decoded ahead of the boundary so the
|
||||
// switch is instant (no black hold). Reused by renderContent when it reaches that index.
|
||||
let groupPreloadEl = null;
|
||||
|
|
@ -1859,6 +1868,20 @@
|
|||
currentIndex = t.index;
|
||||
playCurrentItem();
|
||||
action = 'jump>' + t.index;
|
||||
// LEADER ONLY: open a new sync session for the item we just moved to. The id must change
|
||||
// on every advance or the followers' 1Hz dedupe swallows it and the group sits on the
|
||||
// previous item forever.
|
||||
if (nativeSync && groupSync.is_leader && nativeSyncAnnounced !== t.index) {
|
||||
nativeSyncAnnounced = t.index;
|
||||
const key = (playlist[t.index]?.content_id || playlist[t.index]?.id || t.index);
|
||||
nativeSync.announce(key, Date.now());
|
||||
}
|
||||
} else if (nativeSync) {
|
||||
// Native sync owns alignment: setSyncParams has the element keeping itself in step, so the
|
||||
// seek/nudge maths below would fight it — every correction we applied would be a frame the
|
||||
// player then had to undo. Just make sure the current item is bound.
|
||||
bindNativeSyncVideo();
|
||||
action = 'native';
|
||||
} else if (currentVideoEl && isFinite(currentVideoEl.duration) && currentVideoEl.duration > 0) {
|
||||
const dur = currentVideoEl.duration;
|
||||
const target = t.posSec % dur; // loop-safe when duration_sec > clip length
|
||||
|
|
@ -1914,10 +1937,55 @@
|
|||
|
||||
// Enter/leave group sync. No CSS transform, no forced mute (per-item mute honored), no leader —
|
||||
// just start the schedule tick. Idempotent across refreshes/role churn (there is no role now).
|
||||
// Enter/leave BrightSign native sync for this group. Returns true if it is running afterwards.
|
||||
// Deliberately fails CLOSED: if the module is missing, the platform is not BrightSign, or the
|
||||
// session will not start, we return false and the caller keeps the clock-derived path — a group
|
||||
// that silently ran neither protocol would drift with no indication of why.
|
||||
function applyNativeSync(cfg) {
|
||||
const want = cfg && cfg.backend === 'brightsign';
|
||||
if (!want || !global_ScreenTinkerBSSync() || !global_ScreenTinkerBSSync().available()) {
|
||||
if (nativeSync) { try { nativeSync.stop(); } catch (e) {} }
|
||||
nativeSync = null; nativeSyncEvent = null; nativeSyncBoundId = null; nativeSyncAnnounced = -1;
|
||||
if (want) groupReport('warn', 'native sync requested but SyncManager is unavailable — using clock sync');
|
||||
return false;
|
||||
}
|
||||
if (nativeSync) { try { nativeSync.stop(); } catch (e) {} nativeSync = null; }
|
||||
nativeSyncEvent = null; nativeSyncBoundId = null; nativeSyncAnnounced = -1;
|
||||
|
||||
const s = global_ScreenTinkerBSSync().create({ domain: 'ST-' + String(cfg.group_id).slice(0, 8) });
|
||||
// The leader receives its OWN broadcast and starts from that, like every follower — starting
|
||||
// at announce() time instead would put it ahead of the group by the width of the network.
|
||||
s.onItem = function (ev) { nativeSyncEvent = ev; bindNativeSyncVideo(); };
|
||||
if (!s.start(!!cfg.is_leader)) {
|
||||
groupReport('warn', 'native sync failed to start — using clock sync');
|
||||
return false;
|
||||
}
|
||||
nativeSync = s;
|
||||
console.log('[native-sync] started as ' + (cfg.is_leader ? 'LEADER' : 'follower') + ' group=' + cfg.group_id);
|
||||
groupReport('info', 'native sync started (' + (cfg.is_leader ? 'leader' : 'follower') + ')');
|
||||
return true;
|
||||
}
|
||||
function global_ScreenTinkerBSSync() {
|
||||
return (typeof window !== 'undefined' && window.ScreenTinkerBSSync) || null;
|
||||
}
|
||||
// Bind the mounted video to the current session, exactly once per sync id. The event can arrive
|
||||
// before the element exists (the leader announces as it advances), so this is called both from
|
||||
// the event and from the tick — whichever wins, the guard makes it idempotent.
|
||||
function bindNativeSyncVideo() {
|
||||
if (!nativeSync || !nativeSyncEvent) return;
|
||||
if (nativeSyncEvent.id === nativeSyncBoundId) return;
|
||||
if (!currentVideoEl) return; // images/widgets have no setSyncParams to bind
|
||||
if (nativeSync.attachVideo(currentVideoEl, nativeSyncEvent)) {
|
||||
nativeSyncBoundId = nativeSyncEvent.id;
|
||||
groupReport('info', 'native sync bound id=' + String(nativeSyncEvent.id).slice(0, 24));
|
||||
}
|
||||
}
|
||||
|
||||
function applyGroupSync(cfg) {
|
||||
if (groupSyncTimer) { clearInterval(groupSyncTimer); groupSyncTimer = null; }
|
||||
try { if (cfg) localStorage.setItem('st_group_sync', JSON.stringify(cfg)); else localStorage.removeItem('st_group_sync'); } catch (e) {}
|
||||
if (!cfg) {
|
||||
applyNativeSync(null);
|
||||
if (groupSync) groupReport('info', 'group-sync exited'); groupSync = null; console.log('[group-sync] exited');
|
||||
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} groupPreloadEl = null; groupPreloadIdx = -1; }
|
||||
reconcileAdvanceTimerForMode(); // #200: back to solo -> re-arm a surviving image/widget's timer
|
||||
|
|
@ -1926,7 +1994,14 @@
|
|||
const first = !groupSync;
|
||||
groupSync = cfg;
|
||||
groupAlignPending = true; groupLastAlignedIndex = -1; // snap the first item into sync on entry
|
||||
console.log('[group-sync] group=' + cfg.group_id + ' (clock/schedule, offset=' + clockOffsetMs + 'ms)');
|
||||
// Tell the host which protocol won, so a cold boot with no network starts in the right mode.
|
||||
try { if (BS && cfg.backend) BS.setSyncBackend(cfg.backend); } catch (e) { /* not on BrightSign */ }
|
||||
if (cfg.sync_downgraded && cfg.sync_reason) {
|
||||
groupReport('warn', 'sync downgraded to ' + cfg.backend + ': ' + cfg.sync_reason);
|
||||
}
|
||||
applyNativeSync(cfg);
|
||||
console.log('[group-sync] group=' + cfg.group_id + ' backend=' + (cfg.backend || 'screentinker')
|
||||
+ ' (clock/schedule, offset=' + clockOffsetMs + 'ms)');
|
||||
groupReport('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(cfg.group_id).slice(0, 8) + ' off=' + clockOffsetMs + 'ms');
|
||||
groupScheduleTick(); // align immediately
|
||||
groupSyncTimer = setInterval(groupScheduleTick, 250); // 4Hz local correction
|
||||
|
|
@ -2035,9 +2110,15 @@
|
|||
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
|
||||
}
|
||||
// #group-sync: enter/leave on group membership change (mutually exclusive with wall — the
|
||||
// server sends group_sync=null for a wall member). There's no leader role, so the key is just
|
||||
// the group id; a plain refresh re-aligns the schedule locally (no server round-trip needed).
|
||||
const groupKey = (g) => (g ? String(g.group_id) : '');
|
||||
// server sends group_sync=null for a wall member). A plain refresh re-aligns the schedule
|
||||
// locally (no server round-trip needed).
|
||||
//
|
||||
// The clock-derived protocol has no leader and one mode, so the group id alone used to be
|
||||
// enough. Native sync has both: switching protocol, or leadership moving because the old
|
||||
// leader went offline, changes what THIS player must do — and neither changes the group id.
|
||||
// Keying on the id alone would leave a player running the old protocol, or leave a promoted
|
||||
// leader silently not announcing, until something unrelated forced a re-enter.
|
||||
const groupKey = (g) => (g ? [g.group_id, g.backend || '', g.is_leader ? 'L' : 'f'].join('|') : '');
|
||||
const groupChanged = groupKey(groupSync) !== groupKey(data.group_sync);
|
||||
if (groupChanged) applyGroupSync(data.group_sync || null);
|
||||
else if (groupSync) groupScheduleTick();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { accessContext } = require('../lib/tenancy');
|
|||
// #public-api: operational fleet commands (reboot/shutdown/...) need the 'full' token
|
||||
// scope. No-op for JWT sessions; for tokens a read/write scope is rejected.
|
||||
const { requireScope } = require('../middleware/apiToken');
|
||||
const { resolveSyncBackend, BACKENDS } = require('../lib/sync-backend');
|
||||
|
||||
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||
const ALLOWED_COMMANDS = [
|
||||
|
|
@ -50,6 +51,21 @@ function requireGroupWrite(req, res, next) {
|
|||
next();
|
||||
}
|
||||
|
||||
// What the group's sync_backend setting actually RESOLVES to, for the dashboard. The stored value
|
||||
// is only a request: 'brightsign' on a mixed fleet, or on players spread across subnets, is refused
|
||||
// by the resolver. Sending the decision alongside the request is what lets the UI explain the
|
||||
// refusal instead of showing a setting that quietly isn't in force.
|
||||
function syncDecisionFor(group) {
|
||||
if (!group?.playlist_id) return { sync_effective: null, sync_reason: null, sync_downgraded: false };
|
||||
const members = db.prepare(`
|
||||
SELECT d.id, d.platform, d.ip_address FROM devices d
|
||||
JOIN device_group_members dgm ON dgm.device_id = d.id
|
||||
WHERE dgm.group_id = ? AND d.playlist_id = ?
|
||||
`).all(group.id, group.playlist_id);
|
||||
const d = resolveSyncBackend(group.sync_backend, members);
|
||||
return { sync_effective: d.backend, sync_reason: d.reason, sync_downgraded: d.downgraded };
|
||||
}
|
||||
|
||||
// List groups in the caller's current workspace.
|
||||
router.get('/', (req, res) => {
|
||||
if (!req.workspaceId) return res.json([]);
|
||||
|
|
@ -61,7 +77,7 @@ router.get('/', (req, res) => {
|
|||
GROUP BY g.id
|
||||
ORDER BY g.name ASC
|
||||
`).all(req.workspaceId);
|
||||
res.json(groups);
|
||||
res.json(groups.map(g => ({ ...g, ...syncDecisionFor(g) })));
|
||||
});
|
||||
|
||||
// Create group in the caller's current workspace.
|
||||
|
|
@ -78,7 +94,13 @@ router.post('/', (req, res) => {
|
|||
|
||||
// Update group
|
||||
router.put('/:id', requireGroupWrite, (req, res) => {
|
||||
const { name, color, sync_enabled, leader_device_id, reboot_schedule } = req.body;
|
||||
const { name, color, sync_enabled, leader_device_id, reboot_schedule, sync_backend } = req.body;
|
||||
// Reject an unknown backend rather than storing it: the resolver reads anything unrecognised as
|
||||
// 'auto', so a typo would silently give the operator a different protocol from the one they
|
||||
// picked, with the UI still showing their typo back to them.
|
||||
if (sync_backend !== undefined && !BACKENDS.includes(sync_backend)) {
|
||||
return res.status(400).json({ error: `sync_backend must be one of: ${BACKENDS.join(', ')}` });
|
||||
}
|
||||
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
||||
// #12 scheduled reboot: group-level default nightly-reboot time ("HH:MM" or null/'' = off).
|
||||
// A member device's own reboot_schedule overrides this in the scheduler.
|
||||
|
|
@ -105,12 +127,18 @@ router.put('/:id', requireGroupWrite, (req, res) => {
|
|||
}
|
||||
db.prepare('UPDATE device_groups SET leader_device_id = ? WHERE id = ?').run(leader_device_id || null, req.params.id);
|
||||
}
|
||||
if (sync_backend !== undefined) {
|
||||
db.prepare('UPDATE device_groups SET sync_backend = ? WHERE id = ?').run(sync_backend, req.params.id);
|
||||
}
|
||||
// Re-push to every member so they enter/exit sync mode and refresh their is_leader flag.
|
||||
if (sync_enabled !== undefined || leader_device_id !== undefined) {
|
||||
// sync_backend belongs here too: switching protocol has to reach the players, or the group keeps
|
||||
// running the old one until something unrelated happens to re-push.
|
||||
if (sync_enabled !== undefined || leader_device_id !== undefined || sync_backend !== undefined) {
|
||||
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
||||
for (const m of members) pushPlaylistToDevice(req, m.device_id);
|
||||
}
|
||||
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
|
||||
const updated = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
|
||||
res.json({ ...updated, ...syncDecisionFor(updated) });
|
||||
});
|
||||
|
||||
// #group-sync: manual "Resync now" — nudge every member to re-snap to the shared schedule
|
||||
|
|
|
|||
96
server/test/group-sync-backend-api.test.js
Normal file
96
server/test/group-sync-backend-api.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use strict';
|
||||
|
||||
// Choosing a sync protocol from the dashboard has one failure mode that matters: a value the
|
||||
// resolver does not recognise is read as 'auto'. So a typo — or a client sending a stale/renamed
|
||||
// value — would store fine, return 200, and leave the UI showing a protocol the group is not
|
||||
// running. The operator's only clue would be a wall that is subtly out of step.
|
||||
//
|
||||
// The other half is reporting. When the request cannot be honoured (native sync on a mixed fleet)
|
||||
// the group must still be told what it will ACTUALLY run and why, or the setting silently means
|
||||
// something different from what it says.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-sbapi-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-sync-backend-api';
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
const { resolveTenancy } = require('../lib/tenancy');
|
||||
|
||||
const O = 'o-sa', WS = 'ws-sa', U = 'u-sa', PL = 'pl-sa', G = 'g-sa';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sa@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
|
||||
db.prepare('INSERT OR IGNORE INTO playlists (id,user_id,name,workspace_id) VALUES (?,?,?,?)').run(PL, U, 'Shared', WS);
|
||||
db.prepare(`INSERT OR IGNORE INTO device_groups (id,name,user_id,workspace_id,playlist_id,sync_enabled)
|
||||
VALUES (?,?,?,?,?,1)`).run(G, 'Group', U, WS, PL);
|
||||
|
||||
// A mixed group: one BrightSign, one Android. Native sync cannot include the Android one.
|
||||
for (const [id, platform] of [['d-sa-bs', 'brightsign'], ['d-sa-and', 'Android 12']]) {
|
||||
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,playlist_id,status,platform,ip_address,created_at,updated_at)
|
||||
VALUES (?,?,?,?, 'online',?, '10.0.0.1', strftime('%s','now'),strftime('%s','now'))`)
|
||||
.run(id, id, WS, PL, platform);
|
||||
db.prepare('INSERT OR IGNORE INTO device_group_members (group_id,device_id) VALUES (?,?)').run(G, id);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set('io', null);
|
||||
app.use('/api/groups', requireAuth, resolveTenancy, require('../routes/device-groups'));
|
||||
const server = app.listen(0);
|
||||
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
|
||||
|
||||
async function put(body) {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups/${G}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
test('THE TYPO: an unrecognised backend is refused, not stored as a silent "auto"', async () => {
|
||||
const { status } = await put({ sync_backend: 'brightsigne' });
|
||||
assert.equal(status, 400, 'storing it would show the operator a protocol the group is not running');
|
||||
const stored = db.prepare('SELECT sync_backend FROM device_groups WHERE id = ?').get(G).sync_backend;
|
||||
assert.equal(stored, 'auto', 'the rejected value must not have been written');
|
||||
});
|
||||
|
||||
test('a refused request is saved but reported with what will actually run, and why', async () => {
|
||||
const { status, body } = await put({ sync_backend: 'brightsign' });
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.sync_backend, 'brightsign', 'the operator\'s choice is remembered');
|
||||
assert.equal(body.sync_effective, 'screentinker', 'but this is what the screens will run');
|
||||
assert.equal(body.sync_downgraded, true);
|
||||
assert.match(body.sync_reason, /non-BrightSign/);
|
||||
});
|
||||
|
||||
test('the group list carries the same decision, so the UI never disagrees with the players', async () => {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const groups = await res.json();
|
||||
const g = groups.find(x => x.id === G);
|
||||
assert.equal(g.sync_effective, 'screentinker');
|
||||
assert.equal(g.sync_downgraded, true);
|
||||
});
|
||||
|
||||
test('every accepted value round-trips', async () => {
|
||||
for (const v of ['auto', 'screentinker', 'brightsign']) {
|
||||
const { status, body } = await put({ sync_backend: v });
|
||||
assert.equal(status, 200, v);
|
||||
assert.equal(body.sync_backend, v);
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => server.close());
|
||||
132
server/test/group-sync-backend-resolution.test.js
Normal file
132
server/test/group-sync-backend-resolution.test.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
'use strict';
|
||||
|
||||
// A synchronised group has to agree with itself about WHICH protocol it is running, across three
|
||||
// places that never speak to each other: the players, the dashboard, and the stored setting.
|
||||
//
|
||||
// The stored setting is only a REQUEST. BrightSign's native sync is frame-accurate but exists only
|
||||
// between BrightSign players on one multicast L2 network, so "brightsign" on a mixed fleet is a
|
||||
// request that cannot be honoured. If the server stored it and said nothing, the operator would
|
||||
// read "BrightSign" in the UI while the screens ran the clock-derived protocol — and the one
|
||||
// symptom they would eventually notice (a wall that is a second out) has no visible cause.
|
||||
//
|
||||
// Worse is the leader. Ours is leaderless and survives anything; native sync has ONE broadcaster,
|
||||
// so a group whose elected leader is offline sits unsynchronised with every member waiting for an
|
||||
// announcement that will never come. The dashboard would show a healthy group the whole time.
|
||||
//
|
||||
// The invariants: what the players are told and what the dashboard shows both come from the same
|
||||
// pure resolver, a refused request is reported rather than silently altered, and a group with no
|
||||
// live leader falls back to the protocol that does not need one.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-syncbackend-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-sync-backend';
|
||||
|
||||
const { db } = require('../db/database');
|
||||
|
||||
const O = 'o-sb', WS = 'ws-sb', U = 'u-sb', PL = 'pl-sb';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sb@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare('INSERT OR IGNORE INTO playlists (id,user_id,name,workspace_id) VALUES (?,?,?,?)').run(PL, U, 'Shared', WS);
|
||||
// OR IGNORE swallows constraint violations, so a missing NOT NULL column here would leave the row
|
||||
// absent and every later foreign key fail instead — assert the fixture actually landed.
|
||||
assert.ok(db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(PL), 'playlist fixture did not insert');
|
||||
|
||||
let seq = 0;
|
||||
/** Build a sync-enabled group on the shared playlist with the given members. */
|
||||
function makeGroup(backend, members) {
|
||||
const gid = 'g-sb-' + (++seq);
|
||||
db.prepare(`INSERT INTO device_groups (id,name,user_id,workspace_id,playlist_id,sync_enabled,sync_backend)
|
||||
VALUES (?,?,?,?,?,1,?)`).run(gid, 'G' + seq, U, WS, PL, backend);
|
||||
members.forEach((m, i) => {
|
||||
const did = gid + '-d' + i;
|
||||
db.prepare(`INSERT INTO devices (id,name,workspace_id,playlist_id,status,platform,ip_address,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,strftime('%s','now'),strftime('%s','now'))`)
|
||||
.run(did, 'D' + i, WS, PL, m.status || 'online', m.platform, m.ip || '10.0.0.' + (i + 1));
|
||||
db.prepare('INSERT INTO device_group_members (group_id,device_id) VALUES (?,?)').run(gid, did);
|
||||
});
|
||||
return gid;
|
||||
}
|
||||
|
||||
const { __test } = require('../ws/deviceSocket');
|
||||
|
||||
test('an all-BrightSign group on one subnet is told to run native sync', () => {
|
||||
const g = makeGroup('auto', [
|
||||
{ platform: 'brightsign', ip: '10.0.5.1' },
|
||||
{ platform: 'brightsign', ip: '10.0.5.2' },
|
||||
]);
|
||||
const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
|
||||
const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
|
||||
assert.equal(gs.backend, 'brightsign');
|
||||
assert.equal(gs.sync_downgraded, false);
|
||||
});
|
||||
|
||||
test('THE MIXED FLEET: one Android member drops the whole group back to our protocol', () => {
|
||||
// BrightWall cannot include a non-BrightSign screen. Half-syncing is worse than second-accurate
|
||||
// everywhere, and it would look perfectly healthy from the dashboard.
|
||||
const g = makeGroup('brightsign', [
|
||||
{ platform: 'brightsign', ip: '10.0.6.1' },
|
||||
{ platform: 'Android 12', ip: '10.0.6.2' },
|
||||
]);
|
||||
const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
|
||||
const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
|
||||
assert.equal(gs.backend, 'screentinker');
|
||||
assert.equal(gs.sync_downgraded, true);
|
||||
assert.match(gs.sync_reason, /non-BrightSign/);
|
||||
});
|
||||
|
||||
test('THE SILENT SPLIT: BrightSigns on different subnets do not get multicast sync', () => {
|
||||
const g = makeGroup('brightsign', [
|
||||
{ platform: 'brightsign', ip: '10.1.0.9' },
|
||||
{ platform: 'brightsign', ip: '10.9.0.9' },
|
||||
]);
|
||||
const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
|
||||
const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
|
||||
assert.equal(gs.backend, 'screentinker');
|
||||
assert.match(gs.sync_reason, /multicast|different networks/);
|
||||
});
|
||||
|
||||
test('THE DEAD LEADER: native sync falls back when nobody is left to broadcast', () => {
|
||||
// Ours is leaderless and carries on; native sync has exactly one broadcaster. An all-offline
|
||||
// group still elects a leader (stable id), so without this check the members that DO come back
|
||||
// would sit waiting for an announcement from a player that is powered down.
|
||||
const g = makeGroup('brightsign', [
|
||||
{ platform: 'brightsign', ip: '10.0.7.1', status: 'offline' },
|
||||
{ platform: 'brightsign', ip: '10.0.7.2', status: 'offline' },
|
||||
]);
|
||||
const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
|
||||
const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
|
||||
assert.equal(gs.backend, 'screentinker');
|
||||
assert.equal(gs.sync_downgraded, true);
|
||||
assert.match(gs.sync_reason, /leader is offline/);
|
||||
});
|
||||
|
||||
test('exactly one member is told it is the leader, and it is an online one', () => {
|
||||
const g = makeGroup('auto', [
|
||||
{ platform: 'brightsign', ip: '10.0.8.1', status: 'offline' },
|
||||
{ platform: 'brightsign', ip: '10.0.8.2', status: 'online' },
|
||||
]);
|
||||
const ids = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').all(g);
|
||||
const flags = ids.map(r => __test.resolveGroupSync({ playlist_id: PL }, r.device_id).is_leader);
|
||||
assert.equal(flags.filter(Boolean).length, 1, 'two leaders would both broadcast; none would sync nothing');
|
||||
const leaderId = ids[flags.indexOf(true)].device_id;
|
||||
const st = db.prepare('SELECT status FROM devices WHERE id = ?').get(leaderId).status;
|
||||
assert.equal(st, 'online', 'an offline leader cannot announce');
|
||||
});
|
||||
|
||||
test('an operator choosing our protocol on an all-BrightSign group is obeyed, not overridden', () => {
|
||||
const g = makeGroup('screentinker', [
|
||||
{ platform: 'brightsign', ip: '10.0.9.1' },
|
||||
{ platform: 'brightsign', ip: '10.0.9.2' },
|
||||
]);
|
||||
const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
|
||||
const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
|
||||
assert.equal(gs.backend, 'screentinker');
|
||||
assert.equal(gs.sync_downgraded, false);
|
||||
});
|
||||
148
server/test/group-sync-native-wiring.test.js
Normal file
148
server/test/group-sync-native-wiring.test.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
'use strict';
|
||||
|
||||
// The player's half of native sync, extracted from server/player/index.html and run against a fake
|
||||
// SyncManager. Three things here are easy to get wrong and impossible to see from a unit test of
|
||||
// st-sync.js alone, because they are about how the PLAYER drives it:
|
||||
//
|
||||
// 1. The leader must announce a NEW id on every advance. Reusing an id is swallowed by the 1Hz
|
||||
// dedupe and the whole group sits on the previous item — the failure looks like "sync is
|
||||
// stuck", not "the id was wrong".
|
||||
// 2. Binding must happen exactly once per sync id even though the event arrives at 1Hz and the
|
||||
// player also retries on every 4Hz tick. Re-binding each time reloads the video repeatedly,
|
||||
// which on screen reads as a stutter or a restart loop.
|
||||
// 3. The event routinely arrives BEFORE the video element exists, because the leader announces as
|
||||
// it advances. Dropping it there would leave that item unsynchronised for its whole duration.
|
||||
//
|
||||
// The functions under test are pulled out of the player by source extraction so they cannot drift
|
||||
// from what actually ships — the same technique the fingerprint and identity-reset tests use.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
|
||||
|
||||
/** Pull one top-level `function name(...) {...}` out of the player by brace matching. */
|
||||
function extract(name) {
|
||||
const start = HTML.indexOf('function ' + name + '(');
|
||||
assert.notEqual(start, -1, 'player no longer defines ' + name);
|
||||
let depth = 0, end = -1;
|
||||
for (let i = HTML.indexOf('{', start); i < HTML.length; i++) {
|
||||
if (HTML[i] === '{') depth++;
|
||||
else if (HTML[i] === '}' && --depth === 0) { end = i + 1; break; }
|
||||
}
|
||||
assert.notEqual(end, -1);
|
||||
return HTML.slice(start, end);
|
||||
}
|
||||
|
||||
/** A fake SyncManager session with the real one's contract: 1Hz repeats, dedupe by id. */
|
||||
function makeHarness({ isLeader = false, hasVideo = true } = {}) {
|
||||
const state = {
|
||||
announced: [],
|
||||
attached: [],
|
||||
reports: [],
|
||||
boundId: null,
|
||||
event: null,
|
||||
};
|
||||
|
||||
const sync = {
|
||||
announce(key, now) { const id = 'st_' + key + '_' + now; state.announced.push(id); return id; },
|
||||
attachVideo(video, ev) { state.attached.push(ev.id); return true; },
|
||||
};
|
||||
|
||||
const scope = {
|
||||
nativeSync: sync,
|
||||
nativeSyncEvent: null,
|
||||
nativeSyncBoundId: null,
|
||||
currentVideoEl: hasVideo ? { tagName: 'VIDEO' } : null,
|
||||
groupReport: (lvl, msg) => state.reports.push(msg),
|
||||
String,
|
||||
};
|
||||
|
||||
// bindNativeSyncVideo closes over module-level lets; rebuild it with an explicit scope object so
|
||||
// the assignments are observable.
|
||||
const src = extract('bindNativeSyncVideo')
|
||||
.replace(/nativeSyncBoundId/g, 'S.nativeSyncBoundId')
|
||||
.replace(/nativeSyncEvent/g, 'S.nativeSyncEvent')
|
||||
.replace(/nativeSync\b(?!S)/g, 'S.nativeSync')
|
||||
.replace(/currentVideoEl/g, 'S.currentVideoEl')
|
||||
.replace(/groupReport/g, 'S.groupReport');
|
||||
const S = { ...scope };
|
||||
const bind = new Function('S', src + '; return bindNativeSyncVideo;')(S);
|
||||
return { S, bind, state, sync };
|
||||
}
|
||||
|
||||
test('THE 1Hz TRAP: ten repeats of one sync id bind the video exactly once', () => {
|
||||
const { S, bind, state } = makeHarness();
|
||||
S.nativeSyncEvent = { id: 'st_item7_1000', domain: 'd', iso_timestamp: 't' };
|
||||
for (let i = 0; i < 10; i++) bind();
|
||||
assert.equal(state.attached.length, 1, 'ten binds would be ten video reloads — a visible stutter');
|
||||
});
|
||||
|
||||
test('a NEW id binds again — that is how the group advances', () => {
|
||||
const { S, bind, state } = makeHarness();
|
||||
S.nativeSyncEvent = { id: 'a', domain: 'd', iso_timestamp: 't' };
|
||||
bind(); bind();
|
||||
S.nativeSyncEvent = { id: 'b', domain: 'd', iso_timestamp: 't' };
|
||||
bind();
|
||||
assert.deepEqual(state.attached, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('THE EARLY EVENT: an event with no video yet is kept, not dropped', () => {
|
||||
// The leader announces as it advances, so the broadcast routinely beats the element into
|
||||
// existence. Dropping it would leave that item unsynchronised for its whole duration.
|
||||
const { S, bind, state } = makeHarness({ hasVideo: false });
|
||||
S.nativeSyncEvent = { id: 'early', domain: 'd', iso_timestamp: 't' };
|
||||
bind();
|
||||
assert.equal(state.attached.length, 0, 'nothing to bind to yet');
|
||||
assert.equal(S.nativeSyncBoundId, null, 'and it must NOT be marked bound');
|
||||
|
||||
S.currentVideoEl = { tagName: 'VIDEO' }; // the 4Hz tick calls bind again once it mounts
|
||||
bind();
|
||||
assert.deepEqual(state.attached, ['early'], 'the retained event binds as soon as the video exists');
|
||||
});
|
||||
|
||||
test('an image or widget item never binds — there is no setSyncParams to bind', () => {
|
||||
const { S, bind, state } = makeHarness({ hasVideo: false });
|
||||
S.nativeSyncEvent = { id: 'img', domain: 'd', iso_timestamp: 't' };
|
||||
bind(); bind();
|
||||
assert.equal(state.attached.length, 0);
|
||||
});
|
||||
|
||||
test('the leader mints a DISTINCT id per advance, or the group sticks on one item', () => {
|
||||
// A repeated id is swallowed by every follower's dedupe. The symptom is "sync is stuck", which
|
||||
// points nowhere near the id.
|
||||
const { sync } = makeHarness({ isLeader: true });
|
||||
const a = sync.announce('content-1', 1000);
|
||||
const b = sync.announce('content-2', 2000);
|
||||
const c = sync.announce('content-1', 3000); // same item coming round again on loop
|
||||
assert.notEqual(a, b);
|
||||
assert.notEqual(a, c, 'the same item on a second lap is still a NEW session');
|
||||
});
|
||||
|
||||
test('the player still defines the pieces this wiring depends on', () => {
|
||||
// A rename in index.html that silently broke native sync would otherwise only show up on
|
||||
// hardware, which we have one of.
|
||||
for (const fn of ['applyNativeSync', 'bindNativeSyncVideo', 'applyGroupSync', 'groupScheduleTick']) {
|
||||
assert.ok(HTML.includes('function ' + fn + '('), 'player must still define ' + fn);
|
||||
}
|
||||
assert.match(HTML, /nativeSync\.announce\(/, 'the leader must still announce on advance');
|
||||
assert.match(HTML, /backend === 'brightsign'/, 'native sync must still be gated on the resolved backend');
|
||||
});
|
||||
|
||||
test('THE PROMOTED LEADER: leadership moving must re-enter sync, not be ignored', () => {
|
||||
// Neither switching protocol nor a leader change alters the group id. When the re-enter key was
|
||||
// the id ALONE, a player promoted to leader after the old one went offline carried on behaving as
|
||||
// a follower — so nobody announced, the whole group sat unsynchronised, and the dashboard showed
|
||||
// a healthy group throughout.
|
||||
const m = HTML.match(/const groupKey = \(g\) => (.*);/);
|
||||
assert.ok(m, 'player no longer defines groupKey');
|
||||
const groupKey = new Function('g', 'return ' + m[1] + ';');
|
||||
|
||||
const base = { group_id: 'g1', backend: 'brightsign', is_leader: false };
|
||||
assert.notEqual(groupKey(base), groupKey({ ...base, is_leader: true }), 'promotion must re-enter');
|
||||
assert.notEqual(groupKey(base), groupKey({ ...base, backend: 'screentinker' }), 'protocol switch must re-enter');
|
||||
assert.equal(groupKey(base), groupKey({ ...base }), 'an unchanged group must NOT churn on every push');
|
||||
assert.equal(groupKey(null), '');
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ const { protectSocket } = require('../lib/safe-socket');
|
|||
const flapLimiter = require('../lib/flap-limiter');
|
||||
const sessionSettle = require('../lib/session-settle'); // #148 patch2: eviction-storm debounce
|
||||
const { resolveIdentity } = require('../lib/device-identity');
|
||||
const { resolveSyncBackend } = require('../lib/sync-backend');
|
||||
const logCoalescer = require('../lib/log-coalescer');
|
||||
const loopLag = require('../services/loop-lag');
|
||||
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
|
||||
|
|
@ -144,10 +145,12 @@ function logDeviceStatus(deviceId, status, reason, detail) {
|
|||
// groups). Sync-eligible members = the group's members whose playlist MATCHES the group's shared
|
||||
// playlist. A member on a different playlist is ignored (never synced) — index sync would be
|
||||
// meaningless. Ordered by id for a stable auto-election.
|
||||
// platform + ip_address are selected for the sync-backend resolver, not for election: it decides
|
||||
// native-vs-ours from what the members ARE (BrightSign?) and where they are (one L2 network?).
|
||||
function groupSyncMembers(group) {
|
||||
if (!group || !group.playlist_id) return [];
|
||||
return db.prepare(`
|
||||
SELECT d.id, d.status FROM devices d
|
||||
SELECT d.id, d.status, d.platform, d.ip_address FROM devices d
|
||||
JOIN device_group_members dgm ON dgm.device_id = d.id
|
||||
WHERE dgm.group_id = ? AND d.playlist_id = ? ORDER BY d.id
|
||||
`).all(group.id, group.playlist_id);
|
||||
|
|
@ -170,7 +173,7 @@ function resolveGroupLeader(group) {
|
|||
function deviceSyncGroup(deviceId, devicePlaylistId) {
|
||||
if (!devicePlaylistId) return null;
|
||||
return db.prepare(`
|
||||
SELECT g.id, g.sync_enabled, g.playlist_id, g.leader_device_id
|
||||
SELECT g.id, g.sync_enabled, g.playlist_id, g.leader_device_id, g.sync_backend
|
||||
FROM device_groups g JOIN device_group_members dgm ON dgm.group_id = g.id
|
||||
WHERE dgm.device_id = ? AND g.sync_enabled = 1 AND g.playlist_id = ?
|
||||
ORDER BY g.name ASC, g.id ASC LIMIT 1
|
||||
|
|
@ -181,9 +184,38 @@ function deviceSyncGroup(deviceId, devicePlaylistId) {
|
|||
function resolveGroupSync(device, deviceId) {
|
||||
const group = deviceSyncGroup(deviceId, device?.playlist_id);
|
||||
if (!group) return null;
|
||||
const members = groupSyncMembers(group);
|
||||
const leaderId = resolveGroupLeader(group);
|
||||
if (!leaderId) return null;
|
||||
return { group_id: group.id, is_leader: leaderId === deviceId };
|
||||
|
||||
// Which protocol this group actually runs. The decision lives in one pure function so the
|
||||
// dashboard, the tests and this payload can never disagree about it — an operator being told
|
||||
// "native sync" while the players ran ours would be undebuggable.
|
||||
const decision = resolveSyncBackend(group.sync_backend, members);
|
||||
|
||||
// Native sync is leader/follower and the leader broadcasts; ours is leaderless. A group whose
|
||||
// elected leader is OFFLINE would therefore sit unsynchronised on the native protocol — nobody
|
||||
// is announcing — where our own clock-derived sync carries on regardless. So fall back rather
|
||||
// than leave a wall frozen on whatever it happened to be showing.
|
||||
const leaderOnline = members.some(m => m.id === leaderId && m.status === 'online');
|
||||
let backend = decision.backend;
|
||||
let reason = decision.reason;
|
||||
let downgraded = decision.downgraded;
|
||||
if (backend === 'brightsign' && !leaderOnline) {
|
||||
backend = 'screentinker';
|
||||
reason = 'the group leader is offline — native sync has nobody to broadcast';
|
||||
downgraded = true;
|
||||
}
|
||||
|
||||
return {
|
||||
group_id: group.id,
|
||||
is_leader: leaderId === deviceId,
|
||||
backend,
|
||||
// Carried to the player for logging, and to the dashboard so an operator can see WHY a
|
||||
// requested backend was refused instead of guessing.
|
||||
sync_reason: reason,
|
||||
sync_downgraded: downgraded,
|
||||
};
|
||||
}
|
||||
|
||||
// A widget's CONTENT is always live — /api/widgets/:id/render reads the current config — but the
|
||||
|
|
@ -1476,3 +1508,8 @@ module.exports.__resetTimers = () => {
|
|||
pendingOfflines.clear();
|
||||
evictedSockets.clear();
|
||||
};
|
||||
|
||||
// Test seam: which protocol a group runs, and whether a request was refused, is the one piece of
|
||||
// branching in this file with nothing to do with sockets. Exposing it lets that decision be tested
|
||||
// against a real database without standing up a socket server.
|
||||
module.exports.__test = { resolveGroupSync, resolveGroupLeader, groupSyncMembers };
|
||||
|
|
|
|||
Loading…
Reference in a new issue