mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.
The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.
Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.
LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.
Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.
device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.
FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.
971 pass (+17).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
133 lines
7.1 KiB
JavaScript
133 lines
7.1 KiB
JavaScript
'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);
|
|
});
|