2026-09-09 15:42:53 -04:00
'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)
2026-09-27 08:31:26 -04:00
* plus `ableset` events: the song AbleSet is playing, its
* sections and playhead, when there is an AbleSet module
* to follow (see below). Its own event so the playhead
* never drags the plan onto the wire with it.
2026-09-09 15:42:53 -04:00
* POST /refresh → re-read the plan now
2026-09-10 15:31:12 -04:00
* 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
2026-09-13 12:58:12 -04:00
* GET /documents → { options } for the admin start / end document selects
* (documents ProPresenter has shown or lists — see below)
2026-09-14 15:35:47 -04:00
* GET /note-categories → { options } for a tile's "Item notes" categories:
* the service type's item note categories, by name
2026-09-24 12:42:57 -04:00
* GET /teams → { options } for a Teams tile's "Teams" switches
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
2026-09-13 12:58:12 -04:00
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
2026-09-09 15:42:53 -04:00
*
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
2026-09-13 12:58:12 -04:00
* Auth: Personal Access Token (HTTP Basic client_id:secret) or an OAuth 2.0
2026-09-09 15:42:53 -04:00
* 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
2026-09-14 15:35:47 -04:00
* GET /service_types/{st}/item_note_categories (note category names)
2026-09-24 12:42:57 -04:00
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
2026-09-09 15:42:53 -04:00
*
2026-09-27 08:31:26 -04:00
* Following AbleSet: the same loopback trick, entirely optional. When this
* ProdDash has an AbleSet module that is configured and answering, its song,
* sections and playhead are passed to the tiles, and the song is matched to a
* plan item by name so a tile can fill that item as the song plays. With no
* such module — not installed, disabled, no host — tiles are told
* `available: false` and draw none of it.
*
2026-09-13 12:58:12 -04:00
* 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
2026-09-22 13:06:53 -04:00
* 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.
2026-09-13 12:58:12 -04:00
*
* 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.
2026-09-09 15:42:53 -04:00
*
* 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' );
2026-09-09 15:45:55 -04:00
const os = require ( 'os' );
2026-09-09 15:42:53 -04:00
const path = require ( 'path' );
2026-09-27 08:31:26 -04:00
const { placeDocument , messageItemOf , playlistMirrorsPlan , similarity , MATCH_THRESHOLD } = require ( './matching' );
2026-09-13 12:58:12 -04:00
const {
MINUTE ,
serviceTimes ,
serviceWindows ,
selectService ,
freshService ,
advance ,
fmtTime ,
} = require ( './service-timing' );
2026-09-09 15:42:53 -04:00
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 ;
2026-09-27 08:31:26 -04:00
/** The AbleSet module, followed the same way — entirely optional (see below). */
const ABLESET_ID = 'ableset' ;
const ABLESET_RETRY_MS = 3000 ;
const ABLESET_MISSING_RETRY_MS = 15000 ;
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
const ABLESET_POS_MS = 200 ;
2026-09-09 15:42:53 -04:00
const NOW_NEXT_MISSING_RETRY_MS = 15000 ;
2026-09-13 12:58:12 -04:00
/** How many distinct documents seen live are remembered for the admin selects. */
const SEEN_DOCUMENTS_MAX = 200 ;
2026-09-24 12:42:57 -04:00
/**
* 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 ;
2026-09-14 15:35:47 -04:00
/** How long a service type's item note categories are believed before Planning Center is asked again. */
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE ;
2026-09-13 12:58:12 -04:00
/**
* 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 ;
2026-09-09 15:42:53 -04:00
/** 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 : [],
2026-09-24 12:42:57 -04:00
teams : [], // who is scheduled, by team (the Teams tile)
teamsError : '' , // why the last team read failed, if it did
2026-09-09 15:42:53 -04:00
live : clearedLive (),
2026-09-13 12:58:12 -04:00
service : clearedService (),
timing : null , // the admin's service-timing choices (no secrets)
2026-09-09 15:42:53 -04:00
};
}
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 ,
2026-09-13 12:58:12 -04:00
serviceStartedAt : 0 , // the service's actual start, ms epoch (0 until it starts)
serviceEndedAt : 0 , // …and its end, once it has one
2026-09-09 15:42:53 -04:00
history : {}, // itemId → { startedAt, endedAt }
};
}
2026-09-27 08:31:26 -04:00
/**
* What tiles are told about AbleSet. `available` false covers every way there
* may be nothing to draw — no module installed, disabled, or not answering —
* and the tiles then show none of it at all.
*/
function clearedAbleSet () {
return {
available : false ,
configured : false ,
reachable : false ,
reason : '' ,
playing : false ,
tempo : null ,
song : null ,
section : null ,
sections : [],
beats : null ,
itemId : '' ,
at : 0 ,
};
}
2026-09-13 12:58:12 -04:00
/** 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 ,
};
}
2026-09-09 15:45:55 -04:00
/**
2026-09-13 12:58:12 -04:00
* 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 —
2026-09-09 15:45:55 -04:00
* PORT env, then this machine's proddash.json in the data directory, then the
2026-09-13 12:58:12 -04:00
* 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).
2026-09-09 15:45:55 -04:00
*/
function guessShellPort () {
2026-09-09 15:42:53 -04:00
const env = Number ( process . env . PORT );
if ( env ) return env ;
2026-09-09 15:45:55 -04:00
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 */ }
}
2026-09-09 15:42:53 -04:00
return 24500 ;
}
2026-09-09 15:45:55 -04:00
/** 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 ;
}
2026-09-09 15:42:53 -04:00
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 } ` ;
}
2026-09-13 12:58:12 -04:00
/** "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' ) } ` ;
2026-09-09 15:42:53 -04:00
}
/* ── 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 ;
}
}
2026-09-13 12:58:12 -04:00
/** 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 );
}
2026-09-09 15:42:53 -04:00
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 : '' ,
};
}
2026-09-24 12:42:57 -04:00
/* ── 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' );
}
}
2026-09-13 12:58:12 -04:00
/** "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 : [] };
}
2026-09-09 15:42:53 -04:00
/* ── the module ─────────────────────────────────────────────────────── */
module . exports = {
2026-09-13 12:58:12 -04:00
init ({ config , log , shell }) {
const streams = new Set (); // /stream clients (tiles)
const timerStreams = new Set (); // /timers/stream clients (the Timers module)
2026-09-09 15:42:53 -04:00
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 ;
2026-09-13 12:58:12 -04:00
// 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 ;
2026-09-09 15:42:53 -04:00
let state = clearedState ();
state . configured = pco . configured ;
state . serviceTypeSet = Boolean ( serviceTypeId );
state . serviceType = { id : serviceTypeId , name : '' };
state . live . following = follow ;
2026-09-13 12:58:12 -04:00
state . timing = { ... timing };
2026-09-09 15:42:53 -04:00
let stopped = false ;
let pollTimer = null ;
let inFlight = null ;
let lastFrame = '' ;
2026-09-13 12:58:12 -04:00
let lastTimersSig = '' ;
2026-09-22 13:06:53 -04:00
let lastMessageItemId = null ;
let lastMatchError = '' ;
2026-09-09 15:42:53 -04:00
/* — broadcasting — */
function frameOf () {
return JSON . stringify ( state );
}
2026-09-13 12:58:12 -04:00
function writeAll ( set , payload ) {
for ( const res of set ) {
try {
res . write ( payload );
} catch {
set . delete ( res );
}
}
}
2026-09-09 15:42:53 -04:00
function broadcast ( force ) {
const frame = frameOf ();
if ( ! force && frame === lastFrame ) return ;
lastFrame = frame ;
if ( ! streams . size ) return ;
2026-09-13 12:58:12 -04:00
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` );
2026-09-09 15:42:53 -04:00
}
/* — plan reading — */
function planPath ( planId ) {
return ` ${ SERVICES } /service_types/ ${ encodeURIComponent ( serviceTypeId ) } /plans/ ${ encodeURIComponent ( planId ) } ` ;
}
2026-09-10 15:31:12 -04:00
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
async function listPlans ( stId , { future = 5 , past = 3 } = {}) {
2026-09-09 15:42:53 -04:00
const base = ` ${ SERVICES } /service_types/ ${ encodeURIComponent ( stId ) } /plans` ;
2026-09-10 15:31:12 -04:00
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 }),
2026-09-09 15:42:53 -04:00
]);
const byId = new Map ();
2026-09-10 15:31:12 -04:00
for ( const rec of [...( fut ? . data || []), ...( pst ? . data || [])]) {
2026-09-09 15:42:53 -04:00
const sortDate = Date . parse ( rec ? . attributes ? . sort_date || '' );
if ( ! rec ? . id || ! Number . isFinite ( sortDate )) continue ;
byId . set ( String ( rec . id ), { rec , sortDate });
}
2026-09-10 15:31:12 -04:00
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 ) {
2026-09-09 15:42:53 -04:00
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 ;
}
2026-09-10 15:31:12 -04:00
async function choosePlanId ( stId ) {
return pickPlan ( await listPlans ( stId ));
}
2026-09-09 15:42:53 -04:00
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 ;
}
}
2026-09-24 12:42:57 -04:00
/* — 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 })) };
}
2026-09-09 15:42:53 -04:00
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 ) {
2026-09-13 12:58:12 -04:00
if ( state . plan ) log ( 'no plan found any more — service timing reset' );
2026-09-09 15:42:53 -04:00
state . plan = null ;
state . items = [];
2026-09-24 12:42:57 -04:00
state . teams = [];
state . teamsError = "" ;
2026-09-09 15:42:53 -04:00
state . reachable = true ;
state . lastError = '' ;
state . lastFetched = Date . now ();
2026-09-13 12:58:12 -04:00
syncServiceSafe ( Date . now (), null );
2026-09-09 15:42:53 -04:00
return ;
}
const [ planTimes , items ] = await Promise . all ([ readPlanTimes ( planRec . id ), readItems ( planRec . id )]);
const plan = shapePlan ( planRec , serviceTypeId , planTimes );
2026-09-13 12:58:12 -04:00
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` );
2026-09-09 15:42:53 -04:00
else log ( `following plan ${ plan . id } : ${ plan . dates } ${ plan . title || '' } ` . trim ());
}
state . plan = plan ;
state . items = items ;
2026-09-22 13:06:53 -04:00
noteMessageItem ( items );
2026-09-24 12:42:57 -04:00
// The roster rides its own slow lane and never fails the plan read.
await refreshTeams ( plan . id , planChanged );
2026-09-09 15:42:53 -04:00
state . reachable = true ;
state . lastError = '' ;
state . lastFetched = Date . now ();
2026-09-13 12:58:12 -04:00
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 );
2026-09-09 15:42:53 -04:00
}
2026-09-22 13:06:53 -04:00
/** 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' );
}
2026-09-14 15:35:47 -04:00
/* — 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 ;
}
}
2026-09-09 15:42:53 -04:00
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 ();
2026-09-13 12:58:12 -04:00
broadcastTimers ();
2026-09-09 15:42:53 -04:00
schedulePoll ( delay );
})();
try {
await inFlight ;
} finally {
inFlight = null ;
}
}
2026-09-13 12:58:12 -04:00
/* — 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 ;
2026-09-13 13:12:04 -04:00
// The live item is the headline; without one (not following
// ProPresenter, nothing matched yet) say how far behind, else when
// the service began.
2026-09-13 12:58:12 -04:00
service . status = {
2026-09-13 13:12:04 -04:00
text : itemTitle || ( behind >= BEHIND_WARN_MS ? `Behind ${ fmtDur ( behind ) } ` : `Started ${ fmtTime ( svc . startedAt ) } ` ),
2026-09-13 12:58:12 -04:00
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 ;
2026-09-13 13:12:04 -04:00
// 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 ;
2026-09-13 12:58:12 -04:00
currentItem . status = {
text : itemTitle ,
2026-09-13 13:12:04 -04:00
tone : stale ? 'muted' : over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok' ,
2026-09-13 12:58:12 -04:00
};
2026-09-13 13:12:04 -04:00
currentItem . detail = stale ? ` ${ itemDetail } · ProPresenter unreachable` : itemDetail ;
2026-09-13 12:58:12 -04:00
} 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 — */
2026-09-09 15:42:53 -04:00
let nnRequest = null ;
let nnRetryTimer = null ;
let lastLiveName = null ;
2026-09-13 12:58:12 -04:00
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 ();
2026-09-09 15:45:55 -04:00
let nnPort = guessedPort ;
/** Learned ports that refused the loopback connection (a reverse proxy's port, say). */
const badPorts = new Set ();
2026-09-13 12:58:12 -04:00
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
2026-09-27 08:31:26 -04:00
/* — AbleSet, over loopback like the ProPresenter feed above — */
/**
* The AbleSet module, if this ProdDash has one. Nothing here is required:
* a dashboard with no AbleSet module, a disabled one, or one with no host
* configured simply reports `available: false` and the tiles draw none of
* it. Installing AbleSet later lights it up without a restart, because a
* 404 is retried on the slow timer like any other missing feed.
*
* What tiles get is deliberately small — the song, its sections, and the
* playhead in beats with a stamp — because it moves five times a second
* and must never drag the plan (items, teams) onto the wire with it. It
* rides its own `ableset` event on the module's one stream.
*/
let absRequest = null ;
let absRetryTimer = null ;
let absPosTimer = null ;
let absLastSent = 0 ;
let absLastStill = '' ;
let abs = clearedAbleSet ();
function absPart ( p ) {
if ( ! p || typeof p !== 'object' ) return null ;
const name = String ( p . name || '' ). trim ();
if ( ! name ) return null ;
const n = ( v ) => ( Number . isFinite ( Number ( v )) ? Number ( v ) : null );
return { name , index : n ( p . index ), start : n ( p . start ), end : n ( p . end ) };
}
/**
* Which plan item AbleSet's song is, by the same name matching the live
* ProPresenter document uses. Without this a progress bar would happily
* fill the sermon while the band rehearses the next song: the tile only
* draws progress when the song AbleSet is playing IS the item on screen.
*/
function absItemFor ( songName ) {
if ( ! songName ) return '' ;
let best = null ;
for ( const item of state . items ) {
if ( item . itemType === 'header' ) continue ;
const score = Math . max (
similarity ( songName , item . title ),
item . song ? similarity ( songName , item . song . title ) : 0
);
if ( score >= MATCH_THRESHOLD && ( ! best || score > best . score )) best = { id : item . id , score };
}
return best ? best . id : '' ;
}
function applyAbleSet ( view ) {
const song = absPart ( view ? . song );
abs = {
available : true ,
configured : Boolean ( view ? . configured ),
reachable : Boolean ( view ? . reachable ),
playing : Boolean ( view ? . playing ),
tempo : Number . isFinite ( Number ( view ? . tempo )) ? Number ( view . tempo ) : null ,
song ,
section : absPart ( view ? . section ),
sections : Array . isArray ( view ? . sections )
? view . sections . slice ( 0 , 60 ). map (( s ) => ({ name : String ( s ? . name || '' ), color : s ? . color || null }))
: [],
beats : Number . isFinite ( Number ( view ? . pos ? . beats )) ? Number ( view . pos . beats ) : null ,
itemId : song ? absItemFor ( song . name ) : '' ,
at : Date . now (),
};
broadcastAbleSet ();
}
/** Only the playhead moved: keep everything else and restamp. */
function applyAbleSetPos ( pos ) {
if ( ! abs . available ) return ;
abs = {
... abs ,
beats : Number . isFinite ( Number ( pos ? . beats )) ? Number ( pos . beats ) : abs . beats ,
at : Date . now (),
};
broadcastAbleSet ();
}
/**
* Fan the slice out. A change in anything but the playhead goes at once;
* the playhead itself is throttled, since AbleSet offers more packets
* than any tile can draw.
*/
function broadcastAbleSet ( force ) {
const { beats , at , ... still } = abs ;
const frame = JSON . stringify ( still );
const changed = frame !== absLastStill ;
absLastStill = frame ;
const now = Date . now ();
if ( ! force && ! changed && now - absLastSent < ABLESET_POS_MS ) {
// Due soon anyway — make sure the last packet of a burst still lands.
if ( ! absPosTimer ) {
absPosTimer = setTimeout (() => { absPosTimer = null ; broadcastAbleSet ( true ); }, ABLESET_POS_MS );
absPosTimer . unref ? .();
}
return ;
}
clearTimeout ( absPosTimer );
absPosTimer = null ;
absLastSent = now ;
if ( ! streams . size ) return ;
writeAll ( streams , `event: ableset\ndata: ${ JSON . stringify ( abs ) } \n\n` );
}
function absUnavailable ( reason , retryMs ) {
if ( abs . available || abs . reason !== reason ) {
abs = { ... clearedAbleSet (), reason };
broadcastAbleSet ( true );
}
if ( stopped ) return ;
clearTimeout ( absRetryTimer );
absRetryTimer = setTimeout ( connectAbleSet , retryMs );
absRetryTimer . unref ? .();
}
function connectAbleSet () {
if ( stopped ) return ;
const port = nnPort ;
const req = http . get (
{ host : '127.0.0.1' , port , path : `/api/modules/ ${ ABLESET_ID } /stream` , headers : { Accept : 'text/event-stream' } },
( res ) => {
if ( res . statusCode !== 200 ) {
res . resume ();
return absUnavailable (
res . statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${ res . statusCode } ` ,
ABLESET_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 ( ! data ) continue ;
try {
if ( event === 'state' ) applyAbleSet ( JSON . parse ( data ). state ?? JSON . parse ( data ));
else if ( event === 'pos' ) applyAbleSetPos ( JSON . parse ( data ));
} catch { /* a bad frame is not fatal */ }
}
});
res . on ( 'end' , () => absUnavailable ( 'AbleSet module feed ended' , ABLESET_RETRY_MS ));
res . on ( 'error' , () => absUnavailable ( 'AbleSet module feed dropped' , ABLESET_RETRY_MS ));
}
);
req . on ( 'error' , ( err ) => {
if ( req !== absRequest ) return ; // superseded by a reconnect on the learned port
absUnavailable ( `ProdDash loopback failed: ${ err . message } ` , ABLESET_RETRY_MS );
});
req . setTimeout ( 45000 , () => req . destroy ( new Error ( 'feed went silent' )));
absRequest = req ;
}
2026-09-09 15:45:55 -04:00
function learnPort ( hostHeader ) {
2026-09-13 12:58:12 -04:00
if ( shellPort ) return ; // the shell told us; nothing to learn
2026-09-09 15:45:55 -04:00
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 ;
2026-09-13 12:58:12 -04:00
if ( ! wantFeed || stopped ) return ;
2026-09-09 15:45:55 -04:00
clearTimeout ( nnRetryTimer );
try { nnRequest ? . destroy (); } catch { /* gone */ }
nnRequest = null ;
connectNowNext ();
2026-09-27 08:31:26 -04:00
clearTimeout ( absRetryTimer );
try { absRequest ? . destroy (); } catch { /* gone */ }
absRequest = null ;
connectAbleSet ();
2026-09-09 15:45:55 -04:00
}
2026-09-09 15:42:53 -04:00
2026-09-13 12:58:12 -04:00
/**
* 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.
*/
2026-09-09 15:42:53 -04:00
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 ,
};
2026-09-13 12:58:12 -04:00
lastLiveName = keep . activeName || '' ;
if ( follow && keep . activeName ) {
2026-09-22 13:06:53 -04:00
const hit = matchItem ( keep . activeName , hintFrom ( lastView ));
2026-09-13 12:58:12 -04:00
state . live . matched = Boolean ( hit );
if ( hit ) {
state . live . currentScore = hit . score ;
2026-09-22 13:06:53 -04:00
startItem ( hit . item . id , keep . activeName , hit );
2026-09-13 12:58:12 -04:00
}
}
2026-09-09 15:42:53 -04:00
}
2026-09-22 13:06:53 -04:00
/**
* 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 ) };
2026-09-09 15:42:53 -04:00
}
2026-09-13 12:58:12 -04:00
/** 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 };
}
2026-09-09 15:42:53 -04:00
function applyLiveView ( view ) {
2026-09-13 12:58:12 -04:00
lastView = view && typeof view === 'object' ? view : null ;
2026-09-09 15:42:53 -04:00
const live = state . live ;
2026-09-22 13:06:53 -04:00
if ( ! live . available ) log ( 'following the ProPresenter module feed' );
2026-09-09 15:42:53 -04:00
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 ();
2026-09-13 12:58:12 -04:00
broadcastTimers ();
2026-09-09 15:42:53 -04:00
return ;
}
2026-09-13 12:58:12 -04:00
if ( name === lastLiveName ) {
broadcast (); // reachability may have changed; the frame check keeps it quiet otherwise
return ;
2026-09-09 15:42:53 -04:00
}
2026-09-13 12:58:12 -04:00
rememberDocument ( name );
if ( follow ) {
2026-09-22 13:06:53 -04:00
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 } ` );
}
2026-09-13 12:58:12 -04:00
}
}
2026-09-22 13:06:53 -04:00
lastLiveName = name ;
2026-09-13 12:58:12 -04:00
// The same document is also a possible service start / end cue.
syncServiceSafe ( Date . now (), name );
2026-09-09 15:42:53 -04:00
broadcast ();
2026-09-13 12:58:12 -04:00
broadcastTimers ();
2026-09-09 15:42:53 -04:00
}
2026-09-22 13:06:53 -04:00
/** Make an item current; `liveName` and `hit` say what placed it, for the log. */
function startItem ( itemId , liveName , hit ) {
2026-09-09 15:42:53 -04:00
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 );
2026-09-22 13:06:53 -04:00
const how = hit
? ` ( ${ hit . via }${ hit . via === 'name' ? ` ${ Number ( hit . score ). toFixed ( 2 ) } ` : '' }${ liveName ? `: " ${ liveName } "` : '' } )`
: '' ;
log ( `live item → ${ item ? . title || itemId }${ how } ` );
2026-09-09 15:42:53 -04:00
}
function nowNextUnavailable ( reason , retryMs ) {
const live = state . live ;
if ( live . available || live . reason !== reason ) {
2026-09-22 13:06:53 -04:00
// 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 } ` );
2026-09-09 15:42:53 -04:00
live . available = false ;
live . reason = reason ;
live . ppReachable = false ;
broadcast ();
2026-09-13 12:58:12 -04:00
broadcastTimers ();
2026-09-09 15:42:53 -04:00
}
if ( stopped ) return ;
clearTimeout ( nnRetryTimer );
nnRetryTimer = setTimeout ( connectNowNext , retryMs );
nnRetryTimer . unref ? .();
}
function connectNowNext () {
2026-09-13 12:58:12 -04:00
if ( stopped || ! wantFeed ) return ;
2026-09-09 15:45:55 -04:00
const port = nnPort ;
2026-09-09 15:42:53 -04:00
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 ));
}
);
2026-09-09 15:45:55 -04:00
req . on ( 'error' , ( err ) => {
if ( req !== nnRequest ) return ; // superseded by a reconnect on the learned port
2026-09-13 12:58:12 -04:00
if ( ! shellPort && port !== guessedPort ) {
2026-09-09 15:45:55 -04:00
// 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 );
});
2026-09-09 15:42:53 -04:00
req . setTimeout ( 45000 , () => req . destroy ( new Error ( 'feed went silent' )));
nnRequest = req ;
}
/* — start — */
if ( ! pco . configured ) {
2026-09-13 12:58:12 -04:00
state . lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin' ;
2026-09-09 15:42:53 -04:00
log ( state . lastError );
} else if ( ! serviceTypeId ) {
2026-09-10 15:31:12 -04:00
state . lastError = 'No service type selected — choose one under PCO Plan in /admin' ;
2026-09-09 15:42:53 -04:00
log ( state . lastError );
} else {
log ( `reading service type ${ serviceTypeId } from ${ API_BASE } ( ${ pco . method } ) ${ pinnedPlanId ? `, plan ${ pinnedPlanId } pinned` : '' } ` );
schedulePoll ( 0 );
}
2026-09-13 12:58:12 -04:00
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 ) {
2026-09-09 15:42:53 -04:00
state . live . reason = 'Connecting to the ProPresenter module feed' ;
connectNowNext ();
}
2026-09-27 08:31:26 -04:00
// Always: whether there is an AbleSet module to follow is its answer, not
// an admin setting here.
connectAbleSet ();
2026-09-13 12:58:12 -04:00
mirrorService ();
2026-09-09 15:42:53 -04:00
const heartbeat = setInterval (() => {
2026-09-13 12:58:12 -04:00
writeAll ( streams , ': ping\n\n' );
writeAll ( timerStreams , ': ping\n\n' );
2026-09-09 15:42:53 -04:00
}, HEARTBEAT_MS );
heartbeat . unref ? .();
2026-09-13 12:58:12 -04:00
// 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 ? .();
2026-09-09 15:42:53 -04:00
current = {
streams ,
2026-09-13 12:58:12 -04:00
timerStreams ,
2026-09-09 15:42:53 -04:00
pco ,
getState : () => state ,
2026-09-27 08:31:26 -04:00
getAbleSet : () => abs ,
2026-09-09 15:45:55 -04:00
learnPort ,
2026-09-09 15:42:53 -04:00
refresh () {
clearTimeout ( pollTimer );
return poll ();
},
2026-09-10 15:31:12 -04:00
listPlans ,
pickPlan ,
2026-09-13 12:58:12 -04:00
timersSnapshot ,
documentOptions ,
2026-09-14 15:35:47 -04:00
noteCategoryOptions ,
2026-09-24 12:42:57 -04:00
teamOptions ,
avatarUrlFor : ( id ) => teamPhotos . get ( String ( id )) || "" ,
2026-09-09 15:42:53 -04:00
};
return {
stop () {
stopped = true ;
clearInterval ( heartbeat );
2026-09-13 12:58:12 -04:00
clearInterval ( ticker );
2026-09-09 15:42:53 -04:00
clearTimeout ( pollTimer );
clearTimeout ( nnRetryTimer );
2026-09-27 08:31:26 -04:00
clearTimeout ( absRetryTimer );
clearTimeout ( absPosTimer );
2026-09-09 15:42:53 -04:00
try { nnRequest ? . destroy (); } catch { /* gone */ }
2026-09-27 08:31:26 -04:00
try { absRequest ? . destroy (); } catch { /* gone */ }
2026-09-13 12:58:12 -04:00
for ( const set of [ streams , timerStreams ]) {
for ( const res of set ) {
try { res . end (); } catch { /* already gone */ }
}
set . clear ();
2026-09-09 15:42:53 -04:00
}
if ( current && current . streams === streams ) current = null ;
},
health () {
if ( ! pco . configured ) return { status : 'error' , message : 'No Planning Center credentials configured' };
2026-09-10 15:31:12 -04:00
if ( ! serviceTypeId ) return { status : 'error' , message : 'No service type selected — pick one in the Service type list below' };
2026-09-09 15:42:53 -04:00
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' ;
2026-09-13 12:58:12 -04:00
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 } ` };
2026-09-09 15:42:53 -04:00
},
};
},
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 ));
};
2026-09-13 12:58:12 -04:00
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' );
};
2026-09-09 15:42:53 -04:00
return {
'GET /state' : ( req , res ) => {
2026-09-09 15:45:55 -04:00
current ? . learnPort ( req . headers . host );
2026-09-27 08:31:26 -04:00
json ( res , 200 , {
state : current ? current . getState () : clearedState (),
ableset : current ? current . getAbleSet () : clearedAbleSet (),
now : Date . now (),
});
2026-09-09 15:42:53 -04:00
},
'GET /stream' : ( req , res ) => {
2026-09-09 15:45:55 -04:00
current ? . learnPort ( req . headers . host );
2026-09-13 12:58:12 -04:00
sseHead ( res );
2026-09-09 15:42:53 -04:00
const state = current ? current . getState () : clearedState ();
res . write ( `event: state\ndata: ${ JSON . stringify ({ state , now : Date . now () } )}\n\n` );
2026-09-27 08:31:26 -04:00
res . write ( `event: ableset\ndata: ${ JSON . stringify ( current ? current . getAbleSet () : clearedAbleSet ()) } \n\n` );
2026-09-09 15:42:53 -04:00
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 ) })
);
},
2026-09-13 12:58:12 -04:00
/* — 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 ));
},
2026-09-24 12:42:57 -04:00
/**
2026-09-24 15:15:34 -04:00
* 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.
2026-09-24 12:42:57 -04:00
*/
'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 );
},
2026-09-13 12:58:12 -04:00
/** 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 ());
},
2026-09-14 15:35:47 -04:00
/**
* 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 });
},
2026-09-10 15:31:12 -04:00
/**
* 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 ) => {
2026-09-09 15:42:53 -04:00
if ( ! current ) return json ( res , 503 , { error : 'Module not running.' });
const { pco } = current ;
2026-09-10 15:31:12 -04:00
if ( ! pco . configured ) return json ( res , 200 , { options : [], error : 'No Planning Center credentials yet.' });
2026-09-09 15:42:53 -04:00
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 : '' });
}
}
2026-09-10 15:31:12 -04:00
// "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 });
2026-09-09 15:42:53 -04:00
} catch ( err ) {
2026-09-10 15:31:12 -04:00
log ( `service type list failed: ${ err ? . message || err } ` );
json ( res , err ? . status === 401 ? 401 : 502 , { options : [], error : err ? . message || String ( err ) });
2026-09-09 15:42:53 -04:00
}
},
2026-09-10 15:31:12 -04:00
/**
* 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 ) => {
2026-09-09 15:42:53 -04:00
if ( ! current ) return json ( res , 503 , { error : 'Module not running.' });
2026-09-10 15:31:12 -04:00
const { pco } = current ;
2026-09-09 15:42:53 -04:00
const params = new URLSearchParams ( req . search || '' );
const stId = String ( params . get ( 'serviceTypeId' ) || '' ). trim ();
2026-09-10 15:31:12 -04:00
const auto = { value : '' , label : 'Automatic — today\'s plan, else the next upcoming' };
if ( ! pco . configured || ! stId ) return json ( res , 200 , { options : [ auto ] });
2026-09-09 15:42:53 -04:00
try {
2026-09-10 15:31:12 -04:00
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 });
2026-09-09 15:42:53 -04:00
} catch ( err ) {
2026-09-10 15:31:12 -04:00
json ( res , err ? . status === 401 ? 401 : 502 , { options : [ auto ], error : err ? . message || String ( err ) });
2026-09-09 15:42:53 -04:00
}
},
};
},
};