Colors and Status colors were two groups asking the same question, so they are one: team header, the tints and the three status colours together. The Positions switches go entirely — the team switches already decide what a tile shows, and "Group by position" still arranges what is left. With them go GET /positions and positionOptions(). Two rules the tile was breaking on the way past: a gear group may carry no `help` (rule 5), and only hide-toggles belong in Show (rule 8) — so the group blurbs are gone and the Columns picker sits loose at the top beside the team switches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1819 lines
75 KiB
JavaScript
1819 lines
75 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 /service-types → { options } for the admin "Service type" select,
|
||
* grouped by Planning Center folder path
|
||
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||
* GET /documents → { options } for the admin start / end document selects
|
||
* (documents ProPresenter has shown or lists — see below)
|
||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
||
* the service type's item note categories, by name
|
||
* GET /teams → { options } for a Teams tile's "Teams" switches
|
||
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
|
||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||
*
|
||
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
||
* Auth: Personal Access Token (HTTP Basic client_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
|
||
* GET /service_types/{st}/item_note_categories (note category names)
|
||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
||
*
|
||
* 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
|
||
* 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
|
||
* its scheduled time, or (admin "Service timing") when a chosen ProPresenter
|
||
* document goes live, and ends when the chosen end document goes live, with
|
||
* schedule failsafes for both. The result is offered to the Timers module as
|
||
* three timers: the service, the current item and the countdown to the
|
||
* service.
|
||
*
|
||
* 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 os = require('os');
|
||
const path = require('path');
|
||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||
const {
|
||
MINUTE,
|
||
serviceTimes,
|
||
serviceWindows,
|
||
selectService,
|
||
freshService,
|
||
advance,
|
||
fmtTime,
|
||
} = require('./service-timing');
|
||
|
||
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;
|
||
/** How many distinct documents seen live are remembered for the admin selects. */
|
||
const SEEN_DOCUMENTS_MAX = 200;
|
||
/**
|
||
* Team assignments change far more slowly than the plan itself (someone
|
||
* confirms, a position is filled), so they ride a slow lane: re-read when the
|
||
* plan changes, and otherwise no more often than this.
|
||
*/
|
||
const TEAMS_MIN_MS = 60 * 1000;
|
||
/** How long the service type's own team list is believed. */
|
||
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
||
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
||
const AVATAR_CACHE_MAX = 300;
|
||
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
|
||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||
|
||
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
||
/**
|
||
* Service timer tone (the Timers module colours by it): running behind the
|
||
* plan — completed items over their planned length, plus the current item's
|
||
* overrun — by BEHIND_WARN_MS is `warn`, by BEHIND_DANGER_MS `danger`; past
|
||
* the planned total length is `warn`, by more than BEHIND_DANGER_MS `danger`.
|
||
*/
|
||
const BEHIND_WARN_MS = 2 * MINUTE;
|
||
const BEHIND_DANGER_MS = 5 * MINUTE;
|
||
|
||
/** 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: [],
|
||
teams: [], // who is scheduled, by team (the Teams tile)
|
||
teamsError: '', // why the last team read failed, if it did
|
||
live: clearedLive(),
|
||
service: clearedService(),
|
||
timing: null, // the admin's service-timing choices (no secrets)
|
||
};
|
||
}
|
||
|
||
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, // the service's actual start, ms epoch (0 until it starts)
|
||
serviceEndedAt: 0, // …and its end, once it has one
|
||
history: {}, // itemId → { startedAt, endedAt }
|
||
};
|
||
}
|
||
|
||
/** The service that owns the clock, as tiles and the timers see it. */
|
||
function clearedService() {
|
||
return {
|
||
id: '',
|
||
phase: 'none', // 'none' (no plan / no services) | 'idle' | 'running' | 'stopped'
|
||
startedAt: 0,
|
||
endedAt: 0,
|
||
startTrigger: '',
|
||
endTrigger: '',
|
||
cue: '',
|
||
name: '',
|
||
label: '',
|
||
index: -1,
|
||
count: 0,
|
||
startsAt: 0,
|
||
endsAt: 0,
|
||
windowFrom: 0,
|
||
windowUntil: 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fallback for a shell that does not hand us its port (init() without
|
||
* `shell`, i.e. ProdDash before 1.4.0): guess the way the shell decides —
|
||
* PORT env, then this machine's proddash.json in the data directory, then the
|
||
* checked-in config/proddash.json, then 24500 — and correct the guess from
|
||
* the Host header of the first request that reaches a route (see learnPort).
|
||
*/
|
||
function guessShellPort() {
|
||
const env = Number(process.env.PORT);
|
||
if (env) return env;
|
||
const home = os.homedir();
|
||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||
const root = path.join(__dirname, '..', '..');
|
||
const dataDir = fromEnv
|
||
? path.resolve(root, fromEnv)
|
||
: process.platform === 'win32'
|
||
? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash')
|
||
: process.platform === 'darwin'
|
||
? path.join(home, 'Library', 'Application Support', 'ProdDash')
|
||
: path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||
for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) {
|
||
try {
|
||
const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port);
|
||
if (port) return port;
|
||
} catch { /* try the next one */ }
|
||
}
|
||
return 24500;
|
||
}
|
||
|
||
/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */
|
||
function portFromHost(hostHeader) {
|
||
const m = /:(\d+)$/.exec(String(hostHeader || '').trim());
|
||
if (m) return Number(m[1]) || 0;
|
||
return hostHeader ? 80 : 0;
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
/** "1:02:03" / "4:05" for a duration in ms (for logs and status lines). */
|
||
function fmtDur(ms) {
|
||
const s = Math.max(0, Math.round(Math.abs(Number(ms) || 0) / 1000));
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const sec = s % 60;
|
||
return h
|
||
? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||
: `${m}:${String(sec).padStart(2, '0')}`;
|
||
}
|
||
|
||
/* ── 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 ───────────────────────────────────────────────────── */
|
||
|
||
/** 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;
|
||
}
|
||
}
|
||
|
||
/** Planned length of the service proper (the "during" items), ms. */
|
||
function plannedLengthMs(items) {
|
||
return (items || [])
|
||
.filter((it) => it.servicePosition === 'during')
|
||
.reduce((sum, it) => sum + (Number(it.length) || 0) * 1000, 0);
|
||
}
|
||
|
||
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: '',
|
||
};
|
||
}
|
||
|
||
/* ── team assignments (the Teams tile) ──────────────────────────────── */
|
||
|
||
/**
|
||
* Planning Center's scheduling status letters. The API answers "C", "U" or
|
||
* "D"; some accounts and older records spell the word out, so match on the
|
||
* first letter and treat anything unknown as not yet confirmed — the state a
|
||
* booth should chase.
|
||
*/
|
||
function teamStatusOf(raw) {
|
||
const s = String(raw || '').trim().toUpperCase();
|
||
if (s.startsWith('C')) return 'confirmed';
|
||
if (s.startsWith('D')) return 'declined';
|
||
return 'unconfirmed';
|
||
}
|
||
|
||
/**
|
||
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
|
||
* plan is read with `include=person` the Person record carries the fuller set
|
||
* of names Planning Center has used for the same thing over the years. Take
|
||
* the first that looks like a URL — a missing photo is normal and the tile
|
||
* draws initials instead.
|
||
*/
|
||
function teamPhotoOf(attrs, person) {
|
||
const candidates = [
|
||
attrs?.photo_thumbnail,
|
||
attrs?.photo_url,
|
||
person?.attributes?.avatar,
|
||
person?.attributes?.photo_thumbnail_url,
|
||
person?.attributes?.photo_url,
|
||
person?.attributes?.thumbnail_url,
|
||
];
|
||
for (const c of candidates) {
|
||
const url = String(c || '').trim();
|
||
if (/^https?:\/\//i.test(url)) return url;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
|
||
* each with its people. Teams keep Planning Center's own order (a Team's
|
||
* `sequence`, then name); people sort by position, then by name, so a tile
|
||
* reads the way the schedule page does.
|
||
*
|
||
* Everyone scheduled belongs to a team, but a record whose team relationship
|
||
* is missing (a deleted team, a partial response) is not dropped on the
|
||
* floor — it lands under "Unassigned" where somebody will notice it.
|
||
*
|
||
* `allTeams` is the service type's own team list. Planning Center only
|
||
* returns a team here once somebody is scheduled on it, so without that list
|
||
* a team nobody has signed up for is invisible — which is the one a booth
|
||
* most wants to see. It also carries the authoritative name and order.
|
||
*/
|
||
function shapeTeams(body, allTeams) {
|
||
const included = new Map();
|
||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||
|
||
const teams = new Map();
|
||
for (const pp of body.data || []) {
|
||
const a = pp.attributes || {};
|
||
const teamRef = pp.relationships?.team?.data;
|
||
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
|
||
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
|
||
if (!teams.has(teamId)) {
|
||
teams.set(teamId, {
|
||
id: teamId,
|
||
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
|
||
sequence: Number(teamRec?.attributes?.sequence) || 0,
|
||
members: [],
|
||
});
|
||
}
|
||
const personRef = pp.relationships?.person?.data;
|
||
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
|
||
teams.get(teamId).members.push({
|
||
id: String(pp.id),
|
||
personId: personRef?.id ? String(personRef.id) : '',
|
||
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
|
||
position: String(a.team_position_name || '').trim(),
|
||
status: teamStatusOf(a.status),
|
||
declineReason: String(a.decline_reason || '').trim(),
|
||
notes: String(a.notes || '').trim(),
|
||
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
|
||
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
|
||
});
|
||
}
|
||
|
||
// Teams nobody is scheduled on yet, and the real name/order for the rest.
|
||
for (const t of allTeams || []) {
|
||
const known = teams.get(t.id);
|
||
if (known) {
|
||
if (t.name) known.name = t.name;
|
||
known.sequence = t.sequence;
|
||
} else {
|
||
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
|
||
}
|
||
}
|
||
|
||
const out = [...teams.values()];
|
||
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
|
||
for (const team of out) {
|
||
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
|
||
team.counts = {
|
||
total: team.members.length,
|
||
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
|
||
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
|
||
declined: team.members.filter((m) => m.status === 'declined').length,
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
|
||
ever talks to Planning Center (same reasoning as every other upstream). */
|
||
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
|
||
const avatarInFlight = new Map();
|
||
|
||
function fetchAvatar(url) {
|
||
const hit = avatarCache.get(url);
|
||
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
|
||
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
|
||
const p = (async () => {
|
||
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
|
||
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
|
||
const buf = Buffer.from(await response.arrayBuffer());
|
||
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
|
||
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
|
||
const entry = { buf, type, at: Date.now() };
|
||
avatarCache.set(url, entry);
|
||
return entry;
|
||
})().finally(() => avatarInFlight.delete(url));
|
||
avatarInFlight.set(url, p);
|
||
return p;
|
||
}
|
||
|
||
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
|
||
function avatarUrlAllowed(url) {
|
||
try {
|
||
const u = new URL(String(url || ''));
|
||
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
|
||
async function serveAvatar(res, url) {
|
||
if (!avatarUrlAllowed(url)) {
|
||
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||
return void res.end('No photo');
|
||
}
|
||
try {
|
||
const img = await fetchAvatar(url);
|
||
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
|
||
res.end(img.buf);
|
||
} catch {
|
||
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||
res.end('Photo unavailable');
|
||
}
|
||
}
|
||
|
||
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
|
||
function serviceLabel(win) {
|
||
if (!win || !win.startsAt) return '';
|
||
const name = String(win.name || '').trim();
|
||
return `${fmtTime(win.startsAt)} ${name && !/^service$/i.test(name) ? name : 'service'}`;
|
||
}
|
||
|
||
/** What /timers answers while the module is not running. */
|
||
function emptyTimers(now) {
|
||
return { source: 'pco-plan', label: 'PCO Plan', updatedAt: now, timers: [] };
|
||
}
|
||
|
||
/* ── the module ─────────────────────────────────────────────────────── */
|
||
|
||
module.exports = {
|
||
init({ config, log, shell }) {
|
||
const streams = new Set(); // /stream clients (tiles)
|
||
const timerStreams = new Set(); // /timers/stream clients (the Timers module)
|
||
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;
|
||
|
||
// Service timing choices (admin "Service timing"). A blank number field
|
||
// reaches us as 0, which is a legitimate choice; an absent one as the
|
||
// schema default.
|
||
const minutes = (value, dflt, max) => {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) && n >= 0 && value !== undefined && value !== null && value !== ''
|
||
? Math.min(max, n)
|
||
: dflt;
|
||
};
|
||
const triggersOn = Boolean(config.useDocumentTriggers);
|
||
const startDocument = triggersOn ? String(config.serviceStartDocument || '').trim() : '';
|
||
const endDocument = triggersOn ? String(config.serviceEndDocument || '').trim() : '';
|
||
const wantsDocumentStart = triggersOn && String(config.serviceStartTrigger || 'schedule') === 'document';
|
||
const timing = {
|
||
preRollMs: minutes(config.preRollMinutes, 30, 240) * MINUTE,
|
||
startGraceMs: minutes(config.startGraceMinutes, 10, 120) * MINUTE,
|
||
triggersOn,
|
||
startMode: wantsDocumentStart && startDocument ? 'document' : 'schedule',
|
||
startDocument,
|
||
endDocument,
|
||
};
|
||
const serviceOpts = {
|
||
startMode: timing.startMode,
|
||
startDocument: timing.startDocument,
|
||
endDocument: timing.endDocument,
|
||
startGraceMs: timing.startGraceMs,
|
||
};
|
||
// Document cues need the ProPresenter feed even when the tiles don't follow it.
|
||
const wantFeed = follow || triggersOn;
|
||
|
||
let state = clearedState();
|
||
state.configured = pco.configured;
|
||
state.serviceTypeSet = Boolean(serviceTypeId);
|
||
state.serviceType = { id: serviceTypeId, name: '' };
|
||
state.live.following = follow;
|
||
state.timing = { ...timing };
|
||
|
||
let stopped = false;
|
||
let pollTimer = null;
|
||
let inFlight = null;
|
||
let lastFrame = '';
|
||
let lastTimersSig = '';
|
||
let lastMessageItemId = null;
|
||
let lastMatchError = '';
|
||
|
||
/* — broadcasting — */
|
||
|
||
function frameOf() {
|
||
return JSON.stringify(state);
|
||
}
|
||
|
||
function writeAll(set, payload) {
|
||
for (const res of set) {
|
||
try {
|
||
res.write(payload);
|
||
} catch {
|
||
set.delete(res);
|
||
}
|
||
}
|
||
}
|
||
|
||
function broadcast(force) {
|
||
const frame = frameOf();
|
||
if (!force && frame === lastFrame) return;
|
||
lastFrame = frame;
|
||
if (!streams.size) return;
|
||
writeAll(streams, `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||
}
|
||
|
||
/**
|
||
* The timers change "structurally" far less often than their displayed
|
||
* value does (the consumer extrapolates from startedAt), so a frame goes
|
||
* out only when something other than the running values changed.
|
||
*/
|
||
function timersSignature(snap) {
|
||
return JSON.stringify(snap.timers.map((t) => [t.id, t.state, t.startedAt, t.targetMs, t.status, t.detail]));
|
||
}
|
||
|
||
function broadcastTimers(force) {
|
||
const snap = timersSnapshot(Date.now());
|
||
const sig = timersSignature(snap);
|
||
if (!force && sig === lastTimersSig) return;
|
||
lastTimersSig = sig;
|
||
if (!timerStreams.size) return;
|
||
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||
}
|
||
|
||
/* — plan reading — */
|
||
|
||
function planPath(planId) {
|
||
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||
}
|
||
|
||
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
||
async function listPlans(stId, { future = 5, past = 3 } = {}) {
|
||
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
||
const [fut, pst] = await Promise.all([
|
||
pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
|
||
pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
|
||
]);
|
||
const byId = new Map();
|
||
for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
|
||
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
||
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
||
byId.set(String(rec.id), { rec, sortDate });
|
||
}
|
||
return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
||
}
|
||
|
||
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
||
function pickPlan(plans) {
|
||
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 choosePlanId(stId) {
|
||
return pickPlan(await listPlans(stId));
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/* — team assignments (the Teams tile) — */
|
||
|
||
/**
|
||
* The service type's teams, cached: they change when somebody edits the
|
||
* service type, not during a service. A failed read is not fatal — the
|
||
* plan's own teams still draw, just without the empty ones.
|
||
*/
|
||
let serviceTeams = { at: 0, list: [] };
|
||
|
||
async function readServiceTeams() {
|
||
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
||
try {
|
||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
|
||
const list = (body.data || []).map((t, i) => ({
|
||
id: String(t.id),
|
||
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
||
sequence: Number(t.attributes?.sequence) || i + 1,
|
||
}));
|
||
serviceTeams = { at: Date.now(), list };
|
||
} catch (err) {
|
||
log(`service type team list read failed: ${err?.message || err}`);
|
||
serviceTeams.at = Date.now(); // don't retry every poll
|
||
}
|
||
return serviceTeams.list;
|
||
}
|
||
|
||
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
||
let teamPhotos = new Map();
|
||
let teamsReadAt = 0;
|
||
|
||
/**
|
||
* Who is scheduled on this plan. Asked for with `include=team,person` and
|
||
* read again defensively without the includes when an account refuses
|
||
* them, exactly as the items read does: a tile that can name people
|
||
* without photos is far better than a tile that shows nothing.
|
||
*/
|
||
async function readTeams(planId) {
|
||
const target = `${planPath(planId)}/team_members`;
|
||
let body;
|
||
try {
|
||
body = await pco.getAll(target, { include: 'team,person' });
|
||
} catch (err) {
|
||
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||
log(`team include rejected (${err.message}) — reading team members without includes`);
|
||
body = await pco.getAll(target);
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
const teams = shapeTeams(body, await readServiceTeams());
|
||
const photos = new Map();
|
||
const included = new Map();
|
||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||
for (const pp of body.data || []) {
|
||
const personRef = pp.relationships?.person?.data;
|
||
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
||
if (url) photos.set(String(pp.id), url);
|
||
}
|
||
teamPhotos = photos;
|
||
teamsReadAt = Date.now();
|
||
return teams;
|
||
}
|
||
|
||
/**
|
||
* Refresh the team list when it is worth it: a new plan, or the slow lane
|
||
* coming round. A failure keeps the last good list and says so in the
|
||
* state — the plan itself is what the booth needs, and losing the roster
|
||
* must never cost them the running order.
|
||
*/
|
||
async function refreshTeams(planId, force) {
|
||
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
|
||
try {
|
||
const teams = await readTeams(planId);
|
||
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
|
||
const after = teams.reduce((n, t) => n + t.members.length, 0);
|
||
state.teams = teams;
|
||
state.teamsError = '';
|
||
if (force || before !== after) {
|
||
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
|
||
}
|
||
} catch (err) {
|
||
state.teamsError = err?.message || String(err);
|
||
log(`team read failed: ${state.teamsError}`);
|
||
}
|
||
}
|
||
|
||
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
||
function teamOptions() {
|
||
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
||
}
|
||
|
||
|
||
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) {
|
||
if (state.plan) log('no plan found any more — service timing reset');
|
||
state.plan = null;
|
||
state.items = [];
|
||
state.teams = [];
|
||
state.teamsError = "";
|
||
state.reachable = true;
|
||
state.lastError = '';
|
||
state.lastFetched = Date.now();
|
||
syncServiceSafe(Date.now(), null);
|
||
return;
|
||
}
|
||
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
||
const planChanged = state.plan?.id !== plan.id;
|
||
if (planChanged) {
|
||
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`);
|
||
else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim());
|
||
}
|
||
state.plan = plan;
|
||
state.items = items;
|
||
noteMessageItem(items);
|
||
// The roster rides its own slow lane and never fails the plan read.
|
||
await refreshTeams(plan.id, planChanged);
|
||
state.reachable = true;
|
||
state.lastError = '';
|
||
state.lastFetched = Date.now();
|
||
if (planChanged) {
|
||
// A new plan: the next syncService picks its first window afresh
|
||
// (and resets the item timing while doing so).
|
||
win = null;
|
||
svc = freshService(null);
|
||
}
|
||
// Service selection, item schedule and the running clock all follow
|
||
// from the plan's service times — decided in one place.
|
||
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
|
||
let noteCategoriesInFlight = null;
|
||
|
||
/**
|
||
* The service type's item note categories, by name — notes are matched
|
||
* to categories by name, so the names are the values. Believed for
|
||
* NOTE_CATEGORIES_TTL_MS, read once however many tiles ask at the same
|
||
* moment; a failed re-read keeps serving the last good list. A module
|
||
* that cannot ask (no credentials, no service type) answers an empty
|
||
* list — the gear window then says there is nothing to choose from yet.
|
||
*/
|
||
async function noteCategoryOptions() {
|
||
if (!pco.configured || !serviceTypeId) return [];
|
||
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
|
||
if (!noteCategoriesInFlight) {
|
||
noteCategoriesInFlight = (async () => {
|
||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`);
|
||
const seen = new Set();
|
||
const options = (body.data || [])
|
||
.filter((c) => !c?.attributes?.deleted_at)
|
||
.map((c) => ({ name: String(c.attributes?.name || '').trim(), sequence: Number(c.attributes?.sequence) || 0 }))
|
||
.sort((a, b) => a.sequence - b.sequence)
|
||
.filter((c) => c.name && !seen.has(c.name.toLowerCase()) && seen.add(c.name.toLowerCase()))
|
||
.map((c) => ({ value: c.name, label: c.name }));
|
||
noteCategories = { at: Date.now(), options };
|
||
return options;
|
||
})().finally(() => { noteCategoriesInFlight = null; });
|
||
}
|
||
try {
|
||
return await noteCategoriesInFlight;
|
||
} catch (err) {
|
||
log(`item note categories read failed: ${err?.message || err}`);
|
||
return noteCategories.options;
|
||
}
|
||
}
|
||
|
||
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();
|
||
broadcastTimers();
|
||
schedulePoll(delay);
|
||
})();
|
||
try {
|
||
await inFlight;
|
||
} finally {
|
||
inFlight = null;
|
||
}
|
||
}
|
||
|
||
/* — service timing: which service owns the clock, and its start / end — */
|
||
|
||
let windows = []; // serviceWindows() of the current plan
|
||
let win = null; // the window that owns the clock now
|
||
let svc = freshService(null); // its idle → running → stopped state (service-timing.js)
|
||
let lastTimingError = '';
|
||
let countdownTargetId = '';
|
||
let countdownSince = 0;
|
||
|
||
/** A plan with no plan times still has a date — treat it as one service starting then. */
|
||
function planServices(plan) {
|
||
if (serviceTimes(plan.planTimes).length) return plan.planTimes;
|
||
return plan.sortDate ? [{ id: 'plan', name: '', timeType: 'service', startsAt: plan.sortDate, endsAt: 0 }] : [];
|
||
}
|
||
|
||
/** The plan's header times and every item's scheduled start follow the current service. */
|
||
function applyServiceTimes() {
|
||
const plan = state.plan;
|
||
if (!plan) return;
|
||
plan.serviceStartsAt = win ? win.startsAt : 0;
|
||
plan.serviceEndsAt = win ? win.endsAt : 0;
|
||
plan.serviceName = win ? win.name : '';
|
||
scheduleItems(state.items, plan.serviceStartsAt || plan.sortDate);
|
||
}
|
||
|
||
/** state.service and the live mirror fields, from `win` + `svc`. */
|
||
function mirrorService() {
|
||
const finite = (v) => (Number.isFinite(v) ? v : 0);
|
||
state.service = {
|
||
...clearedService(),
|
||
...svc,
|
||
name: win ? win.name : '',
|
||
label: serviceLabel(win),
|
||
index: win ? win.index : -1,
|
||
count: win ? win.count : 0,
|
||
startsAt: win ? win.startsAt : 0,
|
||
endsAt: win ? win.endsAt : 0,
|
||
windowFrom: win ? finite(win.windowFrom) : 0,
|
||
windowUntil: win ? finite(win.windowUntil) : 0,
|
||
};
|
||
state.live.serviceStartedAt = svc.phase === 'running' || svc.phase === 'stopped' ? svc.startedAt : 0;
|
||
state.live.serviceEndedAt = svc.phase === 'stopped' ? svc.endedAt : 0;
|
||
}
|
||
|
||
/**
|
||
* Bring the service timing up to `now`, optionally with a document that
|
||
* has just gone live in ProPresenter (`cue`). Selects the window that
|
||
* owns the clock (flipping — and resetting item timing — when the next
|
||
* service's window has opened), then advances that service's own
|
||
* idle → running → stopped state. Pure logic in service-timing.js.
|
||
*/
|
||
function syncService(now, cue) {
|
||
const plan = state.plan;
|
||
if (!plan) {
|
||
if (win || svc.phase !== 'none') {
|
||
win = null;
|
||
windows = [];
|
||
svc = freshService(null);
|
||
mirrorService();
|
||
}
|
||
return;
|
||
}
|
||
const endedAt = svc.phase === 'stopped' && svc.endedAt ? { [svc.id]: svc.endedAt } : {};
|
||
windows = serviceWindows(planServices(plan), {
|
||
preRollMs: timing.preRollMs,
|
||
plannedMs: plannedLengthMs(state.items),
|
||
endedAt,
|
||
});
|
||
const next = selectService(windows, now);
|
||
if (!next) {
|
||
// A plan with neither service times nor a date: nothing to time.
|
||
if (win || svc.phase !== 'none') {
|
||
const hadWindow = Boolean(win);
|
||
win = null;
|
||
svc = freshService(null);
|
||
if (hadWindow) resetLiveTracking();
|
||
applyServiceTimes();
|
||
mirrorService();
|
||
}
|
||
return;
|
||
}
|
||
if (!win || win.id !== next.id) {
|
||
// Leaving a window: a service still running is stopped at the boundary
|
||
// (the "next service's start" failsafe), then the new service takes
|
||
// over with fresh item timing.
|
||
if (win && svc.id === win.id && svc.phase === 'running') {
|
||
const leaving = windows.find((w) => w.id === win.id) || win;
|
||
const out = advance(svc, leaving, serviceOpts, Math.max(now, leaving.failsafeEndAt), null);
|
||
const endedAtMs = out.changed ? Math.min(out.svc.endedAt, now) : now;
|
||
log(`service ${serviceLabel(leaving)} ${out.changed ? out.event : 'left its window while running'} (ran ${fmtDur(endedAtMs - svc.startedAt)})`);
|
||
}
|
||
win = next;
|
||
svc = freshService(win);
|
||
log(`service window → ${serviceLabel(win)} (${win.index + 1} of ${win.count}; scheduled start ${fmtTime(win.startsAt)}, start by ${timing.startMode}${timing.endDocument ? ', end by document' : ''})`);
|
||
resetLiveTracking();
|
||
applyServiceTimes();
|
||
} else {
|
||
win = next; // refreshed fields — the plan's times may have been edited
|
||
if (plan.serviceStartsAt !== win.startsAt || plan.serviceEndsAt !== win.endsAt || plan.serviceName !== win.name) {
|
||
applyServiceTimes();
|
||
}
|
||
}
|
||
const out = advance(svc, win, serviceOpts, now, cue);
|
||
if (out.changed) {
|
||
svc = out.svc;
|
||
log(`service ${serviceLabel(win)} ${out.event}`);
|
||
}
|
||
mirrorService();
|
||
}
|
||
|
||
/** syncService that can never take the plan display down with it. */
|
||
function syncServiceSafe(now, cue) {
|
||
try {
|
||
syncService(now, cue);
|
||
} catch (err) {
|
||
const msg = err?.message || String(err);
|
||
if (msg !== lastTimingError) {
|
||
lastTimingError = msg;
|
||
log(`service timing error (plan display unaffected): ${msg}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* — timers offered to the Timers module — */
|
||
|
||
/** How far behind the plan the service runs, ms (negative = ahead). */
|
||
function behindMs(now) {
|
||
const history = state.live.history || {};
|
||
let drift = 0;
|
||
let any = false;
|
||
for (const it of state.items) {
|
||
const h = history[it.id];
|
||
if (!h) continue;
|
||
if (h.endedAt) {
|
||
drift += (h.endedAt - h.startedAt) - (it.length || 0) * 1000;
|
||
any = true;
|
||
} else if (it.length) {
|
||
const over = (now - h.startedAt) - it.length * 1000;
|
||
if (over > 0) {
|
||
drift += over;
|
||
any = true;
|
||
}
|
||
}
|
||
}
|
||
return any ? drift : 0;
|
||
}
|
||
|
||
function serviceTone(elapsedMs, plannedMs, behind) {
|
||
const over = plannedMs ? elapsedMs - plannedMs : 0;
|
||
if (over > BEHIND_DANGER_MS || behind >= BEHIND_DANGER_MS) return 'danger';
|
||
if (over > 0 || behind >= BEHIND_WARN_MS) return 'warn';
|
||
return 'ok';
|
||
}
|
||
|
||
/** The service the countdown counts to: the idle current one, or the next one once this one ended. */
|
||
function countdownTarget() {
|
||
if (!win) return null;
|
||
if (svc.phase === 'idle') return win;
|
||
if (svc.phase === 'stopped') return windows[win.index + 1] || null;
|
||
return null;
|
||
}
|
||
|
||
function timersSnapshot(now) {
|
||
const plan = state.plan;
|
||
const live = state.live;
|
||
const items = state.items.filter((it) => it.itemType !== 'header');
|
||
const idx = live.currentItemId ? items.findIndex((it) => it.id === live.currentItemId) : -1;
|
||
const item = idx >= 0 ? items[idx] : null;
|
||
const hist = item ? live.history[item.id] : null;
|
||
const label = serviceLabel(win);
|
||
const itemDetail = item ? `Item ${idx + 1} of ${items.length}` : '';
|
||
const itemTitle = item ? item.title || item.song?.title || '(untitled)' : '';
|
||
const plannedMs = plannedLengthMs(state.items)
|
||
|| (plan?.totalLength ? plan.totalLength * 1000 : 0)
|
||
|| (win && win.endsAt > win.startsAt ? win.endsAt - win.startsAt : 0)
|
||
|| null;
|
||
const timer = (id, name, kind) => ({
|
||
id,
|
||
label: name,
|
||
kind,
|
||
state: 'idle',
|
||
startedAt: null,
|
||
elapsedMs: null,
|
||
remainingMs: null,
|
||
targetMs: null,
|
||
status: null,
|
||
detail: '',
|
||
});
|
||
|
||
// "Service" — elapsed since the service started, against its planned length.
|
||
const service = timer('service', 'Service', 'elapsed');
|
||
service.targetMs = plannedMs;
|
||
if (!plan || !win) {
|
||
service.status = { text: plan ? 'No service times in this plan' : 'No plan', tone: 'muted' };
|
||
} else if (svc.phase === 'running') {
|
||
const elapsed = now - svc.startedAt;
|
||
const behind = behindMs(now);
|
||
service.state = 'running';
|
||
service.startedAt = svc.startedAt;
|
||
service.elapsedMs = elapsed;
|
||
// The live item is the headline; without one (not following
|
||
// ProPresenter, nothing matched yet) say how far behind, else when
|
||
// the service began.
|
||
service.status = {
|
||
text: itemTitle || (behind >= BEHIND_WARN_MS ? `Behind ${fmtDur(behind)}` : `Started ${fmtTime(svc.startedAt)}`),
|
||
tone: serviceTone(elapsed, plannedMs, behind),
|
||
};
|
||
service.detail = [label, itemDetail].filter(Boolean).join(' · ');
|
||
} else if (svc.phase === 'stopped') {
|
||
service.state = 'stopped';
|
||
service.startedAt = svc.startedAt;
|
||
service.elapsedMs = svc.endedAt - svc.startedAt;
|
||
service.status = { text: `Ended ${fmtTime(svc.endedAt)}`, tone: 'muted' };
|
||
service.detail = label;
|
||
} else {
|
||
const waiting = timing.startMode === 'document' && now >= win.startsAt;
|
||
service.status = waiting
|
||
? { text: 'Waiting for the start document', tone: 'warn' }
|
||
: { text: `Starts ${fmtTime(win.startsAt)}`, tone: 'muted' };
|
||
service.detail = label;
|
||
}
|
||
|
||
// "Current item" — the live plan item's runtime against its planned length.
|
||
const currentItem = timer('item', 'Current item', 'elapsed');
|
||
if (item && hist) {
|
||
const elapsed = (hist.endedAt || now) - hist.startedAt;
|
||
const planned = item.length ? item.length * 1000 : 0;
|
||
const over = planned ? elapsed - planned : 0;
|
||
currentItem.state = hist.endedAt ? 'stopped' : 'running';
|
||
currentItem.startedAt = hist.startedAt;
|
||
currentItem.elapsedMs = elapsed;
|
||
currentItem.targetMs = planned || null;
|
||
// With ProPresenter (or its module) unreachable this is the last item
|
||
// we knew of — still counting, but muted so the wall says "stale".
|
||
const stale = !live.available || !live.ppReachable;
|
||
currentItem.status = {
|
||
text: itemTitle,
|
||
tone: stale ? 'muted' : over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok',
|
||
};
|
||
currentItem.detail = stale ? `${itemDetail} · ProPresenter unreachable` : itemDetail;
|
||
} else {
|
||
currentItem.status = {
|
||
text: !live.following ? 'Not following ProPresenter' : !live.available ? 'ProPresenter feed unavailable' : 'Nothing live',
|
||
tone: 'muted',
|
||
};
|
||
}
|
||
|
||
// "Countdown to service" — to the current service's start, then to the
|
||
// next one once this one has ended; frozen at zero while it runs; idle
|
||
// when today holds no further service.
|
||
const countdown = timer('countdown', 'Countdown to service', 'countdown');
|
||
const target = countdownTarget();
|
||
if (target && localYmd(new Date(target.startsAt)) === localYmd(new Date(now))) {
|
||
if (target.id !== countdownTargetId) {
|
||
countdownTargetId = target.id;
|
||
countdownSince = now;
|
||
}
|
||
const remaining = Math.max(0, target.startsAt - now);
|
||
countdown.detail = serviceLabel(target);
|
||
if (remaining > 0) {
|
||
countdown.state = 'running';
|
||
countdown.startedAt = countdownSince;
|
||
countdown.targetMs = target.startsAt - countdownSince;
|
||
countdown.remainingMs = remaining;
|
||
} else {
|
||
countdown.state = 'stopped';
|
||
countdown.remainingMs = 0;
|
||
countdown.status = {
|
||
text: timing.startMode === 'document' ? 'Waiting for the start document' : 'Service time',
|
||
tone: 'muted',
|
||
};
|
||
}
|
||
} else if (win && svc.phase === 'running') {
|
||
countdown.state = 'stopped';
|
||
countdown.remainingMs = 0;
|
||
countdown.detail = label;
|
||
} else {
|
||
countdown.status = { text: 'No upcoming service today', tone: 'muted' };
|
||
}
|
||
|
||
return { source: 'pco-plan', label: 'PCO Plan', updatedAt: now, timers: [service, currentItem, countdown] };
|
||
}
|
||
|
||
/* — the ProPresenter Now/Next feed over loopback — */
|
||
|
||
let nnRequest = null;
|
||
let nnRetryTimer = null;
|
||
let lastLiveName = null;
|
||
let lastView = null; // the latest Now/Next view-model (playlist items → document list)
|
||
const seenDocuments = new Map(); // document name → last seen live, ms
|
||
// The shell tells us its port (ProdDash ≥ 1.4.0). Without it, fall back to
|
||
// guessing and learning it from the first browser's Host header.
|
||
const shellPort = Number(shell?.port) || 0;
|
||
const guessedPort = shellPort || guessShellPort();
|
||
let nnPort = guessedPort;
|
||
/** Learned ports that refused the loopback connection (a reverse proxy's port, say). */
|
||
const badPorts = new Set();
|
||
|
||
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
|
||
function learnPort(hostHeader) {
|
||
if (shellPort) return; // the shell told us; nothing to learn
|
||
const port = portFromHost(hostHeader);
|
||
if (!port || port === nnPort || badPorts.has(port)) return;
|
||
log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`);
|
||
nnPort = port;
|
||
if (!wantFeed || stopped) return;
|
||
clearTimeout(nnRetryTimer);
|
||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||
nnRequest = null;
|
||
connectNowNext();
|
||
}
|
||
|
||
/**
|
||
* Forget the item timing (a new plan, or the next service's window). Only
|
||
* a *change* of document is a cue from here on — whatever is live at this
|
||
* moment (the previous service's loop, a document left up) must not start
|
||
* or end the new service — but the highlight may pick it straight back up.
|
||
*/
|
||
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 = keep.activeName || '';
|
||
if (follow && 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, keep.activeName, hit);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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. */
|
||
function rememberDocument(name) {
|
||
const n = String(name || '').trim();
|
||
if (!n) return;
|
||
seenDocuments.delete(n);
|
||
seenDocuments.set(n, Date.now());
|
||
while (seenDocuments.size > SEEN_DOCUMENTS_MAX) seenDocuments.delete(seenDocuments.keys().next().value);
|
||
}
|
||
|
||
/**
|
||
* Options for the admin "Start document" / "End document" selects. The
|
||
* Now/Next module exposes the live document and the active playlist's
|
||
* items — not ProPresenter's library — so the list is: what is live now,
|
||
* the active playlist, every document seen live since ProdDash started,
|
||
* and whatever is saved (kept selectable even when ProPresenter doesn't
|
||
* show it right now).
|
||
*/
|
||
function documentOptions() {
|
||
const options = [{ value: '', label: 'None' }];
|
||
const seen = new Set(['']);
|
||
const add = (name, group) => {
|
||
const n = String(name || '').trim();
|
||
if (!n || seen.has(n)) return;
|
||
seen.add(n);
|
||
options.push({ value: n, label: n, group });
|
||
};
|
||
const live = state.live;
|
||
if (live.activeName) add(live.activeName, 'Live now');
|
||
const playlist = Array.isArray(lastView?.items) ? lastView.items : [];
|
||
const playlistGroup = lastView?.playlistName ? `Playlist: ${lastView.playlistName}` : 'Active playlist';
|
||
for (const it of playlist) {
|
||
if (!it || it.isHidden || it.type === 'header') continue;
|
||
add(it.name, playlistGroup);
|
||
}
|
||
const recent = [...seenDocuments.entries()].sort((a, b) => b[1] - a[1]);
|
||
for (const [name] of recent) add(name, 'Seen live since ProdDash started');
|
||
for (const saved of [timing.startDocument, timing.endDocument]) {
|
||
if (saved && !seen.has(saved)) add(saved, 'Saved (not in ProPresenter right now)');
|
||
}
|
||
let note = '';
|
||
if (!wantFeed) note = 'Turn on ProPresenter document triggers (or Follow ProPresenter Module), save, then reopen this list.';
|
||
else if (!live.available) note = `ProPresenter module feed unavailable — ${live.reason || 'reconnecting'}; showing what was seen earlier and what is saved.`;
|
||
else if (!live.ppReachable) note = 'ProPresenter is unreachable — showing what was seen earlier and what is saved.';
|
||
return { options, note };
|
||
}
|
||
|
||
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);
|
||
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();
|
||
broadcastTimers();
|
||
return;
|
||
}
|
||
if (name === lastLiveName) {
|
||
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
|
||
return;
|
||
}
|
||
rememberDocument(name);
|
||
if (follow) {
|
||
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();
|
||
}
|
||
|
||
/** 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) {
|
||
live.history[live.currentItemId].endedAt = now;
|
||
}
|
||
live.currentItemId = itemId;
|
||
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||
const item = state.items.find((it) => it.id === 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;
|
||
broadcast();
|
||
broadcastTimers();
|
||
}
|
||
if (stopped) return;
|
||
clearTimeout(nnRetryTimer);
|
||
nnRetryTimer = setTimeout(connectNowNext, retryMs);
|
||
nnRetryTimer.unref?.();
|
||
}
|
||
|
||
function connectNowNext() {
|
||
if (stopped || !wantFeed) return;
|
||
const port = nnPort;
|
||
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) => {
|
||
if (req !== nnRequest) return; // superseded by a reconnect on the learned port
|
||
if (!shellPort && port !== guessedPort) {
|
||
// The Host header lied (reverse proxy, port forward) — that port is
|
||
// not this ProdDash. Go back to the guess and never trust it again.
|
||
badPorts.add(port);
|
||
nnPort = guessedPort;
|
||
log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`);
|
||
}
|
||
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 a Client ID + Secret (or OAuth token) in /admin';
|
||
log(state.lastError);
|
||
} else if (!serviceTypeId) {
|
||
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
||
log(state.lastError);
|
||
} else {
|
||
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||
schedulePoll(0);
|
||
}
|
||
log(
|
||
`service timing: pre-roll ${Math.round(timing.preRollMs / MINUTE)} min; start by ${
|
||
timing.startMode === 'document'
|
||
? `document "${timing.startDocument}" (failsafe ${Math.round(timing.startGraceMs / MINUTE)} min after the scheduled start)`
|
||
: 'the scheduled time'
|
||
}; end ${timing.endDocument ? `by document "${timing.endDocument}"` : 'at the next service / plan end'}`
|
||
);
|
||
if (wantsDocumentStart && !startDocument) {
|
||
log('service start is set to "ProPresenter document" but no start document is chosen — starting by the scheduled time instead');
|
||
}
|
||
if (wantFeed) {
|
||
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||
connectNowNext();
|
||
}
|
||
mirrorService();
|
||
|
||
const heartbeat = setInterval(() => {
|
||
writeAll(streams, ': ping\n\n');
|
||
writeAll(timerStreams, ': ping\n\n');
|
||
}, HEARTBEAT_MS);
|
||
heartbeat.unref?.();
|
||
|
||
// The clock moves services along on its own: scheduled starts, window
|
||
// flips, failsafes, the countdown reaching zero. Every step is guarded —
|
||
// timing can never take the plan display down.
|
||
const ticker = setInterval(() => {
|
||
if (stopped) return;
|
||
syncServiceSafe(Date.now(), null);
|
||
try {
|
||
broadcast();
|
||
broadcastTimers();
|
||
} catch (err) {
|
||
const msg = err?.message || String(err);
|
||
if (msg !== lastTimingError) {
|
||
lastTimingError = msg;
|
||
log(`broadcast error: ${msg}`);
|
||
}
|
||
}
|
||
}, 1000);
|
||
ticker.unref?.();
|
||
|
||
current = {
|
||
streams,
|
||
timerStreams,
|
||
pco,
|
||
getState: () => state,
|
||
learnPort,
|
||
refresh() {
|
||
clearTimeout(pollTimer);
|
||
return poll();
|
||
},
|
||
listPlans,
|
||
pickPlan,
|
||
timersSnapshot,
|
||
documentOptions,
|
||
noteCategoryOptions,
|
||
teamOptions,
|
||
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
|
||
};
|
||
|
||
return {
|
||
stop() {
|
||
stopped = true;
|
||
clearInterval(heartbeat);
|
||
clearInterval(ticker);
|
||
clearTimeout(pollTimer);
|
||
clearTimeout(nnRetryTimer);
|
||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||
for (const set of [streams, timerStreams]) {
|
||
for (const res of set) {
|
||
try { res.end(); } catch { /* already gone */ }
|
||
}
|
||
set.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 — pick one in the Service type list below' };
|
||
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 service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : '';
|
||
const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||
return { status: 'ok', message: `Following ${name}${plan}${service}${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));
|
||
};
|
||
const sseHead = (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');
|
||
};
|
||
|
||
return {
|
||
'GET /state': (req, res) => {
|
||
current?.learnPort(req.headers.host);
|
||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||
},
|
||
|
||
'GET /stream': (req, res) => {
|
||
current?.learnPort(req.headers.host);
|
||
sseHead(res);
|
||
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) })
|
||
);
|
||
},
|
||
|
||
/* — timers for the Timers module (module guide: "Offering timers") — */
|
||
|
||
'GET /timers': (req, res) => {
|
||
const now = Date.now();
|
||
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
|
||
},
|
||
|
||
'GET /timers/stream': (req, res) => {
|
||
sseHead(res);
|
||
const now = Date.now();
|
||
res.write(`event: timers\ndata: ${JSON.stringify(current ? current.timersSnapshot(now) : emptyTimers(now))}\n\n`);
|
||
if (!current) return void res.end();
|
||
const { timerStreams } = current;
|
||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||
timerStreams.add(res);
|
||
req.on('close', () => timerStreams.delete(res));
|
||
},
|
||
|
||
/**
|
||
* Options for a Teams tile's "Teams" switches. Like the note
|
||
* categories, always a 200 with a list: an empty one simply means there
|
||
* is nothing scheduled to choose from yet, which the gear window says
|
||
* plainly.
|
||
*/
|
||
'GET /teams': (req, res) => {
|
||
json(res, 200, current ? current.teamOptions() : { options: [] });
|
||
},
|
||
|
||
/**
|
||
* A scheduled person's photo, proxied from Planning Center and cached.
|
||
* The tile asks by plan-person id and never sees the CDN URL; an id the
|
||
* module does not know (an old tile, a person since unscheduled) is a
|
||
* 404 and the tile falls back to initials.
|
||
*/
|
||
'GET /team-avatar/*': (req, res) => {
|
||
const id = String(req.wildcard || '').replace(/^\/+/, '');
|
||
const url = current ? current.avatarUrlFor(id) : '';
|
||
serveAvatar(res, url);
|
||
},
|
||
|
||
/** Options for the admin "Start document" / "End document" selects. */
|
||
'GET /documents': (req, res) => {
|
||
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
|
||
json(res, 200, current.documentOptions());
|
||
},
|
||
|
||
/**
|
||
* Options for a tile's "Item notes" → Categories switches: the
|
||
* configured service type's item note categories, by name. Always a
|
||
* 200 with a list — empty while the module is unconfigured, not
|
||
* running or cannot reach Planning Center — because the gear window
|
||
* has nothing better to do with an error than show an empty list.
|
||
*/
|
||
'GET /note-categories': async (req, res) => {
|
||
let options = [];
|
||
try {
|
||
if (current) options = await current.noteCategoryOptions();
|
||
} catch (err) {
|
||
log(`note categories: ${err?.message || err}`);
|
||
}
|
||
json(res, 200, { options });
|
||
},
|
||
|
||
/**
|
||
* Options for the admin page's "Service type" select: every service
|
||
* type, grouped by its Planning Center folder path — so the folders
|
||
* are browsable as <optgroup>s right in the admin form.
|
||
*/
|
||
'GET /service-types': async (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const { pco } = current;
|
||
if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||
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: '' });
|
||
}
|
||
}
|
||
// "Campuses › North Campus" — the folder path from the root down.
|
||
const pathOf = (parentId) => {
|
||
const names = [];
|
||
let id = parentId;
|
||
const seen = new Set();
|
||
while (id && folders.has(id) && !seen.has(id)) {
|
||
seen.add(id);
|
||
names.unshift(folders.get(id).name);
|
||
id = folders.get(id).parentId;
|
||
}
|
||
return names.join(' › ');
|
||
};
|
||
const options = serviceTypes
|
||
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
|
||
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||
json(res, 200, { options });
|
||
} catch (err) {
|
||
log(`service type list failed: ${err?.message || err}`);
|
||
json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Options for the admin page's "Plan" select, for the service type the
|
||
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
|
||
* plans, with the one Automatic would choose marked.
|
||
*/
|
||
'GET /plans': async (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const { pco } = current;
|
||
const params = new URLSearchParams(req.search || '');
|
||
const stId = String(params.get('serviceTypeId') || '').trim();
|
||
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||
try {
|
||
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
||
const picked = current.pickPlan(plans);
|
||
const now = Date.now();
|
||
const options = [auto];
|
||
// Upcoming first (soonest at the top), then the recent ones.
|
||
const ordered = [
|
||
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||
];
|
||
for (const { rec, sortDate } of ordered) {
|
||
const a = rec.attributes || {};
|
||
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
|
||
if (a.series_title) bits.push(String(a.series_title));
|
||
if (a.title) bits.push(String(a.title));
|
||
let label = bits.join(' — ');
|
||
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
||
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
||
}
|
||
json(res, 200, { options });
|
||
} catch (err) {
|
||
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|