Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73d0a3aac | ||
|
|
441cfe3882 |
@@ -328,7 +328,14 @@ below. Nothing is installed without someone clicking.
|
||||
description and item notes. When the ProPresenter module is running it
|
||||
follows that module's live item, highlights the matching plan item, dims
|
||||
what's done and shows the elapsed vs planned runtime and how far
|
||||
ahead/behind the service is. Admin config is the connection: Planning
|
||||
ahead/behind the service is. Items are matched by name — a document named
|
||||
like the Planning Center item or its song, tags and all. The message is
|
||||
the exception: a sermon's document is named after the sermon, so a live
|
||||
document that reads like the plan itself — its date code, series title
|
||||
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
||||
message and lands on the plan's message item (an item titled Message,
|
||||
Sermon or Teaching, else the first item under such a header). Admin
|
||||
config is the connection: Planning
|
||||
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
||||
token), the service type (a list grouped by your Planning Center folders)
|
||||
and an optional pinned plan. Everything about the display is per tile
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
/**
|
||||
* Title matching for the PCO Plan module — the one answer to "is this
|
||||
* ProPresenter document that Planning Center thing?", used twice:
|
||||
* ProPresenter document that Planning Center thing?", used three ways:
|
||||
*
|
||||
* - mapping the document ProPresenter has live to a plan item (the
|
||||
* highlight and the item runtimes), and
|
||||
* highlight and the item runtimes) — placeDocument(), below,
|
||||
* - recognising the message document, which is named after the sermon
|
||||
* and never after its plan item — the message rule, below, and
|
||||
* - recognising the documents an admin picked as the service start / end
|
||||
* cues (see service-timing.js).
|
||||
*
|
||||
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
|
||||
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||
}
|
||||
|
||||
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
|
||||
/* ── the message rule ──────────────────────────────────────────────────
|
||||
A sermon's ProPresenter document is named after the sermon, never after
|
||||
the plan item that stands for it ("Message", "Resi Message"), so no name
|
||||
comparison can place it — and the previous song stayed "current" through
|
||||
a whole message. What the document's name does carry is the plan's own
|
||||
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
|
||||
date, its series title and its title. A live document that reads like
|
||||
the plan is the plan's message, and lands on the plan's message item,
|
||||
however that item happens to be titled. */
|
||||
|
||||
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
|
||||
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
|
||||
/** Undated, the name must read like "Series Title Part #" at least this closely… */
|
||||
const MESSAGE_THRESHOLD = 0.75;
|
||||
/** …dated with this plan's date, it need only resemble the series or the title. */
|
||||
const DATED_MESSAGE_THRESHOLD = 0.6;
|
||||
|
||||
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
|
||||
function leadingDateCode(raw) {
|
||||
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
|
||||
if (!m) return null;
|
||||
const code = m[1];
|
||||
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
|
||||
const mo = Number(code.slice(-4, -2));
|
||||
const d = Number(code.slice(-2));
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||
return { y, m: mo, d };
|
||||
}
|
||||
|
||||
/** The local calendar day of an epoch-ms instant, in the same shape. */
|
||||
function dayOf(ms) {
|
||||
const t = new Date(Number(ms));
|
||||
if (!Number.isFinite(t.getTime())) return null;
|
||||
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this live document read like the plan itself — "260927 Where is Your
|
||||
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
|
||||
* 27 Sept 2026? Returns { score, dated } or null.
|
||||
*
|
||||
* A date code naming another day is a firm no: last week's sermon is not
|
||||
* this plan's message. A date code naming the plan's day is strong evidence,
|
||||
* so the rest need only resemble the series or the title. Without a date
|
||||
* the name must read like series + title together — and carry the title
|
||||
* when the plan has both, so a series bumper ("Where is Your Faith?") is
|
||||
* not taken for the sermon.
|
||||
*/
|
||||
function messageDocumentScore(name, plan) {
|
||||
const series = String(plan?.seriesTitle || '').trim();
|
||||
const title = String(plan?.title || '').trim();
|
||||
if (!series && !title) return null;
|
||||
const code = leadingDateCode(name);
|
||||
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
|
||||
if (code && planDay && !sameDay(code, planDay)) return null;
|
||||
const dated = Boolean(code && planDay);
|
||||
const full = [series, title].filter(Boolean).join(' ');
|
||||
const fullScore = similarity(name, full);
|
||||
if (dated) {
|
||||
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
|
||||
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
|
||||
}
|
||||
if (fullScore < MESSAGE_THRESHOLD) return null;
|
||||
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
|
||||
return { score: fullScore, dated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan item that stands for the message: an item titled with the word
|
||||
* ("Message", "Resi Message" — a bare "Message" first), else the first item
|
||||
* under a header so titled (a "Sermon" section whose one item is the sermon's
|
||||
* own title). Null when the plan has no such slot.
|
||||
*/
|
||||
function messageItemOf(items) {
|
||||
let best = null;
|
||||
let inSection = false;
|
||||
for (const it of Array.isArray(items) ? items : []) {
|
||||
if (!it) continue;
|
||||
const title = String(it.title || '');
|
||||
if (it.itemType === 'header') {
|
||||
inSection = MESSAGE_WORDS.test(title);
|
||||
continue;
|
||||
}
|
||||
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
|
||||
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
|
||||
if (rank === 3) break;
|
||||
}
|
||||
return best ? best.item : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
|
||||
* builds a Planning Center playlist that way — every plan item, headers
|
||||
* included, each flagged as PCO — until an operator adds something of their
|
||||
* own (a song tag, say), which shifts every position after it. Same length,
|
||||
* headers in the same places and every item PCO-flagged is the test.
|
||||
*/
|
||||
function playlistMirrorsPlan(ppItems, items) {
|
||||
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
|
||||
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
|
||||
}
|
||||
|
||||
/** Best plan item by title, the ones ahead of the current item winning ties. */
|
||||
function bestByName(name, items, all, ctx) {
|
||||
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
|
||||
const hint = ctx?.hint;
|
||||
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
|
||||
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;
|
||||
const atPosition = Boolean(wanted && item.id === wanted.id);
|
||||
const candidate = { item, score, dist, atPosition };
|
||||
if (!best || score > best.score + 0.001) {
|
||||
best = candidate;
|
||||
continue;
|
||||
}
|
||||
if (Math.abs(score - best.score) > 0.001) continue;
|
||||
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
|
||||
}
|
||||
return best ? { item: best.item, score: best.score, via: 'name' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a live ProPresenter document sits in the plan: { item, score, via }
|
||||
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
|
||||
* is { index, mirror } — the document's position in the ProPresenter
|
||||
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
|
||||
*
|
||||
* By name first: the item (or song) title most like the document's name,
|
||||
* the ones ahead of the current item winning ties — a near miss lands on the
|
||||
* right row. Then the message rule: a document that reads like the plan
|
||||
* itself lands on the plan's message item. It overrides a name hit that is
|
||||
* anything short of identical — a song called "Faith" must not catch
|
||||
* "260927 Where is Your Faith? - Part 6" — outright when the document is
|
||||
* dated with the plan's day, and when it reads better otherwise. Position
|
||||
* is never a match on its own: it only breaks a tie between equally good
|
||||
* names, and only while the playlist mirrors the plan.
|
||||
*/
|
||||
function placeDocument(name, ctx) {
|
||||
const all = Array.isArray(ctx?.items) ? ctx.items : [];
|
||||
const items = all.filter((it) => it && it.itemType !== 'header');
|
||||
if (!items.length) return null;
|
||||
const byName = bestByName(name, items, all, ctx);
|
||||
const asMessage = messageDocumentScore(name, ctx?.plan);
|
||||
const messageItem = asMessage ? messageItemOf(all) : null;
|
||||
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
|
||||
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MATCH_THRESHOLD,
|
||||
TRIGGER_THRESHOLD,
|
||||
MESSAGE_THRESHOLD,
|
||||
cleanTitle,
|
||||
normalize,
|
||||
similarity,
|
||||
isCue,
|
||||
leadingDateCode,
|
||||
messageDocumentScore,
|
||||
messageItemOf,
|
||||
playlistMirrorsPlan,
|
||||
placeDocument,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the PCO Plan module's document matching — placing what
|
||||
* ProPresenter has live onto a plan item, and the message rule.
|
||||
*
|
||||
* node modules/pco-plan/matching.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. The two
|
||||
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
|
||||
* as they stood on 22 Sept, so the expectations read like a Sunday would.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
MESSAGE_THRESHOLD,
|
||||
leadingDateCode,
|
||||
messageDocumentScore,
|
||||
messageItemOf,
|
||||
playlistMirrorsPlan,
|
||||
placeDocument,
|
||||
} = require('./matching');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(` FAIL ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
|
||||
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
|
||||
|
||||
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
|
||||
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
|
||||
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
|
||||
|
||||
// North Attleboro — the message is an item called "Message", linked in
|
||||
// ProPresenter to the bumper video; the sermon slides are their own document.
|
||||
const NA = [
|
||||
header('h0', 'Doors Open'),
|
||||
item('1', 'Preservice'),
|
||||
item('2', '5 Minute Countdown', 'media'),
|
||||
item('3', 'MC Announcements', 'media'),
|
||||
header('h4', 'Worship'),
|
||||
item('5', 'Call To Worship', 'media'),
|
||||
item('6', 'Alleluia', 'song', 'Alleluia'),
|
||||
item('7', 'What a God', 'song', 'What a God'),
|
||||
item('8', 'Baptisms', 'media'),
|
||||
item('9', 'Promised Land', 'song', 'Promised Land'),
|
||||
item('10', 'Be Praised', 'song', 'Be Praised'),
|
||||
header('h11', 'Message'),
|
||||
item('12', 'Message'),
|
||||
item('13', 'Standing Salvation Moment', 'media'),
|
||||
header('h14', 'Closing'),
|
||||
item('15', 'Postservice', 'song', '-Postservice'),
|
||||
];
|
||||
const NA_PP = [
|
||||
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
|
||||
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
|
||||
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
|
||||
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
|
||||
pp('Closing', 'header'), pp('Postservice'),
|
||||
];
|
||||
|
||||
// Apollo Beach — the message is the media item "Resi Message".
|
||||
const AB = [
|
||||
item('0', 'Pre Service Meeting', 'media'),
|
||||
header('h1', 'Doors Open'),
|
||||
item('2', 'Pre Service', 'media'),
|
||||
item('3', '5 Minute Countdown', 'media'),
|
||||
item('4', 'MC Announcements', 'media'),
|
||||
header('h5', 'Worship'),
|
||||
item('6', 'Call To Worship', 'media'),
|
||||
item('7', 'Alleluia', 'song', 'Alleluia'),
|
||||
item('8', 'What a God', 'song', 'What a God'),
|
||||
item('9', 'The Blood', 'song', 'The Blood'),
|
||||
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
|
||||
header('h11', 'Message'),
|
||||
item('12', 'Resi Message', 'media'),
|
||||
item('13', 'Standing Salvation Moment', 'media'),
|
||||
header('h14', 'Closing'),
|
||||
item('15', 'Post Service', 'media'),
|
||||
];
|
||||
|
||||
const SERMON = '260927 Where is Your Faith? - Part 6';
|
||||
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
|
||||
const titleOf = (hit) => (hit ? hit.item.title : null);
|
||||
|
||||
console.log('date codes');
|
||||
|
||||
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
|
||||
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
|
||||
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
|
||||
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
|
||||
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
|
||||
assert.strictEqual(leadingDateCode('261340 no such month'), null);
|
||||
assert.strictEqual(leadingDateCode('Alleluia'), null);
|
||||
assert.strictEqual(leadingDateCode(''), null);
|
||||
});
|
||||
|
||||
console.log('the message rule');
|
||||
|
||||
test('a document named like the plan, dated with its day, is the message', () => {
|
||||
const hit = messageDocumentScore(SERMON, PLAN);
|
||||
assert.ok(hit, 'recognised');
|
||||
assert.strictEqual(hit.dated, true);
|
||||
assert.strictEqual(hit.score, 1);
|
||||
});
|
||||
|
||||
test("last week's document is not this plan's message", () => {
|
||||
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
|
||||
});
|
||||
|
||||
test('undated, series and part together still read as the message', () => {
|
||||
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
|
||||
assert.ok(plain && plain.dated === false && plain.score === 1);
|
||||
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
|
||||
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
|
||||
});
|
||||
|
||||
test('a series bumper is not the sermon', () => {
|
||||
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
|
||||
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
|
||||
});
|
||||
|
||||
test("dated with the plan's day, the part alone is enough", () => {
|
||||
const hit = messageDocumentScore('260927 Part 6', PLAN);
|
||||
assert.ok(hit && hit.dated);
|
||||
});
|
||||
|
||||
test('a dated document about something else is not the message', () => {
|
||||
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
|
||||
});
|
||||
|
||||
test('songs are not the message', () => {
|
||||
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
|
||||
});
|
||||
|
||||
test('a plan with neither series nor title has no message document', () => {
|
||||
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
|
||||
assert.strictEqual(messageDocumentScore(SERMON, null), null);
|
||||
});
|
||||
|
||||
test('a plan without a date still recognises its own name', () => {
|
||||
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
|
||||
assert.ok(hit && hit.dated === false && hit.score === 1);
|
||||
});
|
||||
|
||||
console.log('the message item');
|
||||
|
||||
test('an item called Message is the message item; so is Resi Message', () => {
|
||||
assert.strictEqual(messageItemOf(NA).id, '12');
|
||||
assert.strictEqual(messageItemOf(AB).id, '12');
|
||||
});
|
||||
|
||||
test('a bare "Message" beats an item that only contains the word', () => {
|
||||
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
|
||||
assert.strictEqual(messageItemOf(withBumper).id, '12');
|
||||
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
|
||||
});
|
||||
|
||||
test('under a Sermon header, the first item is the message whatever it is called', () => {
|
||||
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
|
||||
assert.strictEqual(messageItemOf(plan).id, '2');
|
||||
});
|
||||
|
||||
test('a plan without a message slot has none', () => {
|
||||
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
|
||||
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
|
||||
assert.strictEqual(messageItemOf([]), null);
|
||||
assert.strictEqual(messageItemOf(null), null);
|
||||
});
|
||||
|
||||
console.log('the playlist mirror');
|
||||
|
||||
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
|
||||
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
|
||||
});
|
||||
|
||||
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
|
||||
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
|
||||
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
|
||||
const shifted = [...NA_PP];
|
||||
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
|
||||
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
|
||||
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
|
||||
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
|
||||
assert.strictEqual(playlistMirrorsPlan([], []), false);
|
||||
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
|
||||
});
|
||||
|
||||
console.log('placing what is live');
|
||||
|
||||
test('songs and media land by name, tags and all', () => {
|
||||
const god = place('What a God - [ Full ]', NA);
|
||||
assert.strictEqual(titleOf(god), 'What a God');
|
||||
assert.strictEqual(god.via, 'name');
|
||||
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
|
||||
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
|
||||
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
|
||||
});
|
||||
|
||||
test('the sermon document lands on the message item at both campuses', () => {
|
||||
const na = place(SERMON, NA);
|
||||
assert.strictEqual(titleOf(na), 'Message');
|
||||
assert.strictEqual(na.via, 'message document, dated');
|
||||
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
|
||||
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
|
||||
});
|
||||
|
||||
test('the bumper lands on the message item too, by name', () => {
|
||||
const hit = place('Message Bumper', NA);
|
||||
assert.strictEqual(titleOf(hit), 'Message');
|
||||
assert.strictEqual(hit.via, 'name');
|
||||
});
|
||||
|
||||
test('a song called Faith does not catch the sermon', () => {
|
||||
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
|
||||
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
|
||||
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
|
||||
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
|
||||
});
|
||||
|
||||
test('an item titled exactly like the document wins over the message slot', () => {
|
||||
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
|
||||
assert.strictEqual(place(SERMON, titled).item.id, 's');
|
||||
});
|
||||
|
||||
test('a plan without a message slot leaves the sermon document unplaced', () => {
|
||||
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
|
||||
assert.strictEqual(place(SERMON, noSlot), null);
|
||||
});
|
||||
|
||||
test("an unplanned document, or last week's sermon, lands nowhere", () => {
|
||||
assert.strictEqual(place('Offering Slide', NA), null);
|
||||
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
|
||||
});
|
||||
|
||||
test('nothing to place on an empty plan', () => {
|
||||
assert.strictEqual(place(SERMON, []), null);
|
||||
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
|
||||
});
|
||||
|
||||
// The same song twice: a reprise after "What a God".
|
||||
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
|
||||
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
|
||||
|
||||
test('a tie between equal names goes to the one ahead of the current item', () => {
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
|
||||
});
|
||||
|
||||
test('position breaks a tie only while the playlist mirrors the plan', () => {
|
||||
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
|
||||
assert.strictEqual(mirror, true);
|
||||
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
|
||||
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
|
||||
});
|
||||
|
||||
test('position never places a document on its own', () => {
|
||||
const mirror = playlistMirrorsPlan(NA_PP, NA);
|
||||
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
|
||||
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service from Planning Center Services",
|
||||
"provides": [
|
||||
|
||||
+65
-30
@@ -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 };
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
return best;
|
||||
|
||||
/** 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);
|
||||
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);
|
||||
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