PCO Plan 1.6.0: the message document is matched by the plan's own name
The current item followed the live ProPresenter document by title alone, and a sermon's document is named after the sermon — Waters names them "YYMMDD Series Title - Part #" — never after its plan item. So the last song stayed current through the whole message: North Attleboro on 20 Sept spent 65 minutes on one song in two of its three services. A live document that reads like the plan itself — its date code, series title and part — now lands on the plan's message item: an item titled Message, Sermon or Teaching, else the first item under such a header. A date code naming another day is a firm no, so last week's sermon stays unplaced. Position in the ProPresenter playlist breaks a tie between equal names, only while the playlist mirrors the plan item for item, and never places a document on its own. The log now says what it never said: every feed drop and reconnect, which item the message document will land on, what placed each item and how, and a live document that is not in the plan. matching.test.js: 27 checks built from both campuses' real 27 Sept plans and ProPresenter playlists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
441cfe3882
commit
c73d0a3aac
+69
-34
@@ -33,8 +33,12 @@
|
||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||
* this module reads the propresenter-now-next module's own SSE feed over
|
||||
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
||||
* matches the live presentation name to a plan item. Item start/end
|
||||
* timestamps are tracked here, once, so every tile agrees on the runtime.
|
||||
* places the live document on a plan item (matching.js placeDocument): by
|
||||
* name, or — for the sermon, whose document is named after the sermon and
|
||||
* never after its plan item — by reading like the plan itself ("YYMMDD
|
||||
* Series Title - Part #"), which lands it on the plan's message item. Item
|
||||
* start/end timestamps are tracked here, once, so every tile agrees on the
|
||||
* runtime.
|
||||
*
|
||||
* Service timing: a plan's services (9:00, 11:00…) take turns owning the
|
||||
* service clock — the rules live in service-timing.js. A service starts at
|
||||
@@ -52,7 +56,7 @@ const http = require('http');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { MATCH_THRESHOLD, similarity } = require('./matching');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||
const {
|
||||
MINUTE,
|
||||
serviceTimes,
|
||||
@@ -434,6 +438,8 @@ module.exports = {
|
||||
let inFlight = null;
|
||||
let lastFrame = '';
|
||||
let lastTimersSig = '';
|
||||
let lastMessageItemId = null;
|
||||
let lastMatchError = '';
|
||||
|
||||
/* — broadcasting — */
|
||||
|
||||
@@ -582,6 +588,7 @@ module.exports = {
|
||||
}
|
||||
state.plan = plan;
|
||||
state.items = items;
|
||||
noteMessageItem(items);
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -596,6 +603,17 @@ module.exports = {
|
||||
syncServiceSafe(Date.now(), null);
|
||||
}
|
||||
|
||||
/** Say, once per plan, which item a document named like the plan will land on. */
|
||||
function noteMessageItem(items) {
|
||||
const msg = messageItemOf(items);
|
||||
const id = msg ? msg.id : '';
|
||||
if (id === lastMessageItemId) return;
|
||||
lastMessageItemId = id;
|
||||
log(msg
|
||||
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
|
||||
: 'no message item in this plan — a document named like the plan has nowhere to land');
|
||||
}
|
||||
|
||||
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
||||
|
||||
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
||||
@@ -996,36 +1014,29 @@ module.exports = {
|
||||
};
|
||||
lastLiveName = keep.activeName || '';
|
||||
if (follow && keep.activeName) {
|
||||
const hit = matchItem(keep.activeName);
|
||||
const hit = matchItem(keep.activeName, hintFrom(lastView));
|
||||
state.live.matched = Boolean(hit);
|
||||
if (hit) {
|
||||
state.live.currentScore = hit.score;
|
||||
startItem(hit.item.id);
|
||||
startItem(hit.item.id, keep.activeName, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
|
||||
function matchItem(name) {
|
||||
const items = state.items.filter((it) => it.itemType !== 'header');
|
||||
if (!items.length) return null;
|
||||
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
|
||||
let best = null;
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i];
|
||||
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
||||
if (score < MATCH_THRESHOLD) continue;
|
||||
// Distance ahead of the current item (wrapping so items behind rank last).
|
||||
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
||||
if (
|
||||
!best
|
||||
|| score > best.score + 0.001
|
||||
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
|
||||
) {
|
||||
best = { item, score, dist };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
/**
|
||||
* The plan item a live document stands for — { item, score, via } or
|
||||
* null. The rules live in matching.js (placeDocument): by name, then the
|
||||
* message rule for a document named like the plan itself; the playlist
|
||||
* position only breaks ties, and only while the playlist mirrors the plan.
|
||||
*/
|
||||
function matchItem(name, hint) {
|
||||
return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
|
||||
}
|
||||
|
||||
/** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
|
||||
function hintFrom(view) {
|
||||
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
|
||||
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
|
||||
}
|
||||
|
||||
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */
|
||||
@@ -1077,6 +1088,7 @@ module.exports = {
|
||||
function applyLiveView(view) {
|
||||
lastView = view && typeof view === 'object' ? view : null;
|
||||
const live = state.live;
|
||||
if (!live.available) log('following the ProPresenter module feed');
|
||||
live.available = true;
|
||||
live.reason = '';
|
||||
live.ppEnabled = Boolean(view?.enabled);
|
||||
@@ -1095,23 +1107,40 @@ module.exports = {
|
||||
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
|
||||
return;
|
||||
}
|
||||
lastLiveName = name;
|
||||
rememberDocument(name);
|
||||
if (follow) {
|
||||
const hit = matchItem(name);
|
||||
live.matched = Boolean(hit);
|
||||
if (hit) {
|
||||
live.currentScore = hit.score;
|
||||
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
||||
try {
|
||||
const hit = matchItem(name, hintFrom(view));
|
||||
live.matched = Boolean(hit);
|
||||
if (hit) {
|
||||
live.currentScore = hit.score;
|
||||
if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
|
||||
} else {
|
||||
// Said in the log, because on the wall it looks like the plan
|
||||
// stopped following: the highlight stays where it was.
|
||||
const staying = state.items.find((it) => it.id === live.currentItemId);
|
||||
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
|
||||
}
|
||||
lastMatchError = '';
|
||||
} catch (err) {
|
||||
// Never silent: a matcher that throws would otherwise leave the
|
||||
// highlight stuck with nothing in the log to say why.
|
||||
const msg = err?.message || String(err);
|
||||
if (msg !== lastMatchError) {
|
||||
lastMatchError = msg;
|
||||
log(`matching "${name}" failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastLiveName = name;
|
||||
// The same document is also a possible service start / end cue.
|
||||
syncServiceSafe(Date.now(), name);
|
||||
broadcast();
|
||||
broadcastTimers();
|
||||
}
|
||||
|
||||
function startItem(itemId) {
|
||||
/** Make an item current; `liveName` and `hit` say what placed it, for the log. */
|
||||
function startItem(itemId, liveName, hit) {
|
||||
const live = state.live;
|
||||
const now = Date.now();
|
||||
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||||
@@ -1120,12 +1149,18 @@ module.exports = {
|
||||
live.currentItemId = itemId;
|
||||
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||||
const item = state.items.find((it) => it.id === itemId);
|
||||
log(`live item → ${item?.title || itemId}`);
|
||||
const how = hit
|
||||
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
|
||||
: '';
|
||||
log(`live item → ${item?.title || itemId}${how}`);
|
||||
}
|
||||
|
||||
function nowNextUnavailable(reason, retryMs) {
|
||||
const live = state.live;
|
||||
if (live.available || live.reason !== reason) {
|
||||
// Every drop is in the log: a highlight that stopped moving is
|
||||
// otherwise indistinguishable from a feed that went away.
|
||||
log(`ProPresenter module feed unavailable — ${reason}`);
|
||||
live.available = false;
|
||||
live.reason = reason;
|
||||
live.ppReachable = false;
|
||||
|
||||
Reference in New Issue
Block a user