New module modules/pco-plan: reads the current plan for a chosen Planning Center Services service type (PAT or OAuth token), streams items with start times, lengths, keys, descriptions and item notes to tiles (every detail a per-tile show/hide setting), and follows the propresenter-now-next module's live item over loopback SSE to highlight the current plan item and track elapsed vs planned runtime. Ships its own setup page (/modules/pco-plan/setup.html) to browse folders and pick the service type through the shell admin API, plus tools/pco-mock.js for development. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
826 lines
31 KiB
JavaScript
826 lines
31 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* PCO Plan — server part.
|
||
*
|
||
* Owns the one Planning Center Services connection, keeps the current plan
|
||
* (order of service) for the configured service type in memory, and fans it
|
||
* out to every tile over SSE. Browsers never talk to Planning Center.
|
||
*
|
||
* GET /state → { state, now } (instant paint for new tiles)
|
||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||
* POST /refresh → re-read the plan now
|
||
* GET /browse → folders + service types, for the setup page
|
||
* GET /preview?serviceTypeId=… → which plan the module would pick
|
||
*
|
||
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
||
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
|
||
* bearer token. Responses are JSON:API — `data`, `included`, `links.next`.
|
||
* GET /folders, /service_types (setup browsing)
|
||
* GET /service_types/{st}/plans?filter=future|past (pick the plan)
|
||
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||
* GET …/plans/{id}/plan_times (service start times)
|
||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||
*
|
||
* Phase 2 — 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) 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.
|
||
*
|
||
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
|
||
* tools/pco-mock.js for development.
|
||
*/
|
||
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||
const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||
const SERVICES = '/services/v2';
|
||
|
||
/** Abort a hung Planning Center request well before the poll cadence. */
|
||
const REQUEST_TIMEOUT_MS = 12000;
|
||
/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */
|
||
const MIN_POLL_S = 10;
|
||
/** Backoff after a failed plan read. */
|
||
const RETRY_MS = 15000;
|
||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||
const HEARTBEAT_MS = 15000;
|
||
/** The Now/Next module whose feed we follow. */
|
||
const NOW_NEXT_ID = 'propresenter-now-next';
|
||
const NOW_NEXT_RETRY_MS = 3000;
|
||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||
/** Minimum name-similarity to call a ProPresenter document a plan item. */
|
||
const MATCH_THRESHOLD = 0.6;
|
||
|
||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||
let current = null;
|
||
|
||
/* ── small helpers ──────────────────────────────────────────────────── */
|
||
|
||
function clearedState() {
|
||
return {
|
||
configured: false, // credentials present
|
||
serviceTypeSet: false, // a service type is chosen
|
||
reachable: false, // last Planning Center read succeeded
|
||
lastError: '',
|
||
lastFetched: 0,
|
||
apiBase: API_BASE,
|
||
serviceType: { id: '', name: '' },
|
||
plan: null,
|
||
items: [],
|
||
live: clearedLive(),
|
||
};
|
||
}
|
||
|
||
function clearedLive() {
|
||
return {
|
||
following: false, // admin switch on
|
||
available: false, // Now/Next feed reachable
|
||
reason: '', // why not available
|
||
ppEnabled: false,
|
||
ppReachable: false,
|
||
activeName: '', // what ProPresenter has live right now
|
||
matched: false, // activeName maps to a plan item
|
||
currentItemId: '',
|
||
currentScore: 0,
|
||
serviceStartedAt: 0, // first matched item start, ms epoch
|
||
history: {}, // itemId → { startedAt, endedAt }
|
||
};
|
||
}
|
||
|
||
/** The shell's port, so the loopback Now/Next feed lands on this ProdDash. */
|
||
function shellPort() {
|
||
const env = Number(process.env.PORT);
|
||
if (env) return env;
|
||
try {
|
||
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'proddash.json'), 'utf8'));
|
||
if (Number(cfg.port)) return Number(cfg.port);
|
||
} catch { /* fall through */ }
|
||
return 24500;
|
||
}
|
||
|
||
function localYmd(d) {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
|
||
function cleanTitle(raw) {
|
||
const original = String(raw || '').trim();
|
||
let s = original;
|
||
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
|
||
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
|
||
s = s.replace(/\s[-–—]+\s/g, ' ');
|
||
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
|
||
s = s.replace(/\s{2,}/g, ' ').trim();
|
||
return s || original;
|
||
}
|
||
|
||
function normalize(raw) {
|
||
return cleanTitle(raw)
|
||
.toLowerCase()
|
||
.replace(/&/g, ' and ')
|
||
.replace(/[’'`]/g, '')
|
||
.replace(/[^a-z0-9]+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
|
||
function tokens(norm) {
|
||
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
|
||
}
|
||
|
||
/** 0..1 similarity between two titles. */
|
||
function similarity(a, b) {
|
||
const na = normalize(a);
|
||
const nb = normalize(b);
|
||
if (!na || !nb) return 0;
|
||
if (na === nb) return 1;
|
||
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
|
||
const ta = tokens(na);
|
||
const tb = tokens(nb);
|
||
if (!ta.size || !tb.size) return 0;
|
||
let common = 0;
|
||
for (const t of ta) if (tb.has(t)) common += 1;
|
||
return (2 * common) / (ta.size + tb.size); // Dice coefficient
|
||
}
|
||
|
||
/* ── Planning Center client ─────────────────────────────────────────── */
|
||
|
||
function createPcoClient(config) {
|
||
const token = String(config.accessToken || '').trim();
|
||
const appId = String(config.appId || '').trim();
|
||
const secret = String(config.secret || '');
|
||
let auth = '';
|
||
if (token) auth = `Bearer ${token}`;
|
||
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||
|
||
async function get(target, params) {
|
||
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||
let response;
|
||
try {
|
||
response = await fetch(url, {
|
||
headers: { Authorization: auth, Accept: 'application/json' },
|
||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||
});
|
||
} catch (err) {
|
||
const e = new Error(`Planning Center unreachable: ${err?.message || err}`);
|
||
e.network = true;
|
||
throw e;
|
||
}
|
||
if (!response.ok) {
|
||
let detail = '';
|
||
try {
|
||
const body = await response.json();
|
||
detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || '';
|
||
} catch { /* no JSON body */ }
|
||
const e = new Error(
|
||
response.status === 401
|
||
? 'Planning Center rejected the credentials (401)'
|
||
: `Planning Center ${response.status}${detail ? `: ${detail}` : ''}`
|
||
);
|
||
e.status = response.status;
|
||
if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10;
|
||
throw e;
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
/** Follow JSON:API `links.next` until the collection is complete. */
|
||
async function getAll(target, params) {
|
||
const data = [];
|
||
const included = [];
|
||
let url = target;
|
||
let query = { per_page: 100, ...(params || {}) };
|
||
for (let page = 0; page < 30; page += 1) {
|
||
const body = await get(url, query);
|
||
if (Array.isArray(body?.data)) data.push(...body.data);
|
||
if (Array.isArray(body?.included)) included.push(...body.included);
|
||
const next = body?.links?.next;
|
||
if (!next) break;
|
||
url = next;
|
||
query = {}; // `next` already carries the query string
|
||
}
|
||
return { data, included };
|
||
}
|
||
|
||
return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll };
|
||
}
|
||
|
||
/* ── plan shaping ───────────────────────────────────────────────────── */
|
||
|
||
function pickServiceTime(planTimes, nowMs) {
|
||
const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt);
|
||
const pool = services.length ? services : planTimes.filter((t) => t.startsAt);
|
||
if (!pool.length) return null;
|
||
pool.sort((a, b) => a.startsAt - b.startsAt);
|
||
// The service that is running or up next; after the last one, the last one.
|
||
const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs);
|
||
return upcoming || pool[pool.length - 1];
|
||
}
|
||
|
||
/** Scheduled start of every item from the chosen service start time. */
|
||
function scheduleItems(items, serviceStartMs) {
|
||
for (const item of items) item.startsAt = 0;
|
||
if (!serviceStartMs) return;
|
||
let t = serviceStartMs;
|
||
for (const item of items) {
|
||
if (item.servicePosition !== 'during') continue;
|
||
item.startsAt = t;
|
||
t += (item.length || 0) * 1000;
|
||
}
|
||
// Post-service items run on from the end of the service…
|
||
for (const item of items) {
|
||
if (item.servicePosition !== 'post') continue;
|
||
item.startsAt = t;
|
||
t += (item.length || 0) * 1000;
|
||
}
|
||
// …and pre-service items are counted backwards to end at the service start.
|
||
let back = serviceStartMs;
|
||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||
const item = items[i];
|
||
if (item.servicePosition !== 'pre') continue;
|
||
back -= (item.length || 0) * 1000;
|
||
item.startsAt = back;
|
||
}
|
||
}
|
||
|
||
function shapeItems(itemsBody) {
|
||
const included = new Map();
|
||
for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||
const rel = (item, name) => item?.relationships?.[name]?.data;
|
||
const items = (itemsBody.data || []).map((it) => {
|
||
const a = it.attributes || {};
|
||
const songRef = rel(it, 'song');
|
||
const song = songRef ? included.get(`Song/${songRef.id}`) : null;
|
||
const arrRef = rel(it, 'arrangement');
|
||
const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null;
|
||
const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : [];
|
||
const notes = noteRefs
|
||
.map((ref) => included.get(`ItemNote/${ref.id}`))
|
||
.filter(Boolean)
|
||
.map((n) => ({
|
||
category: String(n.attributes?.category_name || ''),
|
||
content: String(n.attributes?.content || '').trim(),
|
||
}))
|
||
.filter((n) => n.content);
|
||
return {
|
||
id: String(it.id),
|
||
sequence: Number(a.sequence) || 0,
|
||
title: String(a.title || '').trim(),
|
||
itemType: String(a.item_type || 'item'),
|
||
length: Number(a.length) || 0,
|
||
servicePosition: String(a.service_position || 'during'),
|
||
description: String(a.description || '').trim(),
|
||
keyName: String(a.key_name || '').trim(),
|
||
song: song
|
||
? {
|
||
title: String(song.attributes?.title || ''),
|
||
author: String(song.attributes?.author || ''),
|
||
ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '',
|
||
}
|
||
: null,
|
||
arrangement: arrangement ? String(arrangement.attributes?.name || '') : '',
|
||
notes,
|
||
startsAt: 0,
|
||
};
|
||
});
|
||
items.sort((x, y) => x.sequence - y.sequence);
|
||
return items;
|
||
}
|
||
|
||
function shapePlan(planRec, serviceTypeId, planTimes) {
|
||
const a = planRec?.attributes || {};
|
||
return {
|
||
id: String(planRec?.id || ''),
|
||
serviceTypeId: String(serviceTypeId),
|
||
title: String(a.title || '').trim(),
|
||
seriesTitle: String(a.series_title || '').trim(),
|
||
dates: String(a.dates || '').trim(),
|
||
shortDates: String(a.short_dates || '').trim(),
|
||
sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0,
|
||
totalLength: Number(a.total_length) || 0,
|
||
itemsCount: Number(a.items_count) || 0,
|
||
url: String(a.planning_center_url || ''),
|
||
planTimes,
|
||
serviceStartsAt: 0,
|
||
serviceEndsAt: 0,
|
||
serviceName: '',
|
||
};
|
||
}
|
||
|
||
/* ── the module ─────────────────────────────────────────────────────── */
|
||
|
||
module.exports = {
|
||
init({ config, log }) {
|
||
const streams = new Set();
|
||
const pco = createPcoClient(config);
|
||
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||
const pinnedPlanId = String(config.planId || '').trim();
|
||
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
||
const follow = config.followProPresenter !== false;
|
||
|
||
let state = clearedState();
|
||
state.configured = pco.configured;
|
||
state.serviceTypeSet = Boolean(serviceTypeId);
|
||
state.serviceType = { id: serviceTypeId, name: '' };
|
||
state.live.following = follow;
|
||
|
||
let stopped = false;
|
||
let pollTimer = null;
|
||
let inFlight = null;
|
||
let lastFrame = '';
|
||
|
||
/* — broadcasting — */
|
||
|
||
function frameOf() {
|
||
return JSON.stringify(state);
|
||
}
|
||
|
||
function broadcast(force) {
|
||
const frame = frameOf();
|
||
if (!force && frame === lastFrame) return;
|
||
lastFrame = frame;
|
||
if (!streams.size) return;
|
||
const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`;
|
||
for (const res of streams) {
|
||
try {
|
||
res.write(payload);
|
||
} catch {
|
||
streams.delete(res);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* — plan reading — */
|
||
|
||
function planPath(planId) {
|
||
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||
}
|
||
|
||
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
||
async function choosePlanId(stId) {
|
||
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
||
const [future, past] = await Promise.all([
|
||
pco.get(base, { filter: 'future', order: 'sort_date', per_page: 5 }),
|
||
pco.get(base, { filter: 'past', order: '-sort_date', per_page: 3 }),
|
||
]);
|
||
const byId = new Map();
|
||
for (const rec of [...(future?.data || []), ...(past?.data || [])]) {
|
||
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
||
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
||
byId.set(String(rec.id), { rec, sortDate });
|
||
}
|
||
const plans = Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
||
if (!plans.length) return null;
|
||
const now = Date.now();
|
||
const today = localYmd(new Date(now));
|
||
const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today);
|
||
if (todays) return todays.rec;
|
||
const next = plans.find((p) => p.sortDate > now);
|
||
if (next) return next.rec;
|
||
return plans[plans.length - 1].rec;
|
||
}
|
||
|
||
async function readPlanTimes(planId) {
|
||
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
||
return (body.data || [])
|
||
.map((t) => ({
|
||
id: String(t.id),
|
||
name: String(t.attributes?.name || '').trim(),
|
||
timeType: String(t.attributes?.time_type || 'service'),
|
||
startsAt: Date.parse(t.attributes?.starts_at || '') || 0,
|
||
endsAt: Date.parse(t.attributes?.ends_at || '') || 0,
|
||
}))
|
||
.filter((t) => t.startsAt)
|
||
.sort((x, y) => x.startsAt - y.startsAt);
|
||
}
|
||
|
||
async function readItems(planId) {
|
||
const target = `${planPath(planId)}/items`;
|
||
try {
|
||
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
||
} catch (err) {
|
||
// An account/API that refuses one of the includes still has items.
|
||
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||
log(`items include rejected (${err.message}) — reading items without includes`);
|
||
return shapeItems(await pco.getAll(target));
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function readPlan() {
|
||
if (!pco.configured || !serviceTypeId) return;
|
||
let planRec = null;
|
||
if (pinnedPlanId) {
|
||
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
|
||
} else {
|
||
const chosen = await choosePlanId(serviceTypeId);
|
||
// Re-read the chosen plan by id: the list endpoint's attributes are the
|
||
// same, but this keeps one code path and picks up edits immediately.
|
||
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
|
||
}
|
||
if (!state.serviceType.name) {
|
||
try {
|
||
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
|
||
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
|
||
} catch (err) {
|
||
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
|
||
throw err;
|
||
}
|
||
}
|
||
if (!planRec) {
|
||
state.plan = null;
|
||
state.items = [];
|
||
state.reachable = true;
|
||
state.lastError = '';
|
||
state.lastFetched = Date.now();
|
||
return;
|
||
}
|
||
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
||
const service = pickServiceTime(planTimes, Date.now());
|
||
if (service) {
|
||
plan.serviceStartsAt = service.startsAt;
|
||
plan.serviceEndsAt = service.endsAt;
|
||
plan.serviceName = service.name;
|
||
}
|
||
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
|
||
if (state.plan?.id !== plan.id) {
|
||
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`);
|
||
else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim());
|
||
resetLiveTracking();
|
||
}
|
||
state.plan = plan;
|
||
state.items = items;
|
||
state.reachable = true;
|
||
state.lastError = '';
|
||
state.lastFetched = Date.now();
|
||
}
|
||
|
||
function schedulePoll(ms) {
|
||
if (stopped) return;
|
||
clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(poll, ms);
|
||
pollTimer.unref?.();
|
||
}
|
||
|
||
async function poll() {
|
||
if (stopped) return;
|
||
if (inFlight) return inFlight;
|
||
inFlight = (async () => {
|
||
let delay = pollMs;
|
||
try {
|
||
await readPlan();
|
||
} catch (err) {
|
||
state.reachable = false;
|
||
state.lastError = err?.message || String(err);
|
||
delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS;
|
||
log(`plan read failed: ${state.lastError}`);
|
||
}
|
||
broadcast();
|
||
schedulePoll(delay);
|
||
})();
|
||
try {
|
||
await inFlight;
|
||
} finally {
|
||
inFlight = null;
|
||
}
|
||
}
|
||
|
||
/* — phase 2: follow the ProPresenter Now/Next feed over loopback — */
|
||
|
||
let nnRequest = null;
|
||
let nnRetryTimer = null;
|
||
let lastLiveName = null;
|
||
|
||
function resetLiveTracking() {
|
||
const keep = state.live;
|
||
state.live = {
|
||
...clearedLive(),
|
||
following: keep.following,
|
||
available: keep.available,
|
||
reason: keep.reason,
|
||
ppEnabled: keep.ppEnabled,
|
||
ppReachable: keep.ppReachable,
|
||
activeName: keep.activeName,
|
||
};
|
||
lastLiveName = null;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
function applyLiveView(view) {
|
||
const live = state.live;
|
||
live.available = true;
|
||
live.reason = '';
|
||
live.ppEnabled = Boolean(view?.enabled);
|
||
live.ppReachable = Boolean(view?.reachable);
|
||
const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null;
|
||
const name = String(activeItem?.name || view?.activePresentationName || '').trim();
|
||
live.activeName = name;
|
||
if (!name) {
|
||
// Cleared output between items is normal — the current item stays current.
|
||
lastLiveName = '';
|
||
broadcast();
|
||
return;
|
||
}
|
||
if (name === lastLiveName) return;
|
||
lastLiveName = name;
|
||
const hit = matchItem(name);
|
||
live.matched = Boolean(hit);
|
||
if (hit) {
|
||
live.currentScore = hit.score;
|
||
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
||
}
|
||
broadcast();
|
||
}
|
||
|
||
function startItem(itemId) {
|
||
const live = state.live;
|
||
const now = Date.now();
|
||
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||
live.history[live.currentItemId].endedAt = now;
|
||
}
|
||
live.currentItemId = itemId;
|
||
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||
if (!live.serviceStartedAt) live.serviceStartedAt = now;
|
||
const item = state.items.find((it) => it.id === itemId);
|
||
log(`live item → ${item?.title || itemId}`);
|
||
}
|
||
|
||
function nowNextUnavailable(reason, retryMs) {
|
||
const live = state.live;
|
||
if (live.available || live.reason !== reason) {
|
||
live.available = false;
|
||
live.reason = reason;
|
||
live.ppReachable = false;
|
||
broadcast();
|
||
}
|
||
if (stopped) return;
|
||
clearTimeout(nnRetryTimer);
|
||
nnRetryTimer = setTimeout(connectNowNext, retryMs);
|
||
nnRetryTimer.unref?.();
|
||
}
|
||
|
||
function connectNowNext() {
|
||
if (stopped || !follow) return;
|
||
const port = shellPort();
|
||
const req = http.get(
|
||
{ host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||
(res) => {
|
||
if (res.statusCode !== 200) {
|
||
res.resume();
|
||
const why = res.statusCode === 404
|
||
? 'ProPresenter module is disabled or not installed'
|
||
: `ProPresenter module feed answered ${res.statusCode}`;
|
||
return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS);
|
||
}
|
||
res.setEncoding('utf8');
|
||
let buffer = '';
|
||
res.on('data', (chunk) => {
|
||
buffer += chunk;
|
||
let idx;
|
||
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||
const block = buffer.slice(0, idx);
|
||
buffer = buffer.slice(idx + 2);
|
||
let event = 'message';
|
||
let data = '';
|
||
for (const line of block.split('\n')) {
|
||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||
}
|
||
if (event !== 'state' || !data) continue;
|
||
try {
|
||
applyLiveView(JSON.parse(data));
|
||
} catch { /* a bad frame is not fatal */ }
|
||
}
|
||
});
|
||
res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS));
|
||
res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
|
||
}
|
||
);
|
||
req.on('error', (err) => nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS));
|
||
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||
nnRequest = req;
|
||
}
|
||
|
||
/* — start — */
|
||
|
||
if (!pco.configured) {
|
||
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
|
||
log(state.lastError);
|
||
} else if (!serviceTypeId) {
|
||
state.lastError = 'No service type selected — open /modules/pco-plan/setup.html';
|
||
log(state.lastError);
|
||
} else {
|
||
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||
schedulePoll(0);
|
||
}
|
||
if (follow) {
|
||
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||
connectNowNext();
|
||
}
|
||
|
||
const heartbeat = setInterval(() => {
|
||
for (const res of streams) {
|
||
try {
|
||
res.write(': ping\n\n');
|
||
} catch {
|
||
streams.delete(res);
|
||
}
|
||
}
|
||
}, HEARTBEAT_MS);
|
||
heartbeat.unref?.();
|
||
|
||
current = {
|
||
streams,
|
||
pco,
|
||
getState: () => state,
|
||
refresh() {
|
||
clearTimeout(pollTimer);
|
||
return poll();
|
||
},
|
||
choosePlanId,
|
||
};
|
||
|
||
return {
|
||
stop() {
|
||
stopped = true;
|
||
clearInterval(heartbeat);
|
||
clearTimeout(pollTimer);
|
||
clearTimeout(nnRetryTimer);
|
||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||
for (const res of streams) {
|
||
try { res.end(); } catch { /* already gone */ }
|
||
}
|
||
streams.clear();
|
||
if (current && current.streams === streams) current = null;
|
||
},
|
||
health() {
|
||
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — open /modules/pco-plan/setup.html' };
|
||
if (!state.reachable) {
|
||
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||
}
|
||
const name = state.serviceType.name || serviceTypeId;
|
||
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
||
const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||
return { status: 'ok', message: `Following ${name}${plan}${live}` };
|
||
},
|
||
};
|
||
},
|
||
|
||
routes({ log }) {
|
||
const json = (res, status, body) => {
|
||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||
res.end(JSON.stringify(body));
|
||
};
|
||
|
||
return {
|
||
'GET /state': (req, res) => {
|
||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||
},
|
||
|
||
'GET /stream': (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no',
|
||
});
|
||
res.write('retry: 3000\n\n');
|
||
const state = current ? current.getState() : clearedState();
|
||
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||
if (!current) return void res.end();
|
||
const { streams } = current;
|
||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||
streams.add(res);
|
||
req.on('close', () => streams.delete(res));
|
||
},
|
||
|
||
'POST /refresh': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
current.refresh().then(
|
||
() => json(res, 200, { ok: true, state: current?.getState() }),
|
||
(err) => json(res, 502, { error: err?.message || String(err) })
|
||
);
|
||
},
|
||
|
||
/** Folders + service types as flat lists; the setup page builds the tree. */
|
||
'GET /browse': async (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const { pco } = current;
|
||
if (!pco.configured) {
|
||
return json(res, 400, { error: 'No Planning Center credentials — add them in /admin first.', unconfigured: true });
|
||
}
|
||
try {
|
||
const [folderBody, stBody] = await Promise.all([
|
||
pco.getAll(`${SERVICES}/folders`),
|
||
pco.getAll(`${SERVICES}/service_types`),
|
||
]);
|
||
const folders = new Map();
|
||
for (const f of folderBody.data || []) {
|
||
folders.set(String(f.id), {
|
||
id: String(f.id),
|
||
name: String(f.attributes?.name || 'Folder'),
|
||
parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '',
|
||
});
|
||
}
|
||
const serviceTypes = (stBody.data || []).map((st) => ({
|
||
id: String(st.id),
|
||
name: String(st.attributes?.name || 'Service type'),
|
||
frequency: String(st.attributes?.frequency || ''),
|
||
parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '',
|
||
}));
|
||
// Fill in any folder the list did not include (some accounts only
|
||
// list root folders) by fetching the missing parents individually.
|
||
const missing = new Set();
|
||
for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId);
|
||
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId);
|
||
let guard = 0;
|
||
while (missing.size && guard < 50) {
|
||
const id = missing.values().next().value;
|
||
missing.delete(id);
|
||
guard += 1;
|
||
try {
|
||
const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data;
|
||
const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : '';
|
||
folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId });
|
||
if (parentId && !folders.has(parentId)) missing.add(parentId);
|
||
} catch {
|
||
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
|
||
}
|
||
}
|
||
const state = current.getState();
|
||
json(res, 200, {
|
||
folders: Array.from(folders.values()),
|
||
serviceTypes,
|
||
selected: state.serviceType.id,
|
||
selectedName: state.serviceType.name,
|
||
apiBase: API_BASE,
|
||
method: pco.method,
|
||
});
|
||
} catch (err) {
|
||
log(`browse failed: ${err?.message || err}`);
|
||
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
|
||
}
|
||
},
|
||
|
||
/** Which plan would be followed for a service type — shown before saving. */
|
||
'GET /preview': async (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const params = new URLSearchParams(req.search || '');
|
||
const stId = String(params.get('serviceTypeId') || '').trim();
|
||
if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' });
|
||
try {
|
||
const rec = await current.choosePlanId(stId);
|
||
if (!rec) return json(res, 200, { plan: null });
|
||
const a = rec.attributes || {};
|
||
json(res, 200, {
|
||
plan: {
|
||
id: String(rec.id),
|
||
title: String(a.title || ''),
|
||
seriesTitle: String(a.series_title || ''),
|
||
dates: String(a.dates || ''),
|
||
itemsCount: Number(a.items_count) || 0,
|
||
totalLength: Number(a.total_length) || 0,
|
||
sortDate: a.sort_date || '',
|
||
},
|
||
});
|
||
} catch (err) {
|
||
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|