239 lines
11 KiB
JavaScript
239 lines
11 KiB
JavaScript
'use strict';
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* Service timing for the PCO Plan module — pure functions, no I/O, no
|
|||
|
|
* timers. server.js feeds them the plan's service times, the documents
|
|||
|
|
* ProPresenter puts live and the clock; service-timing.test.js calls them
|
|||
|
|
* directly. Two questions are answered here:
|
|||
|
|
*
|
|||
|
|
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
|
|||
|
|
* 2. When does that service count as started, and as ended? → advance
|
|||
|
|
*
|
|||
|
|
* ── Service windows ────────────────────────────────────────────────────
|
|||
|
|
*
|
|||
|
|
* A plan can have several services (plan_times of type "service": 9:00 and
|
|||
|
|
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
|
|||
|
|
* timing and the countdown at any moment, and moving into the next
|
|||
|
|
* service's window resets all three. Each service's window runs from the
|
|||
|
|
* end of the previous one to a boundary the next service's pre-roll sets:
|
|||
|
|
*
|
|||
|
|
* boundary(i → i+1) = min( start[i+1],
|
|||
|
|
* max( start[i+1] − preRoll, endOf[i] ) )
|
|||
|
|
*
|
|||
|
|
* endOf[i] = the moment service i ended (its end cue fired), else its
|
|||
|
|
* scheduled end (plan_time ends_at, else start + the planned
|
|||
|
|
* length of the "during" items, else 90 min).
|
|||
|
|
*
|
|||
|
|
* In words: the next service takes over `preRoll` before its scheduled
|
|||
|
|
* start (30 min by default; admin "Pre-roll"), but never before the current
|
|||
|
|
* one has ended or was due to end, and never later than its own start. A
|
|||
|
|
* service still running at the boundary is stopped there — that is the
|
|||
|
|
* "next service's start" failsafe the end cue has. The first window opens at
|
|||
|
|
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
|
|||
|
|
* countdown running, service idle); the last window never closes — the last
|
|||
|
|
* service stays "stopped" until the plan itself changes (the next day's plan
|
|||
|
|
* takes over through the plan picker). The last service also has a failsafe
|
|||
|
|
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
|
|||
|
|
*
|
|||
|
|
* ── Start and end of a service ─────────────────────────────────────────
|
|||
|
|
*
|
|||
|
|
* phase: idle → running → stopped, never backwards within one window.
|
|||
|
|
*
|
|||
|
|
* start, schedule mode (default) at the scheduled start; startedAt = it.
|
|||
|
|
* start, document mode when the start document goes live
|
|||
|
|
* (edge: it has to *become* live, from
|
|||
|
|
* pre-roll before the scheduled start
|
|||
|
|
* onwards — a rehearsal an hour earlier
|
|||
|
|
* that shows the same document is not the
|
|||
|
|
* service). Failsafe: if the document has
|
|||
|
|
* not gone live by start + grace (admin
|
|||
|
|
* "Start failsafe", 10 min default), the
|
|||
|
|
* service starts anyway, counting from
|
|||
|
|
* the scheduled start — the best estimate
|
|||
|
|
* of when it really began.
|
|||
|
|
* end, document when the end document goes live while
|
|||
|
|
* the service is running (an end document
|
|||
|
|
* live before the start — the previous
|
|||
|
|
* service's loop — is ignored). Failsafe
|
|||
|
|
* either way: the window boundary (see
|
|||
|
|
* above), or, for the last service, its
|
|||
|
|
* scheduled end + LAST_SERVICE_OVERRUN_MS.
|
|||
|
|
*
|
|||
|
|
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
|
|||
|
|
* matching.js for the tolerance), so "Post-Service Loop" still ends the
|
|||
|
|
* service when ProPresenter calls it "Post Service Loop".
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
|
|||
|
|
|
|||
|
|
const MINUTE = 60000;
|
|||
|
|
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
|
|||
|
|
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
|
|||
|
|
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
|
|||
|
|
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
|
|||
|
|
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
|
|||
|
|
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
|
|||
|
|
/** A service with no end time and no planned item lengths is assumed this long. */
|
|||
|
|
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
|
|||
|
|
|
|||
|
|
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
|
|||
|
|
function serviceTimes(planTimes) {
|
|||
|
|
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
|
|||
|
|
const services = list.filter((t) => t.timeType === 'service');
|
|||
|
|
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The window each service owns (see the file comment).
|
|||
|
|
*
|
|||
|
|
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
|
|||
|
|
* @param {object} [opts]
|
|||
|
|
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
|
|||
|
|
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
|
|||
|
|
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
|
|||
|
|
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
|
|||
|
|
*/
|
|||
|
|
function serviceWindows(planTimes, opts = {}) {
|
|||
|
|
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
|
|||
|
|
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
|
|||
|
|
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
|
|||
|
|
const services = serviceTimes(planTimes);
|
|||
|
|
const windows = services.map((svc, i) => {
|
|||
|
|
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
|
|||
|
|
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
|
|||
|
|
const next = services[i + 1];
|
|||
|
|
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
|
|||
|
|
return {
|
|||
|
|
id: String(svc.id),
|
|||
|
|
name: String(svc.name || ''),
|
|||
|
|
index: i,
|
|||
|
|
count: services.length,
|
|||
|
|
startsAt: svc.startsAt,
|
|||
|
|
endsAt: scheduledEnd,
|
|||
|
|
windowFrom: -Infinity, // filled in below
|
|||
|
|
windowUntil,
|
|||
|
|
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
|
|||
|
|
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
|
|||
|
|
isLast: !next,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
|
|||
|
|
return windows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The window that holds `now` — null only when there are no services. */
|
|||
|
|
function selectService(windows, now) {
|
|||
|
|
if (!Array.isArray(windows) || !windows.length) return null;
|
|||
|
|
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Timing state for a service that has just come into its window. */
|
|||
|
|
function freshService(window) {
|
|||
|
|
return {
|
|||
|
|
id: window ? String(window.id) : '',
|
|||
|
|
phase: window ? 'idle' : 'none',
|
|||
|
|
startedAt: 0,
|
|||
|
|
endedAt: 0,
|
|||
|
|
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
|
|||
|
|
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
|
|||
|
|
cue: '', // the document that started / ended it
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fmtTime(ms) {
|
|||
|
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Move a service's timing on by a clock tick or by a document going live.
|
|||
|
|
* Pure: returns a new state and a one-line description of what happened
|
|||
|
|
* (for the log), or `changed: false`.
|
|||
|
|
*
|
|||
|
|
* @param {object} svc state from freshService(window) / a previous advance()
|
|||
|
|
* @param {object} window from serviceWindows()
|
|||
|
|
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
|
|||
|
|
* @param {number} now ms epoch
|
|||
|
|
* @param {string} [cue] the document that just went live (omit on a plain tick)
|
|||
|
|
* @returns {{ svc: object, changed: boolean, event: string }}
|
|||
|
|
*/
|
|||
|
|
function advance(svc, window, opts, now, cue) {
|
|||
|
|
const o = opts || {};
|
|||
|
|
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
|
|||
|
|
const cueName = String(cue || '').trim();
|
|||
|
|
const same = { svc, changed: false, event: '' };
|
|||
|
|
if (!svc || !window || svc.id !== String(window.id)) return same;
|
|||
|
|
const at = fmtTime(window.startsAt);
|
|||
|
|
|
|||
|
|
if (svc.phase === 'idle') {
|
|||
|
|
const documentMode = o.startMode === 'document' && o.startDocument;
|
|||
|
|
if (documentMode) {
|
|||
|
|
if (cueName && now >= window.cueFrom) {
|
|||
|
|
const score = similarity(cueName, o.startDocument);
|
|||
|
|
if (score >= TRIGGER_THRESHOLD) {
|
|||
|
|
return {
|
|||
|
|
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
|
|||
|
|
changed: true,
|
|||
|
|
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (now >= window.startsAt + grace) {
|
|||
|
|
return {
|
|||
|
|
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
|
|||
|
|
changed: true,
|
|||
|
|
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return same;
|
|||
|
|
}
|
|||
|
|
if (now >= window.startsAt) {
|
|||
|
|
return {
|
|||
|
|
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
|
|||
|
|
changed: true,
|
|||
|
|
event: `started — scheduled ${at}`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return same;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (svc.phase === 'running') {
|
|||
|
|
if (o.endDocument && cueName) {
|
|||
|
|
const score = similarity(cueName, o.endDocument);
|
|||
|
|
if (score >= TRIGGER_THRESHOLD) {
|
|||
|
|
return {
|
|||
|
|
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
|
|||
|
|
changed: true,
|
|||
|
|
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (now >= window.failsafeEndAt) {
|
|||
|
|
const endedAt = Math.min(now, window.failsafeEndAt);
|
|||
|
|
return {
|
|||
|
|
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
|
|||
|
|
changed: true,
|
|||
|
|
event: window.isLast
|
|||
|
|
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
|
|||
|
|
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return same;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return same;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
MINUTE,
|
|||
|
|
DEFAULT_PRE_ROLL_MS,
|
|||
|
|
DEFAULT_START_GRACE_MS,
|
|||
|
|
LAST_SERVICE_OVERRUN_MS,
|
|||
|
|
DEFAULT_SERVICE_LENGTH_MS,
|
|||
|
|
serviceTimes,
|
|||
|
|
serviceWindows,
|
|||
|
|
selectService,
|
|||
|
|
freshService,
|
|||
|
|
advance,
|
|||
|
|
fmtTime,
|
|||
|
|
};
|